1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3
4const STORE_FILENAME: &str = "context_ir_v1.json";
5
6const MAX_ITEMS: usize = 128;
8const MAX_ITEM_CONTENT_CHARS: usize = 4096;
9const MAX_TOTAL_CONTENT_CHARS: usize = 65_536;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ContextIrV1 {
13 pub schema_version: u32,
14 pub created_at: String,
15 pub updated_at: String,
16 pub next_seq: u64,
17 pub totals: ContextIrTotalsV1,
18 pub items: Vec<ContextIrItemV1>,
19}
20
21#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22pub struct ContextIrTotalsV1 {
23 pub items_recorded: u64,
24 pub input_tokens: u64,
25 pub output_tokens: u64,
26 pub tokens_saved: u64,
27}
28
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ContextIrSourceKindV1 {
32 Read,
33 Shell,
34 Search,
35 Provider,
36 #[default]
37 Other,
38}
39
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
41pub struct ContextIrSourceV1 {
42 pub kind: ContextIrSourceKindV1,
43 pub tool: String,
44 pub client_name: Option<String>,
45 pub agent_id: Option<String>,
46 pub path: Option<String>,
47 pub command: Option<String>,
48 pub pattern: Option<String>,
49}
50
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52pub struct ContextIrSafetyV1 {
53 pub redacted: bool,
55 pub boundary_mode: Option<String>,
57}
58
59#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60pub struct ContextIrVerificationV1 {
61 pub content_md5: Option<String>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ContextIrItemV1 {
66 pub seq: u64,
67 pub created_at: String,
68 pub source: ContextIrSourceV1,
69 pub input_tokens: usize,
70 pub output_tokens: usize,
71 pub duration_us: u64,
72 pub compression_ratio: f64,
73 pub content_excerpt: String,
74 pub truncated: bool,
75 pub safety: ContextIrSafetyV1,
76 pub verification: ContextIrVerificationV1,
77}
78
79#[derive(Debug, Clone)]
80pub struct RecordIrInput<'a> {
81 pub kind: ContextIrSourceKindV1,
82 pub tool: &'a str,
83 pub client_name: Option<String>,
84 pub agent_id: Option<String>,
85 pub path: Option<&'a str>,
86 pub command: Option<&'a str>,
87 pub pattern: Option<&'a str>,
88 pub input_tokens: usize,
89 pub output_tokens: usize,
90 pub duration: std::time::Duration,
91 pub content_excerpt: &'a str,
92}
93
94impl ContextIrV1 {
95 pub fn new() -> Self {
96 let now = chrono::Utc::now().to_rfc3339();
97 Self {
98 schema_version: crate::core::contracts::CONTEXT_IR_V1_SCHEMA_VERSION,
99 created_at: now.clone(),
100 updated_at: now,
101 next_seq: 1,
102 totals: ContextIrTotalsV1::default(),
103 items: Vec::new(),
104 }
105 }
106
107 pub fn record(&mut self, input: RecordIrInput<'_>) {
108 let now = chrono::Utc::now().to_rfc3339();
109
110 let (content_excerpt, truncated) = bound_and_redact_excerpt(input.content_excerpt);
111 let command = input.command.map(crate::core::redaction::redact_text);
112 let pattern = input.pattern.map(crate::core::redaction::redact_text);
113
114 let ratio = if input.input_tokens == 0 {
115 1.0
116 } else {
117 input.output_tokens as f64 / input.input_tokens as f64
118 };
119
120 let content_md5 = if content_excerpt.trim().is_empty() {
121 None
122 } else {
123 Some(crate::core::hasher::hash_str(&content_excerpt))
124 };
125
126 let item = ContextIrItemV1 {
127 seq: self.next_seq,
128 created_at: now.clone(),
129 source: ContextIrSourceV1 {
130 kind: input.kind,
131 tool: input.tool.to_string(),
132 client_name: input.client_name,
133 agent_id: input.agent_id,
134 path: input.path.map(std::string::ToString::to_string),
135 command,
136 pattern,
137 },
138 input_tokens: input.input_tokens,
139 output_tokens: input.output_tokens,
140 duration_us: input.duration.as_micros() as u64,
141 compression_ratio: ratio,
142 content_excerpt,
143 truncated,
144 safety: ContextIrSafetyV1 {
145 redacted: true,
146 boundary_mode: Some(format!(
147 "{:?}",
148 crate::core::io_boundary::boundary_mode_effective(
149 &crate::core::roles::active_role()
150 )
151 )),
152 },
153 verification: ContextIrVerificationV1 { content_md5 },
154 };
155
156 self.next_seq = self.next_seq.saturating_add(1);
157 self.updated_at = now;
158
159 self.totals.items_recorded = self.totals.items_recorded.saturating_add(1);
160 self.totals.input_tokens = self
161 .totals
162 .input_tokens
163 .saturating_add(item.input_tokens as u64);
164 self.totals.output_tokens = self
165 .totals
166 .output_tokens
167 .saturating_add(item.output_tokens as u64);
168 self.totals.tokens_saved = self
169 .totals
170 .tokens_saved
171 .saturating_add(item.input_tokens.saturating_sub(item.output_tokens) as u64);
172
173 self.items.push(item);
174 self.prune_in_place();
175 }
176
177 fn prune_in_place(&mut self) {
178 while self.items.len() > MAX_ITEMS
179 || total_content_chars(&self.items) > MAX_TOTAL_CONTENT_CHARS
180 {
181 if self.items.is_empty() {
182 break;
183 }
184 self.items.remove(0);
185 }
186 }
187
188 pub fn save(&self) {
189 let _ = self.try_save();
190 }
191
192 pub fn try_save(&self) -> anyhow::Result<()> {
195 let dir = crate::core::paths::cache_dir().map_err(anyhow::Error::msg)?;
196 let json = serde_json::to_string_pretty(self)?;
197 let json = crate::core::redaction::redact_text(&json);
198 crate::config_io::write_atomic(&dir.join(STORE_FILENAME), &json).map_err(anyhow::Error::msg)
199 }
200
201 pub fn load() -> Self {
202 crate::core::paths::cache_dir()
203 .ok()
204 .map(|d| d.join(STORE_FILENAME))
205 .and_then(|p| std::fs::read_to_string(p).ok())
206 .and_then(|s| serde_json::from_str(&s).ok())
207 .unwrap_or_default()
208 }
209}
210
211impl Default for ContextIrV1 {
212 fn default() -> Self {
213 Self::new()
214 }
215}
216
217pub fn write_project_context_ir(
218 project_root: &Path,
219 ir: &ContextIrV1,
220 filename: Option<&str>,
221) -> Result<PathBuf, String> {
222 let ts = chrono::Utc::now().format("%Y-%m-%d_%H%M%S");
223 let name = filename.map_or_else(
224 || format!("context-ir-v1_{ts}.json"),
225 std::string::ToString::to_string,
226 );
227 let destination = crate::core::context_proof::proof_destination(project_root, &name)?;
228
229 let json = serde_json::to_string_pretty(ir).map_err(|e| e.to_string())?;
230 let json = crate::core::redaction::redact_text(&json);
231 destination.write(&json)
232}
233
234fn bound_and_redact_excerpt(s: &str) -> (String, bool) {
235 let redacted = crate::core::redaction::redact_text(s);
236 let mut out = redacted;
237 let truncated = out.chars().count() > MAX_ITEM_CONTENT_CHARS;
238 if truncated {
239 out = out.chars().take(MAX_ITEM_CONTENT_CHARS).collect();
240 }
241 (out, truncated)
242}
243
244fn total_content_chars(items: &[ContextIrItemV1]) -> usize {
245 items
246 .iter()
247 .map(|i| i.content_excerpt.chars().count())
248 .sum()
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn store_is_bounded() {
257 let mut ir = ContextIrV1::new();
258 let big = "x".repeat(MAX_ITEM_CONTENT_CHARS + 10);
259 for _ in 0..(MAX_ITEMS + 10) {
260 ir.record(RecordIrInput {
261 kind: ContextIrSourceKindV1::Read,
262 tool: "ctx_read",
263 client_name: None,
264 agent_id: None,
265 path: Some("src/lib.rs"),
266 command: None,
267 pattern: None,
268 input_tokens: 100,
269 output_tokens: 10,
270 duration: std::time::Duration::from_millis(1),
271 content_excerpt: &big,
272 });
273 }
274 assert!(ir.items.len() <= MAX_ITEMS);
275 assert!(total_content_chars(&ir.items) <= MAX_TOTAL_CONTENT_CHARS);
276 }
277
278 #[test]
279 fn excerpt_is_truncated() {
280 let (s, truncated) = bound_and_redact_excerpt(&"x".repeat(MAX_ITEM_CONTENT_CHARS + 1));
281 assert!(truncated);
282 assert!(s.chars().count() <= MAX_ITEM_CONTENT_CHARS);
283 }
284}