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 crate::core::request_ledger::finish_serving(std::time::Duration::from_secs(5));
425}
426
427fn caller_session_id() -> Option<String> {
429 crate::core::session::caller::with(|session| session.id.clone())
430}
431
432fn project_ocla_compression(path: &str, source_tokens: u64, output_tokens: u64) {
436 use crate::core::ocla::OclaRegistry;
437 use crate::core::ocla::types::{CompressionRequest, OclaRequestContext};
438
439 let reg = OclaRegistry::global();
440 let source_ref = format!("file:{path}");
441 let request = CompressionRequest {
442 context: OclaRequestContext {
443 request_id: format!("cli-read-{}", path.len()),
444 session_id: caller_session_id().unwrap_or_default(),
445 agent_id: String::new(),
446 content_ref: source_ref.clone(),
447 tenant_id: None,
448 trace_id: "tr-unit".into(),
449 task_id: None,
450 parent_task_id: None,
451 },
452 source_ref,
453 source_tokens,
454 target_tokens: output_tokens,
455 quality_policy_ref: None,
456 };
457 let _ = reg.compression_provider.compress(request);
458}
459
460fn project_ocla_savings(path: &str, original_tokens: u64, output_tokens: u64) {
464 use crate::core::ocla::OclaRegistry;
465 use crate::core::ocla::types::{OclaRequestContext, SavingsEvidence};
466
467 let context = OclaRequestContext {
468 request_id: format!("cli-read-{}", path.len()),
469 session_id: caller_session_id().unwrap_or_else(|| "cli-read".to_string()),
470 agent_id: "lean-ctx".to_string(),
471 content_ref: format!("file:{path}"),
472 tenant_id: None,
473 trace_id: String::new(),
474 task_id: None,
475 parent_task_id: None,
476 };
477 let evidence = SavingsEvidence {
478 context,
479 original_tokens,
480 delivered_tokens: output_tokens,
481 quality_ref: None,
482 evidence_ref: format!("read:{path}:{original_tokens}:{output_tokens}"),
483 };
484 let _ = OclaRegistry::global()
485 .savings_ledger
486 .project_savings(evidence);
487}
488
489fn shell_ledger_label(command: &str) -> String {
491 let first_line = command.lines().next().unwrap_or(command);
492 let trimmed = first_line.trim();
493 if trimmed.len() <= 80 {
494 format!("$ {trimmed}")
495 } else {
496 format!("$ {}…", &trimmed[..trimmed.floor_char_boundary(77)])
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 #[test]
510 fn read_observation_does_not_invent_acceptance_or_quality() {
511 let _data = crate::core::data_dir::isolated_data_dir();
512 let outcome = read_observation("sample.rs", "full", "/project");
513 assert_eq!(outcome.accepted, None);
514 assert_eq!(outcome.quality_score_milli, None);
515 assert_eq!(outcome.context.content_ref, "file:sample.rs");
516 assert_eq!(outcome.outcome_ref.as_deref(), Some("read:/project:full"));
517 }
518
519 #[test]
520 fn record_file_read_does_not_panic_without_session() {
521 let _dir = crate::core::data_dir::isolated_data_dir();
522 record_file_read(
523 "/tmp/nonexistent.rs",
524 "full",
525 100,
526 50,
527 false,
528 std::time::Duration::from_millis(1),
529 "excerpt",
530 );
531 }
532
533 #[test]
534 fn record_search_does_not_panic_without_session() {
535 let _dir = crate::core::data_dir::isolated_data_dir();
536 record_search(
537 500,
538 200,
539 150,
540 "pattern",
541 "/tmp",
542 std::time::Duration::from_millis(1),
543 "matches",
544 );
545 }
546
547 #[test]
548 fn record_tree_does_not_panic_without_session() {
549 let _dir = crate::core::data_dir::isolated_data_dir();
550 record_tree(100, 80);
551 }
552
553 #[test]
554 fn record_shell_does_not_panic_without_session() {
555 let _dir = crate::core::data_dir::isolated_data_dir();
556 record_shell_command(500, 200);
557 }
558
559 #[test]
560 fn flush_all_is_idempotent_and_safe_without_state() {
561 let _dir = crate::core::data_dir::isolated_data_dir();
562 flush_all();
565 flush_all();
566 }
567
568 #[test]
569 fn periodic_flush_counter_increments() {
570 TOOL_CALL_COUNT.store(0, Ordering::Relaxed);
571 maybe_periodic_flush();
572 assert_eq!(TOOL_CALL_COUNT.load(Ordering::Relaxed), 1);
573 }
574
575 #[test]
576 fn cli_read_persists_observation_sinks_to_disk() {
577 let dir = crate::core::data_dir::isolated_data_dir();
586 let file = dir.path().join("sample.rs");
587 std::fs::write(&file, "fn main() {\n println!(\"hi\");\n}\n").unwrap();
588 let path = file.to_string_lossy();
589
590 record_file_read(
591 &path,
592 "full",
593 1000,
594 200,
595 false,
596 std::time::Duration::from_millis(2),
597 "sample.rs [3L]\nfn main() {}",
598 );
599 flush_all();
600
601 let data = crate::core::data_dir::lean_ctx_data_dir().expect("data dir");
602 let state = crate::core::paths::state_dir().expect("state dir");
603 assert!(
604 !data.join("mode_stats.json").exists(),
605 "a CLI read must not train the retired mode predictor"
606 );
607 assert!(
608 state.join("heatmap.json").exists(),
609 "heatmap must persist after a CLI read + flush"
610 );
611 }
612
613 #[test]
614 fn cli_read_records_context_ir_lineage() {
615 let dir = crate::core::data_dir::isolated_data_dir();
620 let file = dir.path().join("ir_sample.rs");
621 std::fs::write(&file, "fn main() {}\n").unwrap();
622 let path = file.to_string_lossy();
623
624 record_file_read(
625 &path,
626 "full",
627 1000,
628 200,
629 false,
630 std::time::Duration::from_millis(3),
631 "ir_sample.rs [1L]\nfn main() {}",
632 );
633
634 let ir = ContextIrV1::load();
635 assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI read");
636 let item = &ir.items[0];
637 assert_eq!(item.source.tool, "ctx_read");
638 assert!(matches!(item.source.kind, ContextIrSourceKindV1::Read));
639 assert!(
640 item.source
641 .path
642 .as_deref()
643 .unwrap_or("")
644 .ends_with("ir_sample.rs"),
645 "IR records the read path, got {:?}",
646 item.source.path
647 );
648 assert_eq!(item.source.pattern.as_deref(), Some("full"));
649 assert_eq!(item.input_tokens, 1000);
650 assert_eq!(item.output_tokens, 200);
651 assert!(item.duration_us > 0, "a real duration must be recorded");
652 assert!(!item.content_excerpt.is_empty(), "excerpt must be captured");
653 }
654
655 #[test]
656 fn cli_search_records_context_ir_lineage() {
657 let _dir = crate::core::data_dir::isolated_data_dir();
659
660 record_search(
661 800,
662 500,
663 120,
664 "fn handle",
665 "src/",
666 std::time::Duration::from_millis(4),
667 "src/lib.rs:12: fn handle() {}",
668 );
669
670 let ir = ContextIrV1::load();
671 assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI search");
672 let item = &ir.items[0];
673 assert_eq!(item.source.tool, "ctx_search");
674 assert!(matches!(item.source.kind, ContextIrSourceKindV1::Search));
675 assert_eq!(item.input_tokens, 500);
677 assert_eq!(item.output_tokens, 120);
678 assert!(item.duration_us > 0, "a real duration must be recorded");
679 }
680}