use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::context::ContextSource;
use thndrs_agent::context::{ContextItem, ContextItemKind, ContextLedger, ContextVisibility};
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ContextSourceMeta {
pub path: String,
pub scope: String,
pub content_hash: u64,
pub truncated: bool,
pub byte_count: usize,
}
impl From<&ContextSource> for ContextSourceMeta {
fn from(source: &ContextSource) -> Self {
Self {
path: source.path.display().to_string(),
scope: source.scope.clone(),
content_hash: source.content_hash,
truncated: source.truncated,
byte_count: source.byte_count,
}
}
}
impl ContextSourceMeta {
pub fn from_source(source: &ContextSource) -> Self {
Self::from(source)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ContextItemMeta {
pub id: String,
pub kind: ContextItemKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_hash: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_handle: Option<String>,
pub byte_count: usize,
pub token_estimate: usize,
pub visibility: ContextVisibility,
#[serde(default)]
pub reason_code: String,
pub reason: String,
}
impl From<&ContextItem> for ContextItemMeta {
fn from(item: &ContextItem) -> Self {
Self {
id: item.id.clone(),
kind: item.kind.clone(),
source_path: item.source_path.as_ref().map(|path| path.display().to_string()),
scope: Some(item.scope.clone()),
content_hash: item.content_hash,
artifact_handle: item.artifact_handle.clone(),
byte_count: item.byte_count,
token_estimate: item.token_estimate,
visibility: item.visibility.clone(),
reason_code: item.reason_code.clone(),
reason: redact_audit_text(&item.reason),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ContextLedgerMeta {
pub items: Vec<ContextItemMeta>,
pub available_input: u64,
pub target: u64,
pub auto_compaction_threshold: u64,
pub used: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<ContextDiagnosticMeta>,
}
impl From<&ContextLedger> for ContextLedgerMeta {
fn from(ledger: &ContextLedger) -> Self {
Self {
items: ledger.items.iter().map(ContextItemMeta::from).collect(),
available_input: ledger.budget.available_input,
target: ledger.budget.target,
auto_compaction_threshold: ledger.budget.auto_compaction_threshold,
used: ledger.budget.used,
diagnostics: ledger
.diagnostics
.iter()
.map(|diagnostic| ContextDiagnosticMeta {
severity: diagnostic.severity.label().to_string(),
code: diagnostic.code.clone(),
message: redact_audit_text(&diagnostic.message),
})
.collect(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ContextDiagnosticMeta {
pub severity: String,
pub code: String,
pub message: String,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct SessionConfigMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_dir: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files: Vec<SessionConfigFile>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub origins: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_files: Vec<SessionConfigFile>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_diagnostics: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct SessionConfigFile {
pub path: String,
pub source: String,
pub sha256: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct McpToolSessionMeta {
pub server_name: String,
pub original_tool_name: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct AcpPermissionOptionRecord {
pub id: String,
pub name: String,
pub kind: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct AcpSessionMetadata {
pub agent_name: String,
pub acp_session_id: String,
pub command: String,
pub protocol_version: String,
pub agent_info_name: Option<String>,
pub agent_info_version: Option<String>,
pub client_info_name: Option<String>,
pub client_info_version: Option<String>,
}
fn redact_audit_text(value: &str) -> String {
let tokens: Vec<&str> = value.split_whitespace().collect();
let mut redacted = Vec::with_capacity(tokens.len());
let mut index = 0;
while let Some(token) = tokens.get(index) {
if token.eq_ignore_ascii_case("bearer")
&& tokens.get(index + 1).is_some_and(|next| {
next.trim_matches(|character: char| character.is_ascii_punctuation())
.len()
>= 10
})
{
redacted.push("Bearer [REDACTED]".to_string());
index += 2;
} else {
redacted.push(redact_audit_token(token));
index += 1;
}
}
redacted.join(" ")
}
fn redact_audit_token(token: &str) -> String {
let trimmed = token.trim_matches(|character: char| matches!(character, ',' | ';' | '(' | ')' | '[' | ']'));
let lower = trimmed.to_ascii_lowercase();
if trimmed.starts_with("sk-") && trimmed.len() >= 11 {
return token.replacen(trimmed, "sk-[REDACTED]", 1);
}
if lower.starts_with("bearer") && trimmed.len() >= 17 {
return "Bearer [REDACTED]".to_string();
}
if let Some((key, value)) = trimmed.split_once(['=', ':'])
&& matches!(
key.to_ascii_lowercase().as_str(),
"password" | "passwd" | "api_key" | "apikey" | "access_token" | "secret"
)
&& value.len() >= 4
{
return token.replacen(trimmed, &format!("{key}=[REDACTED]"), 1);
}
token.to_string()
}