1use crate::core::context_ir::{ContextIrSourceKindV1, ContextIrV1, RecordIrInput};
14use crate::core::context_ledger::ContextLedger;
15use crate::core::heatmap;
16use crate::core::intent_engine::StructuredIntent;
17use crate::core::session::SessionState;
18use crate::core::stats;
19use std::sync::atomic::{AtomicU32, Ordering};
20
21const PERIODIC_FLUSH_INTERVAL: u32 = 50;
25static TOOL_CALL_COUNT: AtomicU32 = AtomicU32::new(0);
26
27pub(crate) fn usable_root(root: Option<&str>) -> Option<&str> {
29 root.filter(|r| !r.trim().is_empty() && *r != ".")
30}
31
32pub fn ir_excerpt(text: &str) -> &str {
37 const MAX: usize = 200;
38 if text.len() <= MAX {
39 return text;
40 }
41 let mut end = MAX;
42 while end > 0 && !text.is_char_boundary(end) {
43 end -= 1;
44 }
45 &text[..end]
46}
47
48pub fn record_file_read(
55 path: &str,
56 mode: &str,
57 original_tokens: usize,
58 output_tokens: usize,
59 is_cache_hit: bool,
60 duration: std::time::Duration,
61 output_excerpt: &str,
62) {
63 record_file_read_accounting(
64 path,
65 mode,
66 original_tokens,
67 output_tokens,
68 is_cache_hit,
69 duration,
70 );
71 let mut ir = ContextIrV1::load();
72 ir.record(RecordIrInput {
73 kind: ContextIrSourceKindV1::Read,
74 tool: "ctx_read",
75 client_name: None,
76 agent_id: None,
77 path: Some(path),
78 command: None,
79 pattern: Some(mode),
80 input_tokens: original_tokens,
81 output_tokens,
82 duration,
83 content_excerpt: ir_excerpt(output_excerpt),
84 });
85 ir.save();
86 record_file_read_projection(path, mode, original_tokens, output_tokens);
87 if mode == "aggressive" && original_tokens > output_tokens {
88 maybe_periodic_flush();
89 }
90 maybe_periodic_flush();
91}
92
93pub fn record_file_read_accounting(
96 path: &str,
97 mode: &str,
98 original_tokens: usize,
99 output_tokens: usize,
100 is_cache_hit: bool,
101 duration: std::time::Duration,
102) {
103 let saved = original_tokens.saturating_sub(output_tokens);
104 let tool_key = format!("cli_{mode}");
105
106 stats::record(&tool_key, original_tokens, output_tokens);
107 crate::core::execution_lifecycle::record_heatmap_access(path, original_tokens, saved);
108
109 crate::core::events::emit_tool_call(
111 &tool_key,
112 original_tokens as u64,
113 saved as u64,
114 Some(mode.to_string()),
115 duration.as_millis() as u64,
116 Some(path.to_string()),
117 );
118 if is_cache_hit {
119 crate::core::events::emit_cache_hit(path, saved as u64);
120 }
121 crate::core::savings_ledger::record_read_event(original_tokens, saved, None, None);
127
128 if is_cache_hit {
129 crate::core::telemetry::global_metrics().record_cache(true);
130 }
131
132 let session_root = crate::core::session::caller::with_mut(|session| {
136 session.touch_file(path, None, mode, original_tokens);
137 if is_cache_hit {
138 session.record_cache_hit();
139 }
140 if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
141 let touched: Vec<String> = session
142 .files_touched
143 .iter()
144 .map(|ft| ft.path.clone())
145 .collect();
146 let inferred = StructuredIntent::from_file_patterns(&touched);
147 if inferred.confidence >= 0.4 {
148 session.active_structured_intent = Some(inferred);
149 }
150 }
151 session.project_root.clone()
152 })
153 .flatten();
154
155 let learning_root = usable_root(session_root.as_deref())
160 .map(str::to_string)
161 .or_else(|| crate::core::protocol::detect_project_root(path))
162 .unwrap_or_else(|| String::from("."));
163
164 if std::path::Path::new(path).is_file() {
168 let mut ledger = ContextLedger::load();
169 ledger.record(path, mode, original_tokens, output_tokens);
170 ledger.save();
171 }
172
173 record_read_observation(path, mode, &learning_root);
178}
179
180pub fn record_file_read_projection(
181 path: &str,
182 mode: &str,
183 original_tokens: usize,
184 output_tokens: usize,
185) {
186 let saved = original_tokens.saturating_sub(output_tokens);
187 if saved > 0 {
191 project_ocla_savings(path, original_tokens as u64, output_tokens as u64);
192 }
193 if mode == "aggressive" && saved > 0 {
194 project_ocla_compression(path, original_tokens as u64, output_tokens as u64);
195 }
196}
197
198fn record_read_observation(path: &str, resolved_mode: &str, project_root: &str) {
203 let _ = crate::core::ocla::OclaRegistry::global()
204 .outcome_tracker
205 .record_outcome(read_observation(path, resolved_mode, project_root));
206}
207
208fn read_observation(
209 path: &str,
210 resolved_mode: &str,
211 project_root: &str,
212) -> crate::core::ocla::Outcome {
213 let context = crate::core::ocla::OclaRequestContext {
214 request_id: format!("cli-read:{path}:{resolved_mode}"),
215 session_id: caller_session_id().unwrap_or_else(|| "cli-read".to_string()),
216 agent_id: "lean-ctx".to_string(),
217 content_ref: format!("file:{path}"),
218 tenant_id: None,
219 trace_id: "tr-unit".into(),
220 task_id: None,
221 parent_task_id: None,
222 };
223 crate::core::ocla::Outcome {
224 context,
225 accepted: None,
226 quality_score_milli: None,
227 outcome_ref: Some(format!("read:{project_root}:{resolved_mode}")),
228 }
229}
230
231#[cfg(test)]
238pub(crate) fn record_search(
239 modeled_baseline: usize,
240 observed_tokens: usize,
241 output_tokens: usize,
242 pattern: &str,
243 path: &str,
244 duration: std::time::Duration,
245 output_excerpt: &str,
246) {
247 record_search_accounting(
248 modeled_baseline,
249 observed_tokens,
250 output_tokens,
251 path,
252 duration,
253 );
254 let mut ir = ContextIrV1::load();
255 ir.record(RecordIrInput {
256 kind: ContextIrSourceKindV1::Search,
257 tool: "ctx_search",
258 client_name: None,
259 agent_id: None,
260 path: Some(path),
261 command: None,
262 pattern: Some(pattern),
263 input_tokens: observed_tokens,
264 output_tokens,
265 duration,
266 content_excerpt: ir_excerpt(output_excerpt),
267 });
268 ir.save();
269}
270
271pub fn record_search_accounting(
273 modeled_baseline: usize,
274 observed_tokens: usize,
275 output_tokens: usize,
276 path: &str,
277 duration: std::time::Duration,
278) {
279 stats::record("cli_grep", modeled_baseline, output_tokens);
280
281 let search_saved = modeled_baseline.saturating_sub(output_tokens);
283 if search_saved > 0 {
284 crate::core::events::emit_tool_call(
285 "cli_grep",
286 modeled_baseline as u64,
287 search_saved as u64,
288 Some("search".to_string()),
289 duration.as_millis() as u64,
290 Some(path.to_string()),
291 );
292 }
293 crate::core::savings_ledger::record_tool_event(
294 "cli_grep",
295 observed_tokens,
296 output_tokens,
297 None,
298 None,
299 );
300 crate::core::savings_tracker::record_compression(
301 modeled_baseline as u64,
302 output_tokens as u64,
303 "cli_grep",
304 );
305
306 crate::core::session::caller::with_mut(SessionState::record_command);
307}
308
309pub fn record_tree(original_tokens: usize, output_tokens: usize) {
311 stats::record("cli_ls", original_tokens, output_tokens);
312
313 let saved = original_tokens.saturating_sub(output_tokens);
315 if saved > 0 {
316 crate::core::events::emit_tool_call(
317 "cli_ls",
318 original_tokens as u64,
319 saved as u64,
320 Some("tree".to_string()),
321 0,
322 None,
323 );
324 }
325
326 crate::core::session::caller::with_mut(SessionState::record_command);
327}
328
329pub(crate) fn record_shell_command(original_tokens: usize, output_tokens: usize) {
333 record_shell_command_named("(shell)", original_tokens, output_tokens);
334}
335
336pub(crate) fn record_shell_command_named(
337 command: &str,
338 original_tokens: usize,
339 output_tokens: usize,
340) {
341 stats::record("cli_shell", original_tokens, output_tokens);
342
343 let saved = original_tokens.saturating_sub(output_tokens);
345 if saved > 0 {
346 crate::core::events::emit_tool_call(
347 "cli_shell",
348 original_tokens as u64,
349 saved as u64,
350 Some("shell".to_string()),
351 0,
352 None,
353 );
354 }
355 crate::core::savings_ledger::record_tool_event(
358 "cli_shell",
359 original_tokens,
360 output_tokens,
361 None,
362 None,
363 );
364 crate::core::savings_tracker::record_compression(
365 original_tokens as u64,
366 output_tokens as u64,
367 "cli_shell",
368 );
369
370 if original_tokens > 0 {
371 let label = shell_ledger_label(command);
372 let mut ledger = crate::core::context_ledger::ContextLedger::load();
373 ledger.record_shell(&label, original_tokens, output_tokens);
374 ledger.save();
375 }
376 crate::core::session::caller::with_mut(SessionState::record_command);
377}
378
379pub(crate) fn maybe_periodic_flush() {
384 let count = TOOL_CALL_COUNT.fetch_add(1, Ordering::Relaxed) + 1;
385 if count.is_multiple_of(PERIODIC_FLUSH_INTERVAL) {
386 let _ = std::thread::spawn(flush_all);
387 }
388}
389
390pub fn flush_all() {
404 stats::flush();
405 heatmap::flush();
406 crate::core::path_mode_memory::flush();
407 crate::core::grammar_usage::flush();
408 crate::core::auto_mode_resolver::flush_sources();
409 crate::core::edit_quality::flush();
410 crate::core::edit_metering::flush();
411}
412
413pub fn flush_all_at_exit() {
420 crate::core::bounce_tracker::flush_pending();
421 flush_all();
422}
423
424fn caller_session_id() -> Option<String> {
426 crate::core::session::caller::with(|session| session.id.clone())
427}
428
429fn project_ocla_compression(path: &str, source_tokens: u64, output_tokens: u64) {
433 use crate::core::ocla::OclaRegistry;
434 use crate::core::ocla::types::{CompressionRequest, OclaRequestContext};
435
436 let reg = OclaRegistry::global();
437 let source_ref = format!("file:{path}");
438 let request = CompressionRequest {
439 context: OclaRequestContext {
440 request_id: format!("cli-read-{}", path.len()),
441 session_id: caller_session_id().unwrap_or_default(),
442 agent_id: String::new(),
443 content_ref: source_ref.clone(),
444 tenant_id: None,
445 trace_id: "tr-unit".into(),
446 task_id: None,
447 parent_task_id: None,
448 },
449 source_ref,
450 source_tokens,
451 target_tokens: output_tokens,
452 quality_policy_ref: None,
453 };
454 let _ = reg.compression_provider.compress(request);
455}
456
457fn project_ocla_savings(path: &str, original_tokens: u64, output_tokens: u64) {
461 use crate::core::ocla::OclaRegistry;
462 use crate::core::ocla::types::{OclaRequestContext, SavingsEvidence};
463
464 let context = OclaRequestContext {
465 request_id: format!("cli-read-{}", path.len()),
466 session_id: caller_session_id().unwrap_or_else(|| "cli-read".to_string()),
467 agent_id: "lean-ctx".to_string(),
468 content_ref: format!("file:{path}"),
469 tenant_id: None,
470 trace_id: String::new(),
471 task_id: None,
472 parent_task_id: None,
473 };
474 let evidence = SavingsEvidence {
475 context,
476 original_tokens,
477 delivered_tokens: output_tokens,
478 quality_ref: None,
479 evidence_ref: format!("read:{path}:{original_tokens}:{output_tokens}"),
480 };
481 let _ = OclaRegistry::global()
482 .savings_ledger
483 .project_savings(evidence);
484}
485
486fn shell_ledger_label(command: &str) -> String {
488 let first_line = command.lines().next().unwrap_or(command);
489 let trimmed = first_line.trim();
490 if trimmed.len() <= 80 {
491 format!("$ {trimmed}")
492 } else {
493 format!("$ {}…", &trimmed[..trimmed.floor_char_boundary(77)])
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 #[test]
507 fn read_observation_does_not_invent_acceptance_or_quality() {
508 let _data = crate::core::data_dir::isolated_data_dir();
509 let outcome = read_observation("sample.rs", "full", "/project");
510 assert_eq!(outcome.accepted, None);
511 assert_eq!(outcome.quality_score_milli, None);
512 assert_eq!(outcome.context.content_ref, "file:sample.rs");
513 assert_eq!(outcome.outcome_ref.as_deref(), Some("read:/project:full"));
514 }
515
516 #[test]
517 fn record_file_read_does_not_panic_without_session() {
518 let _dir = crate::core::data_dir::isolated_data_dir();
519 record_file_read(
520 "/tmp/nonexistent.rs",
521 "full",
522 100,
523 50,
524 false,
525 std::time::Duration::from_millis(1),
526 "excerpt",
527 );
528 }
529
530 #[test]
531 fn record_search_does_not_panic_without_session() {
532 let _dir = crate::core::data_dir::isolated_data_dir();
533 record_search(
534 500,
535 200,
536 150,
537 "pattern",
538 "/tmp",
539 std::time::Duration::from_millis(1),
540 "matches",
541 );
542 }
543
544 #[test]
545 fn record_tree_does_not_panic_without_session() {
546 let _dir = crate::core::data_dir::isolated_data_dir();
547 record_tree(100, 80);
548 }
549
550 #[test]
551 fn record_shell_does_not_panic_without_session() {
552 let _dir = crate::core::data_dir::isolated_data_dir();
553 record_shell_command(500, 200);
554 }
555
556 #[test]
557 fn flush_all_is_idempotent_and_safe_without_state() {
558 let _dir = crate::core::data_dir::isolated_data_dir();
559 flush_all();
562 flush_all();
563 }
564
565 #[test]
566 fn periodic_flush_counter_increments() {
567 TOOL_CALL_COUNT.store(0, Ordering::Relaxed);
568 maybe_periodic_flush();
569 assert_eq!(TOOL_CALL_COUNT.load(Ordering::Relaxed), 1);
570 }
571
572 #[test]
573 fn cli_read_persists_observation_sinks_to_disk() {
574 let dir = crate::core::data_dir::isolated_data_dir();
583 let file = dir.path().join("sample.rs");
584 std::fs::write(&file, "fn main() {\n println!(\"hi\");\n}\n").unwrap();
585 let path = file.to_string_lossy();
586
587 record_file_read(
588 &path,
589 "full",
590 1000,
591 200,
592 false,
593 std::time::Duration::from_millis(2),
594 "sample.rs [3L]\nfn main() {}",
595 );
596 flush_all();
597
598 let data = crate::core::data_dir::lean_ctx_data_dir().expect("data dir");
599 let state = crate::core::paths::state_dir().expect("state dir");
600 assert!(
601 !data.join("mode_stats.json").exists(),
602 "a CLI read must not train the retired mode predictor"
603 );
604 assert!(
605 state.join("heatmap.json").exists(),
606 "heatmap must persist after a CLI read + flush"
607 );
608 }
609
610 #[test]
611 fn cli_read_records_context_ir_lineage() {
612 let dir = crate::core::data_dir::isolated_data_dir();
617 let file = dir.path().join("ir_sample.rs");
618 std::fs::write(&file, "fn main() {}\n").unwrap();
619 let path = file.to_string_lossy();
620
621 record_file_read(
622 &path,
623 "full",
624 1000,
625 200,
626 false,
627 std::time::Duration::from_millis(3),
628 "ir_sample.rs [1L]\nfn main() {}",
629 );
630
631 let ir = ContextIrV1::load();
632 assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI read");
633 let item = &ir.items[0];
634 assert_eq!(item.source.tool, "ctx_read");
635 assert!(matches!(item.source.kind, ContextIrSourceKindV1::Read));
636 assert!(
637 item.source
638 .path
639 .as_deref()
640 .unwrap_or("")
641 .ends_with("ir_sample.rs"),
642 "IR records the read path, got {:?}",
643 item.source.path
644 );
645 assert_eq!(item.source.pattern.as_deref(), Some("full"));
646 assert_eq!(item.input_tokens, 1000);
647 assert_eq!(item.output_tokens, 200);
648 assert!(item.duration_us > 0, "a real duration must be recorded");
649 assert!(!item.content_excerpt.is_empty(), "excerpt must be captured");
650 }
651
652 #[test]
653 fn cli_search_records_context_ir_lineage() {
654 let _dir = crate::core::data_dir::isolated_data_dir();
656
657 record_search(
658 800,
659 500,
660 120,
661 "fn handle",
662 "src/",
663 std::time::Duration::from_millis(4),
664 "src/lib.rs:12: fn handle() {}",
665 );
666
667 let ir = ContextIrV1::load();
668 assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI search");
669 let item = &ir.items[0];
670 assert_eq!(item.source.tool, "ctx_search");
671 assert!(matches!(item.source.kind, ContextIrSourceKindV1::Search));
672 assert_eq!(item.input_tokens, 500);
674 assert_eq!(item.output_tokens, 120);
675 assert!(item.duration_us > 0, "a real duration must be recorded");
676 }
677}