1use crate::core::context_ir::{ContextIrSourceKindV1, ContextIrV1, RecordIrInput};
12use crate::core::context_ledger::ContextLedger;
13use crate::core::heatmap;
14use crate::core::intent_engine::StructuredIntent;
15use crate::core::session::SessionState;
16use crate::core::stats;
17
18const TRAVERSAL_WINDOW: usize = 6;
22
23pub(crate) fn recent_working_set(session: &SessionState, current: &str) -> Vec<String> {
26 let mut out: Vec<String> = Vec::new();
27 for f in session.files_touched.iter().rev() {
28 if f.path == current || out.contains(&f.path) {
29 continue;
30 }
31 out.push(f.path.clone());
32 if out.len() >= TRAVERSAL_WINDOW {
33 break;
34 }
35 }
36 out
37}
38
39pub(crate) fn usable_root(root: Option<&str>) -> Option<&str> {
41 root.filter(|r| !r.trim().is_empty() && *r != ".")
42}
43
44fn ir_excerpt(text: &str) -> &str {
49 const MAX: usize = 200;
50 if text.len() <= MAX {
51 return text;
52 }
53 let mut end = MAX;
54 while end > 0 && !text.is_char_boundary(end) {
55 end -= 1;
56 }
57 &text[..end]
58}
59
60pub fn record_file_read(
67 path: &str,
68 mode: &str,
69 original_tokens: usize,
70 output_tokens: usize,
71 is_cache_hit: bool,
72 duration: std::time::Duration,
73 output_excerpt: &str,
74) {
75 let saved = original_tokens.saturating_sub(output_tokens);
76 let tool_key = format!("cli_{mode}");
77
78 stats::record(&tool_key, original_tokens, output_tokens);
79 heatmap::record_file_access(path, original_tokens, saved);
80 crate::core::savings_ledger::record_read_event(original_tokens, saved);
86
87 let mut learning_root = String::from(".");
91
92 if let Some(mut session) = SessionState::load_latest() {
93 session.touch_file(path, None, mode, original_tokens);
94 if is_cache_hit {
95 session.record_cache_hit();
96 }
97
98 if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
99 let touched: Vec<String> = session
100 .files_touched
101 .iter()
102 .map(|ft| ft.path.clone())
103 .collect();
104 let inferred = StructuredIntent::from_file_patterns(&touched);
105 if inferred.confidence >= 0.4 {
106 session.active_structured_intent = Some(inferred);
107 }
108 }
109
110 let project_root = session.project_root.clone();
111 if let Some(root) = usable_root(project_root.as_deref()) {
112 learning_root = root.to_string();
113 }
114 let calls = session.stats.total_tool_calls;
115
116 let working_set = recent_working_set(&session, path);
119
120 let _ = session.save();
121
122 if let Some(root) = usable_root(project_root.as_deref()) {
123 crate::core::cooccurrence::record_focus_access(root, path, &working_set);
124 }
125 maybe_consolidate(project_root.as_deref(), calls);
126 }
127
128 if std::path::Path::new(path).is_file() {
132 let mut ledger = ContextLedger::load();
133 ledger.record(path, mode, original_tokens, output_tokens);
134 ledger.save();
135 }
136
137 record_read_learning(
144 path,
145 mode,
146 original_tokens,
147 output_tokens,
148 is_cache_hit,
149 &learning_root,
150 );
151
152 let mut ir = ContextIrV1::load();
158 ir.record(RecordIrInput {
159 kind: ContextIrSourceKindV1::Read,
160 tool: "ctx_read",
161 client_name: None,
162 agent_id: None,
163 path: Some(path),
164 command: None,
165 pattern: Some(mode),
166 input_tokens: original_tokens,
167 output_tokens,
168 duration,
169 content_excerpt: ir_excerpt(output_excerpt),
170 });
171 ir.save();
172}
173
174fn record_read_learning(
181 path: &str,
182 resolved_mode: &str,
183 original_tokens: usize,
184 output_tokens: usize,
185 is_cache_hit: bool,
186 project_root: &str,
187) {
188 let sig = crate::core::mode_predictor::FileSignature::from_path(path, original_tokens);
191 let density = if output_tokens > 0 {
192 original_tokens as f64 / output_tokens as f64
193 } else {
194 1.0
195 };
196 let outcome = crate::core::mode_predictor::ModeOutcome {
197 mode: resolved_mode.to_string(),
198 tokens_in: original_tokens,
199 tokens_out: output_tokens,
200 density: density.min(1.0),
201 };
202 let mut predictor = crate::core::mode_predictor::ModePredictor::new();
203 predictor.set_project_root(project_root);
204 predictor.record(sig, outcome);
205 predictor.save();
206
207 let saved = original_tokens.saturating_sub(output_tokens);
211 let ext = std::path::Path::new(path)
212 .extension()
213 .and_then(|e| e.to_str())
214 .unwrap_or("")
215 .to_string();
216 let thresholds = crate::core::adaptive_thresholds::thresholds_for_path(path);
217 let feedback_outcome = crate::core::feedback::CompressionOutcome {
218 session_id: format!("{}", std::process::id()),
219 language: ext,
220 entropy_threshold: thresholds.bpe_entropy,
221 jaccard_threshold: thresholds.jaccard,
222 total_turns: 1,
223 tokens_saved: saved as u64,
224 tokens_original: original_tokens as u64,
225 cache_hits: u32::from(is_cache_hit),
226 total_reads: 1,
227 task_completed: crate::core::bounce_tracker::global()
231 .lock()
232 .ok()
233 .and_then(|bt| bt.bounce_rate_for_extension(path))
234 .is_none_or(|rate| rate < 0.30),
235 timestamp: chrono::Local::now().to_rfc3339(),
236 };
237 let mut store = crate::core::feedback::FeedbackStore::load();
238 store.project_root = Some(project_root.to_string());
239 store.record_outcome(feedback_outcome);
240
241 crate::core::anomaly::record_metric("tokens_per_call", output_tokens as f64);
245 crate::core::anomaly::save_debounced();
246}
247
248pub fn record_search(
255 modeled_baseline: usize,
256 observed_tokens: usize,
257 output_tokens: usize,
258 pattern: &str,
259 path: &str,
260 duration: std::time::Duration,
261 output_excerpt: &str,
262) {
263 stats::record("cli_grep", modeled_baseline, output_tokens);
264 crate::core::savings_ledger::record_tool_event("cli_grep", observed_tokens, output_tokens);
265
266 if let Some(mut session) = SessionState::load_latest() {
267 session.record_command();
268 let project_root = session.project_root.clone();
269 let calls = session.stats.total_tool_calls;
270 let _ = session.save();
271
272 maybe_consolidate(project_root.as_deref(), calls);
273 }
274
275 crate::core::anomaly::record_metric("tokens_per_call", output_tokens as f64);
278 crate::core::anomaly::save_debounced();
279
280 let mut ir = ContextIrV1::load();
284 ir.record(RecordIrInput {
285 kind: ContextIrSourceKindV1::Search,
286 tool: "ctx_search",
287 client_name: None,
288 agent_id: None,
289 path: Some(path),
290 command: None,
291 pattern: Some(pattern),
292 input_tokens: observed_tokens,
293 output_tokens,
294 duration,
295 content_excerpt: ir_excerpt(output_excerpt),
296 });
297 ir.save();
298}
299
300pub fn record_tree(original_tokens: usize, output_tokens: usize) {
302 stats::record("cli_ls", original_tokens, output_tokens);
303
304 if let Some(mut session) = SessionState::load_latest() {
305 session.record_command();
306 let _ = session.save();
307 }
308}
309
310pub fn record_shell_command(original_tokens: usize, output_tokens: usize) {
314 stats::record("cli_shell", original_tokens, output_tokens);
315 crate::core::savings_ledger::record_tool_event("cli_shell", original_tokens, output_tokens);
318
319 if let Some(mut session) = SessionState::load_latest() {
320 session.record_command();
321 let project_root = session.project_root.clone();
322 let calls = session.stats.total_tool_calls;
323 let _ = session.save();
324
325 if original_tokens > 0 {
326 maybe_consolidate(project_root.as_deref(), calls);
327 }
328 }
329}
330
331pub fn flush_all() {
345 stats::flush();
346 heatmap::flush();
347 crate::core::path_mode_memory::flush();
348 crate::core::auto_mode_resolver::flush_sources();
349 crate::core::edit_quality::flush();
350 crate::core::mode_predictor::ModePredictor::flush();
351 crate::core::feedback::FeedbackStore::flush();
352 crate::core::threshold_learning::flush();
353 crate::core::litm_calibration::flush();
354}
355
356fn maybe_consolidate(project_root: Option<&str>, calls: u32) {
359 let Some(root) = project_root else { return };
360 let autonomy = crate::tools::autonomy::AutonomyState::new();
361 if crate::tools::autonomy::should_auto_consolidate(&autonomy, calls) {
362 let root = root.to_string();
363 let _ = crate::core::consolidation_engine::consolidate_latest(
364 &root,
365 crate::core::consolidation_engine::ConsolidationBudgets::default(),
366 );
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
380 fn record_file_read_does_not_panic_without_session() {
381 let _dir = crate::core::data_dir::isolated_data_dir();
382 record_file_read(
383 "/tmp/nonexistent.rs",
384 "full",
385 100,
386 50,
387 false,
388 std::time::Duration::from_millis(1),
389 "excerpt",
390 );
391 }
392
393 #[test]
394 fn record_search_does_not_panic_without_session() {
395 let _dir = crate::core::data_dir::isolated_data_dir();
396 record_search(
397 500,
398 200,
399 150,
400 "pattern",
401 "/tmp",
402 std::time::Duration::from_millis(1),
403 "matches",
404 );
405 }
406
407 #[test]
408 fn record_tree_does_not_panic_without_session() {
409 let _dir = crate::core::data_dir::isolated_data_dir();
410 record_tree(100, 80);
411 }
412
413 #[test]
414 fn record_shell_does_not_panic_without_session() {
415 let _dir = crate::core::data_dir::isolated_data_dir();
416 record_shell_command(500, 200);
417 }
418
419 #[test]
420 fn flush_all_is_idempotent_and_safe_without_state() {
421 let _dir = crate::core::data_dir::isolated_data_dir();
422 flush_all();
425 flush_all();
426 }
427
428 #[test]
429 fn cli_read_persists_learning_sinks_to_disk() {
430 let dir = crate::core::data_dir::isolated_data_dir();
435 let file = dir.path().join("sample.rs");
436 std::fs::write(&file, "fn main() {\n println!(\"hi\");\n}\n").unwrap();
437 let path = file.to_string_lossy();
438
439 record_file_read(
440 &path,
441 "full",
442 1000,
443 200,
444 false,
445 std::time::Duration::from_millis(2),
446 "sample.rs [3L]\nfn main() {}",
447 );
448 flush_all();
449
450 let data = crate::core::data_dir::lean_ctx_data_dir().expect("data dir");
451 let state = crate::core::paths::state_dir().expect("state dir");
452 assert!(
453 data.join("mode_stats.json").exists(),
454 "mode predictor must persist after a CLI read + flush"
455 );
456 assert!(
457 state.join("feedback.json").exists(),
458 "compression feedback must persist after a CLI read + flush"
459 );
460 assert!(
461 state.join("heatmap.json").exists(),
462 "heatmap must persist after a CLI read + flush"
463 );
464 }
465
466 #[test]
467 fn cli_read_records_context_ir_lineage() {
468 let dir = crate::core::data_dir::isolated_data_dir();
473 let file = dir.path().join("ir_sample.rs");
474 std::fs::write(&file, "fn main() {}\n").unwrap();
475 let path = file.to_string_lossy();
476
477 record_file_read(
478 &path,
479 "full",
480 1000,
481 200,
482 false,
483 std::time::Duration::from_millis(3),
484 "ir_sample.rs [1L]\nfn main() {}",
485 );
486
487 let ir = ContextIrV1::load();
488 assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI read");
489 let item = &ir.items[0];
490 assert_eq!(item.source.tool, "ctx_read");
491 assert!(matches!(item.source.kind, ContextIrSourceKindV1::Read));
492 assert!(
493 item.source
494 .path
495 .as_deref()
496 .unwrap_or("")
497 .ends_with("ir_sample.rs"),
498 "IR records the read path, got {:?}",
499 item.source.path
500 );
501 assert_eq!(item.source.pattern.as_deref(), Some("full"));
502 assert_eq!(item.input_tokens, 1000);
503 assert_eq!(item.output_tokens, 200);
504 assert!(item.duration_us > 0, "a real duration must be recorded");
505 assert!(!item.content_excerpt.is_empty(), "excerpt must be captured");
506 }
507
508 #[test]
509 fn cli_search_records_context_ir_lineage() {
510 let _dir = crate::core::data_dir::isolated_data_dir();
512
513 record_search(
514 800,
515 500,
516 120,
517 "fn handle",
518 "src/",
519 std::time::Duration::from_millis(4),
520 "src/lib.rs:12: fn handle() {}",
521 );
522
523 let ir = ContextIrV1::load();
524 assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI search");
525 let item = &ir.items[0];
526 assert_eq!(item.source.tool, "ctx_search");
527 assert!(matches!(item.source.kind, ContextIrSourceKindV1::Search));
528 assert_eq!(item.input_tokens, 500);
530 assert_eq!(item.output_tokens, 120);
531 assert!(item.duration_us > 0, "a real duration must be recorded");
532 }
533}