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::grammar_usage::flush();
349 crate::core::auto_mode_resolver::flush_sources();
350 crate::core::edit_quality::flush();
351 crate::core::edit_metering::flush();
352 crate::core::mode_predictor::ModePredictor::flush();
353 crate::core::feedback::FeedbackStore::flush();
354 crate::core::threshold_learning::flush();
355 crate::core::litm_calibration::flush();
356}
357
358fn maybe_consolidate(project_root: Option<&str>, calls: u32) {
361 let Some(root) = project_root else { return };
362 let autonomy = crate::tools::autonomy::AutonomyState::new();
363 if crate::tools::autonomy::should_auto_consolidate(&autonomy, calls) {
364 let root = root.to_string();
365 let _ = crate::core::consolidation_engine::consolidate_latest(
366 &root,
367 crate::core::consolidation_engine::ConsolidationBudgets::default(),
368 );
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
382 fn record_file_read_does_not_panic_without_session() {
383 let _dir = crate::core::data_dir::isolated_data_dir();
384 record_file_read(
385 "/tmp/nonexistent.rs",
386 "full",
387 100,
388 50,
389 false,
390 std::time::Duration::from_millis(1),
391 "excerpt",
392 );
393 }
394
395 #[test]
396 fn record_search_does_not_panic_without_session() {
397 let _dir = crate::core::data_dir::isolated_data_dir();
398 record_search(
399 500,
400 200,
401 150,
402 "pattern",
403 "/tmp",
404 std::time::Duration::from_millis(1),
405 "matches",
406 );
407 }
408
409 #[test]
410 fn record_tree_does_not_panic_without_session() {
411 let _dir = crate::core::data_dir::isolated_data_dir();
412 record_tree(100, 80);
413 }
414
415 #[test]
416 fn record_shell_does_not_panic_without_session() {
417 let _dir = crate::core::data_dir::isolated_data_dir();
418 record_shell_command(500, 200);
419 }
420
421 #[test]
422 fn flush_all_is_idempotent_and_safe_without_state() {
423 let _dir = crate::core::data_dir::isolated_data_dir();
424 flush_all();
427 flush_all();
428 }
429
430 #[test]
431 fn cli_read_persists_learning_sinks_to_disk() {
432 let dir = crate::core::data_dir::isolated_data_dir();
437 let file = dir.path().join("sample.rs");
438 std::fs::write(&file, "fn main() {\n println!(\"hi\");\n}\n").unwrap();
439 let path = file.to_string_lossy();
440
441 record_file_read(
442 &path,
443 "full",
444 1000,
445 200,
446 false,
447 std::time::Duration::from_millis(2),
448 "sample.rs [3L]\nfn main() {}",
449 );
450 flush_all();
451
452 let data = crate::core::data_dir::lean_ctx_data_dir().expect("data dir");
453 let state = crate::core::paths::state_dir().expect("state dir");
454 assert!(
455 data.join("mode_stats.json").exists(),
456 "mode predictor must persist after a CLI read + flush"
457 );
458 assert!(
459 state.join("feedback.json").exists(),
460 "compression feedback must persist after a CLI read + flush"
461 );
462 assert!(
463 state.join("heatmap.json").exists(),
464 "heatmap must persist after a CLI read + flush"
465 );
466 }
467
468 #[test]
469 fn cli_read_records_context_ir_lineage() {
470 let dir = crate::core::data_dir::isolated_data_dir();
475 let file = dir.path().join("ir_sample.rs");
476 std::fs::write(&file, "fn main() {}\n").unwrap();
477 let path = file.to_string_lossy();
478
479 record_file_read(
480 &path,
481 "full",
482 1000,
483 200,
484 false,
485 std::time::Duration::from_millis(3),
486 "ir_sample.rs [1L]\nfn main() {}",
487 );
488
489 let ir = ContextIrV1::load();
490 assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI read");
491 let item = &ir.items[0];
492 assert_eq!(item.source.tool, "ctx_read");
493 assert!(matches!(item.source.kind, ContextIrSourceKindV1::Read));
494 assert!(
495 item.source
496 .path
497 .as_deref()
498 .unwrap_or("")
499 .ends_with("ir_sample.rs"),
500 "IR records the read path, got {:?}",
501 item.source.path
502 );
503 assert_eq!(item.source.pattern.as_deref(), Some("full"));
504 assert_eq!(item.input_tokens, 1000);
505 assert_eq!(item.output_tokens, 200);
506 assert!(item.duration_us > 0, "a real duration must be recorded");
507 assert!(!item.content_excerpt.is_empty(), "excerpt must be captured");
508 }
509
510 #[test]
511 fn cli_search_records_context_ir_lineage() {
512 let _dir = crate::core::data_dir::isolated_data_dir();
514
515 record_search(
516 800,
517 500,
518 120,
519 "fn handle",
520 "src/",
521 std::time::Duration::from_millis(4),
522 "src/lib.rs:12: fn handle() {}",
523 );
524
525 let ir = ContextIrV1::load();
526 assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI search");
527 let item = &ir.items[0];
528 assert_eq!(item.source.tool, "ctx_search");
529 assert!(matches!(item.source.kind, ContextIrSourceKindV1::Search));
530 assert_eq!(item.input_tokens, 500);
532 assert_eq!(item.output_tokens, 120);
533 assert!(item.duration_us > 0, "a real duration must be recorded");
534 }
535}