1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::context::ContextSource;
8use thndrs_agent::context::{ContextItem, ContextItemKind, ContextLedger, ContextVisibility};
9
10#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
12pub struct ContextSourceMeta {
13 pub path: String,
15 pub scope: String,
17 pub content_hash: u64,
19 pub truncated: bool,
21 pub byte_count: usize,
23}
24
25impl From<&ContextSource> for ContextSourceMeta {
26 fn from(source: &ContextSource) -> Self {
27 Self {
28 path: source.path.display().to_string(),
29 scope: source.scope.clone(),
30 content_hash: source.content_hash,
31 truncated: source.truncated,
32 byte_count: source.byte_count,
33 }
34 }
35}
36
37impl ContextSourceMeta {
38 pub fn from_source(source: &ContextSource) -> Self {
40 Self::from(source)
41 }
42}
43
44#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
46pub struct ContextItemMeta {
47 pub id: String,
49 pub kind: ContextItemKind,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub source_path: Option<String>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub scope: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub content_hash: Option<u64>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub artifact_handle: Option<String>,
63 pub byte_count: usize,
65 pub token_estimate: usize,
67 pub visibility: ContextVisibility,
69 #[serde(default)]
71 pub reason_code: String,
72 pub reason: String,
74}
75
76impl From<&ContextItem> for ContextItemMeta {
77 fn from(item: &ContextItem) -> Self {
78 Self {
79 id: item.id.clone(),
80 kind: item.kind.clone(),
81 source_path: item.source_path.as_ref().map(|path| path.display().to_string()),
82 scope: Some(item.scope.clone()),
83 content_hash: item.content_hash,
84 artifact_handle: item.artifact_handle.clone(),
85 byte_count: item.byte_count,
86 token_estimate: item.token_estimate,
87 visibility: item.visibility.clone(),
88 reason_code: item.reason_code.clone(),
89 reason: redact_audit_text(&item.reason),
90 }
91 }
92}
93
94#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
96pub struct ContextLedgerMeta {
97 pub items: Vec<ContextItemMeta>,
99 pub available_input: u64,
101 pub target: u64,
103 pub auto_compaction_threshold: u64,
105 pub used: u64,
107 #[serde(default, skip_serializing_if = "Vec::is_empty")]
109 pub diagnostics: Vec<ContextDiagnosticMeta>,
110}
111
112impl From<&ContextLedger> for ContextLedgerMeta {
113 fn from(ledger: &ContextLedger) -> Self {
114 Self {
115 items: ledger.items.iter().map(ContextItemMeta::from).collect(),
116 available_input: ledger.budget.available_input,
117 target: ledger.budget.target,
118 auto_compaction_threshold: ledger.budget.auto_compaction_threshold,
119 used: ledger.budget.used,
120 diagnostics: ledger
121 .diagnostics
122 .iter()
123 .map(|diagnostic| ContextDiagnosticMeta {
124 severity: diagnostic.severity.label().to_string(),
125 code: diagnostic.code.clone(),
126 message: redact_audit_text(&diagnostic.message),
127 })
128 .collect(),
129 }
130 }
131}
132
133#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
135pub struct ContextDiagnosticMeta {
136 pub severity: String,
138 pub code: String,
140 pub message: String,
142}
143
144#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
149pub struct SessionConfigMeta {
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub session_dir: Option<String>,
153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
155 pub files: Vec<SessionConfigFile>,
156 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
158 pub origins: BTreeMap<String, String>,
159 #[serde(default, skip_serializing_if = "Vec::is_empty")]
161 pub diagnostics: Vec<String>,
162 #[serde(default, skip_serializing_if = "Vec::is_empty")]
164 pub mcp_files: Vec<SessionConfigFile>,
165 #[serde(default, skip_serializing_if = "Vec::is_empty")]
167 pub mcp_diagnostics: Vec<String>,
168}
169
170#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
172pub struct SessionConfigFile {
173 pub path: String,
175 pub source: String,
177 pub sha256: String,
179}
180
181#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
183pub struct McpToolSessionMeta {
184 pub server_name: String,
186 pub original_tool_name: String,
188}
189
190#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
192pub struct AcpPermissionOptionRecord {
193 pub id: String,
195 pub name: String,
197 pub kind: String,
199}
200
201#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
203pub struct AcpSessionMetadata {
204 pub agent_name: String,
206 pub acp_session_id: String,
208 pub command: String,
210 pub protocol_version: String,
212 pub agent_info_name: Option<String>,
214 pub agent_info_version: Option<String>,
216 pub client_info_name: Option<String>,
218 pub client_info_version: Option<String>,
220}
221
222fn redact_audit_text(value: &str) -> String {
223 let tokens: Vec<&str> = value.split_whitespace().collect();
224 let mut redacted = Vec::with_capacity(tokens.len());
225 let mut index = 0;
226
227 while let Some(token) = tokens.get(index) {
228 if token.eq_ignore_ascii_case("bearer")
229 && tokens.get(index + 1).is_some_and(|next| {
230 next.trim_matches(|character: char| character.is_ascii_punctuation())
231 .len()
232 >= 10
233 })
234 {
235 redacted.push("Bearer [REDACTED]".to_string());
236 index += 2;
237 } else {
238 redacted.push(redact_audit_token(token));
239 index += 1;
240 }
241 }
242
243 redacted.join(" ")
244}
245
246fn redact_audit_token(token: &str) -> String {
247 let trimmed = token.trim_matches(|character: char| matches!(character, ',' | ';' | '(' | ')' | '[' | ']'));
248 let lower = trimmed.to_ascii_lowercase();
249
250 if trimmed.starts_with("sk-") && trimmed.len() >= 11 {
251 return token.replacen(trimmed, "sk-[REDACTED]", 1);
252 }
253 if lower.starts_with("bearer") && trimmed.len() >= 17 {
254 return "Bearer [REDACTED]".to_string();
255 }
256 if let Some((key, value)) = trimmed.split_once(['=', ':'])
257 && matches!(
258 key.to_ascii_lowercase().as_str(),
259 "password" | "passwd" | "api_key" | "apikey" | "access_token" | "secret"
260 )
261 && value.len() >= 4
262 {
263 return token.replacen(trimmed, &format!("{key}=[REDACTED]"), 1);
264 }
265
266 token.to_string()
267}