1use std::sync::atomic::Ordering;
2
3use super::server::{CepComputedStats, CrpMode, LeanCtxServer, ToolCallRecord};
4use super::startup::auto_consolidate_knowledge;
5use super::{ctx_compress, ctx_share};
6
7impl LeanCtxServer {
8 pub async fn record_call(
10 &self,
11 tool: &str,
12 original: usize,
13 saved: usize,
14 mode: Option<String>,
15 ) {
16 self.record_call_with_timing(tool, original, saved, mode, 0)
17 .await;
18 }
19
20 pub async fn record_call_with_path(
24 &self,
25 tool: &str,
26 original: usize,
27 saved: usize,
28 mode: Option<String>,
29 path: Option<&str>,
30 duration_ms: u64,
31 ) {
32 self.record_call_with_timing_inner(tool, original, saved, mode, duration_ms, path)
33 .await;
34 }
35
36 pub async fn record_call_with_timing(
38 &self,
39 tool: &str,
40 original: usize,
41 saved: usize,
42 mode: Option<String>,
43 duration_ms: u64,
44 ) {
45 self.record_call_with_timing_inner(tool, original, saved, mode, duration_ms, None)
46 .await;
47 }
48
49 async fn record_call_with_timing_inner(
50 &self,
51 tool: &str,
52 original: usize,
53 saved: usize,
54 mode: Option<String>,
55 duration_ms: u64,
56 path: Option<&str>,
57 ) {
58 let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
59 let mut calls = self.tool_calls.write().await;
60 calls.push(ToolCallRecord {
61 tool: tool.to_string(),
62 original_tokens: original,
63 saved_tokens: saved,
64 mode: mode.clone(),
65 duration_ms,
66 timestamp: ts.clone(),
67 });
68
69 const MAX_TOOL_CALL_RECORDS: usize = 500;
70 if calls.len() > MAX_TOOL_CALL_RECORDS {
71 let excess = calls.len() - MAX_TOOL_CALL_RECORDS;
72 calls.drain(..excess);
73 }
74
75 if duration_ms > 0 || original > 0 {
80 Self::append_tool_call_log(tool, duration_ms, original, saved, mode.as_deref(), &ts);
81 }
82
83 crate::core::events::emit_tool_call(
84 tool,
85 original as u64,
86 saved as u64,
87 mode.clone(),
88 duration_ms,
89 path.map(ToString::to_string),
90 );
91
92 let output_tokens = original.saturating_sub(saved);
93 crate::core::stats::record(tool, original, output_tokens);
94 if tool == "ctx_shell" {
101 crate::core::savings_ledger::record_tool_event(tool, original, output_tokens);
102 }
103
104 let mut session = self.session.write().await;
105 session.record_tool_call(saved as u64, original as u64);
106 if tool == "ctx_shell" {
107 session.record_command();
108 }
109 let pending_save = if session.should_save() {
110 session.prepare_save().ok()
111 } else {
112 None
113 };
114 drop(calls);
115 drop(session);
116
117 if let Some(prepared) = pending_save {
118 tokio::task::spawn_blocking(move || {
119 let _ = prepared.write_to_disk();
120 });
121 }
122
123 self.write_mcp_live_stats().await;
124 }
125
126 pub fn increment_and_check(&self) -> bool {
128 let count = self.call_count.fetch_add(1, Ordering::Relaxed) + 1;
129 let interval = Self::checkpoint_interval_effective();
130 interval > 0 && count.is_multiple_of(interval)
131 }
132
133 pub async fn auto_checkpoint(&self) -> Option<String> {
135 let cache = self.cache.read().await;
136 if cache.get_all_entries().is_empty() {
137 return None;
138 }
139 let complexity = crate::core::adaptive::classify_from_context(&cache);
140 let checkpoint = ctx_compress::handle(&cache, false, CrpMode::effective());
141 drop(cache);
142
143 let mut session = self.session.write().await;
144 let _ = session.save();
145 let session_summary = session.format_compact();
146 let has_insights = !session.findings.is_empty() || !session.decisions.is_empty();
147 let project_root = session.project_root.clone();
148 let summary_candidate = crate::core::session_summary::build_candidate(&session);
150 drop(session);
151
152 if has_insights && let Some(ref root) = project_root {
153 let root = root.clone();
154 std::thread::spawn(move || {
155 auto_consolidate_knowledge(&root);
156 });
157 }
158
159 if let Some(ref root) = project_root {
161 let root = root.clone();
162 std::thread::spawn(move || {
163 let _ =
164 crate::core::session_summary::maybe_record_periodic(&root, summary_candidate);
165 });
166 }
167
168 let multi_agent_block = self
169 .auto_multi_agent_checkpoint(project_root.as_ref())
170 .await;
171
172 self.record_call("ctx_compress", 0, 0, Some("auto".to_string()))
173 .await;
174
175 self.record_cep_snapshot().await;
176
177 if !crate::core::protocol::meta_visible() {
178 return None;
179 }
180
181 let doc_reminder = {
182 let session = self.session.read().await;
183 let calls = self.tool_calls.read().await;
184 Self::activity_nudge(&session, &calls)
185 };
186
187 Some(format!(
188 "{checkpoint}\n\n--- SESSION STATE ---\n{session_summary}\n\n{}{multi_agent_block}{doc_reminder}",
189 complexity.instruction_suffix()
190 ))
191 }
192
193 async fn auto_multi_agent_checkpoint(&self, project_root: Option<&String>) -> String {
194 let Some(root) = project_root else {
195 return String::new();
196 };
197
198 let registry = crate::core::agents::AgentRegistry::load_or_create();
199 let active = registry.list_active(Some(root));
200 if active.len() <= 1 {
201 return String::new();
202 }
203
204 let agent_id = self.agent_id.read().await;
205 let my_id = match agent_id.as_deref() {
206 Some(id) => id.to_string(),
207 None => return String::new(),
208 };
209 drop(agent_id);
210
211 let cache = self.cache.read().await;
212 let entries = cache.get_all_entries();
213 if !entries.is_empty() {
214 let mut by_access: Vec<_> = entries.iter().collect();
215 by_access.sort_by_key(|x| std::cmp::Reverse(x.1.read_count()));
216 let top_paths: Vec<&str> = by_access
217 .iter()
218 .take(5)
219 .map(|(key, _)| key.as_str())
220 .collect();
221 let paths_csv = top_paths.join(",");
222
223 let _ = ctx_share::handle(
224 "push",
225 Some(&my_id),
226 None,
227 Some(&paths_csv),
228 None,
229 &cache,
230 root,
231 );
232 }
233 drop(cache);
234
235 let pending_count = registry
236 .scratchpad
237 .iter()
238 .filter(|e| !e.read_by.contains(&my_id) && e.from_agent != my_id)
239 .count();
240
241 let shared_dir = crate::core::data_dir::lean_ctx_data_dir()
242 .unwrap_or_default()
243 .join("agents")
244 .join("shared");
245 let shared_count = if shared_dir.exists() {
246 std::fs::read_dir(&shared_dir).map_or(0, std::iter::Iterator::count)
247 } else {
248 0
249 };
250
251 let agent_names: Vec<String> = active
252 .iter()
253 .map(|a| {
254 let role = a.role.as_deref().unwrap_or(&a.agent_type);
255 format!("{role}({})", &a.agent_id[..8.min(a.agent_id.len())])
256 })
257 .collect();
258
259 format!(
260 "\n\n--- MULTI-AGENT SYNC ---\nAgents: {} | Pending msgs: {} | Shared contexts: {}\nAuto-shared top-5 cached files.\n--- END SYNC ---",
261 agent_names.join(", "),
262 pending_count,
263 shared_count,
264 )
265 }
266
267 pub fn append_tool_call_log(
269 tool: &str,
270 duration_ms: u64,
271 original: usize,
272 saved: usize,
273 mode: Option<&str>,
274 timestamp: &str,
275 ) {
276 const MAX_LOG_LINES: usize = 50;
277 if let Ok(dir) = crate::core::paths::state_dir() {
278 let log_path = dir.join("tool-calls.log");
279 let mode_str = mode.unwrap_or("-");
280 let slow = if duration_ms > 5000 { " **SLOW**" } else { "" };
281 let line = format!(
282 "{timestamp}\t{tool}\t{duration_ms}ms\torig={original}\tsaved={saved}\tmode={mode_str}{slow}\n"
283 );
284
285 let mut lines: Vec<String> = std::fs::read_to_string(&log_path)
286 .unwrap_or_default()
287 .lines()
288 .map(std::string::ToString::to_string)
289 .collect();
290
291 lines.push(line.trim_end().to_string());
292 if lines.len() > MAX_LOG_LINES {
293 lines.drain(0..lines.len() - MAX_LOG_LINES);
294 }
295
296 let _ = std::fs::write(&log_path, lines.join("\n") + "\n");
297 }
298 }
299
300 fn compute_cep_stats(
301 calls: &[ToolCallRecord],
302 stats: &crate::core::cache::CacheStats,
303 complexity: &crate::core::adaptive::TaskComplexity,
304 ) -> CepComputedStats {
305 let total_original: u64 = calls.iter().map(|c| c.original_tokens as u64).sum();
306 let total_saved: u64 = calls.iter().map(|c| c.saved_tokens as u64).sum();
307 let total_compressed = total_original.saturating_sub(total_saved);
308 let compression_rate = if total_original > 0 {
309 total_saved as f64 / total_original as f64
310 } else {
311 0.0
312 };
313
314 let modes_used: std::collections::HashSet<&str> =
315 calls.iter().filter_map(|c| c.mode.as_deref()).collect();
316 let mode_diversity = (modes_used.len() as f64 / 10.0).min(1.0);
317 let cache_util = stats.hit_rate() / 100.0;
318 let output_efficiency = 1.0 - crate::core::output_echo::current_avg_ratio();
321 let cep_score = cache_util * 0.25
322 + mode_diversity * 0.15
323 + compression_rate * 0.45
324 + output_efficiency * 0.15;
325
326 let mut mode_counts: std::collections::HashMap<String, u64> =
327 std::collections::HashMap::new();
328 for call in calls {
329 if let Some(ref mode) = call.mode {
330 *mode_counts.entry(mode.clone()).or_insert(0) += 1;
331 }
332 }
333
334 CepComputedStats {
335 cep_score: (cep_score * 100.0).round() as u32,
336 cache_util: (cache_util * 100.0).round() as u32,
337 mode_diversity: (mode_diversity * 100.0).round() as u32,
338 compression_rate: (compression_rate * 100.0).round() as u32,
339 total_original,
340 total_compressed,
341 total_saved,
342 mode_counts,
343 complexity: format!("{complexity:?}"),
344 cache_hits: stats.cache_hits(),
345 total_reads: stats.total_reads(),
346 tool_call_count: calls.len() as u64,
347 }
348 }
349
350 async fn write_mcp_live_stats(&self) {
351 let count = self.call_count.load(Ordering::Relaxed);
352 if count > 1 && !count.is_multiple_of(5) {
353 return;
354 }
355
356 let cache = self.cache.read().await;
357 let calls = self.tool_calls.read().await;
358 let stats = cache.get_stats();
359 let complexity = crate::core::adaptive::classify_from_context(&cache);
360
361 let cs = Self::compute_cep_stats(&calls, stats, &complexity);
362 let started_at = calls
363 .first()
364 .map(|c| c.timestamp.clone())
365 .unwrap_or_default();
366
367 drop(cache);
368 drop(calls);
369
370 crate::core::stats::record_cep_session(
376 cs.cep_score,
377 cs.cache_hits,
378 cs.total_reads,
379 cs.total_original,
380 cs.total_compressed,
381 &cs.mode_counts,
382 cs.tool_call_count,
383 &cs.complexity,
384 );
385
386 let live = serde_json::json!({
387 "cep_score": cs.cep_score,
388 "cache_utilization": cs.cache_util,
389 "mode_diversity": cs.mode_diversity,
390 "compression_rate": cs.compression_rate,
391 "task_complexity": cs.complexity,
392 "files_cached": cs.total_reads,
393 "total_reads": cs.total_reads,
394 "cache_hits": cs.cache_hits,
395 "tokens_saved": cs.total_saved,
396 "tokens_original": cs.total_original,
397 "tool_calls": cs.tool_call_count,
398 "started_at": started_at,
399 "updated_at": chrono::Local::now().to_rfc3339(),
400 });
401
402 if let Ok(dir) = crate::core::paths::state_dir() {
403 let _ = std::fs::write(dir.join("mcp-live.json"), live.to_string());
404 }
405 }
406
407 pub async fn record_cep_snapshot(&self) {
409 let cache = self.cache.read().await;
410 let calls = self.tool_calls.read().await;
411 let stats = cache.get_stats();
412 let complexity = crate::core::adaptive::classify_from_context(&cache);
413
414 let cs = Self::compute_cep_stats(&calls, stats, &complexity);
415
416 drop(cache);
417 drop(calls);
418
419 crate::core::stats::record_cep_session(
420 cs.cep_score,
421 cs.cache_hits,
422 cs.total_reads,
423 cs.total_original,
424 cs.total_compressed,
425 &cs.mode_counts,
426 cs.tool_call_count,
427 &cs.complexity,
428 );
429 }
430
431 fn activity_nudge(
432 session: &crate::core::session::SessionState,
433 calls: &[ToolCallRecord],
434 ) -> &'static str {
435 let last_doc_ts = session
436 .progress
437 .last()
438 .map(|p| p.timestamp)
439 .or_else(|| session.decisions.last().map(|d| d.timestamp))
440 .or_else(|| session.findings.last().map(|f| f.timestamp));
441
442 if let Some(ts) = last_doc_ts {
443 let age = chrono::Utc::now() - ts;
444 if age.num_minutes() < 8 {
445 return "";
446 }
447 }
448
449 let (weighted_score, significant_tools, shell_heavy, edit_heavy) =
450 Self::compute_activity_score(calls, last_doc_ts);
451
452 if weighted_score < 20 || significant_tools < 5 {
453 if session.stats.total_tool_calls >= 30
454 && session.decisions.is_empty()
455 && session.progress.is_empty()
456 {
457 return "\n[CHECKPOINT: please document current progress via ctx_session(action=\"task\") or ctx_knowledge(action=\"remember\")]";
458 }
459 return "";
460 }
461
462 if shell_heavy {
463 "\n[CHECKPOINT: multiple shell commands executed — any test results or findings worth persisting via ctx_knowledge(action=\"remember\")?]"
464 } else if edit_heavy {
465 "\n[CHECKPOINT: several files modified — document the architecture decision or pattern via ctx_knowledge(action=\"remember\")?]"
466 } else {
467 "\n[CHECKPOINT: significant work detected — consider persisting decisions via ctx_knowledge(action=\"remember\")]"
468 }
469 }
470
471 fn compute_activity_score(
472 calls: &[ToolCallRecord],
473 last_doc_ts: Option<chrono::DateTime<chrono::Utc>>,
474 ) -> (u32, u32, bool, bool) {
475 let mut weighted_score: u32 = 0;
476 let mut significant_tools: u32 = 0;
477 let mut shell_count: u32 = 0;
478 let mut edit_count: u32 = 0;
479
480 let since_doc: Vec<&ToolCallRecord> = if let Some(ts) = last_doc_ts {
481 let ts_str = ts.format("%Y-%m-%d %H:%M:%S").to_string();
482 calls.iter().filter(|c| c.timestamp > ts_str).collect()
483 } else {
484 calls.iter().collect()
485 };
486
487 for call in &since_doc {
488 let tool = call.tool.as_str();
489 let is_knowledge = tool == "ctx_knowledge" || tool == "ctx_session";
490 if is_knowledge {
491 weighted_score = 0;
492 significant_tools = 0;
493 shell_count = 0;
494 edit_count = 0;
495 continue;
496 }
497
498 let (weight, significant) = match tool {
499 "edit" | "write" | "str_replace" => {
500 edit_count += 1;
501 (4u32, true)
502 }
503 "ctx_shell" => {
504 shell_count += 1;
505 let is_test_or_build = call
506 .mode
507 .as_deref()
508 .is_some_and(|m| m.contains("test") || m.contains("build"));
509 if is_test_or_build {
510 (3, true)
511 } else {
512 (2, true)
513 }
514 }
515 "ctx_read" => {
516 let is_cache_hit = call.saved_tokens > 0
517 && call.original_tokens > 0
518 && call.saved_tokens == call.original_tokens;
519 if is_cache_hit { (0, false) } else { (1, false) }
520 }
521 _ => (1, false),
522 };
523
524 weighted_score = weighted_score.saturating_add(weight);
525 if significant {
526 significant_tools += 1;
527 }
528 }
529
530 let shell_heavy = shell_count >= 3 && shell_count > edit_count;
531 let edit_heavy = edit_count >= 3 && edit_count >= shell_count;
532
533 (weighted_score, significant_tools, shell_heavy, edit_heavy)
534 }
535}
536
537#[cfg(test)]
538mod activity_score_tests {
539 use super::*;
540
541 fn make_call(tool: &str, mode: Option<&str>) -> ToolCallRecord {
542 ToolCallRecord {
543 tool: tool.to_string(),
544 original_tokens: 100,
545 saved_tokens: 50,
546 mode: mode.map(String::from),
547 duration_ms: 10,
548 timestamp: "2026-01-01 12:00:00".to_string(),
549 }
550 }
551
552 fn make_cache_hit() -> ToolCallRecord {
553 ToolCallRecord {
554 tool: "ctx_read".to_string(),
555 original_tokens: 100,
556 saved_tokens: 100,
557 mode: Some("full".to_string()),
558 duration_ms: 1,
559 timestamp: "2026-01-01 12:00:00".to_string(),
560 }
561 }
562
563 #[test]
564 fn empty_calls_zero_score() {
565 let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&[], None);
566 assert_eq!(score, 0);
567 assert_eq!(sig, 0);
568 }
569
570 #[test]
571 fn edits_have_highest_weight() {
572 let calls = vec![
573 make_call("edit", None),
574 make_call("edit", None),
575 make_call("edit", None),
576 ];
577 let (score, sig, _, edit_heavy) = LeanCtxServer::compute_activity_score(&calls, None);
578 assert_eq!(score, 12);
579 assert_eq!(sig, 3);
580 assert!(edit_heavy);
581 }
582
583 #[test]
584 fn shell_test_build_weight_three() {
585 let calls = vec![
586 make_call("ctx_shell", Some("test")),
587 make_call("ctx_shell", Some("build")),
588 make_call("ctx_shell", Some("test")),
589 ];
590 let (score, sig, shell_heavy, _) = LeanCtxServer::compute_activity_score(&calls, None);
591 assert_eq!(score, 9);
592 assert_eq!(sig, 3);
593 assert!(shell_heavy);
594 }
595
596 #[test]
597 fn cache_hits_zero_weight() {
598 let calls = vec![make_cache_hit(), make_cache_hit(), make_cache_hit()];
599 let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&calls, None);
600 assert_eq!(score, 0);
601 assert_eq!(sig, 0);
602 }
603
604 #[test]
605 fn knowledge_call_resets_score() {
606 let calls = vec![
607 make_call("edit", None),
608 make_call("edit", None),
609 make_call("ctx_knowledge", None),
610 make_call("ctx_read", None),
611 ];
612 let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&calls, None);
613 assert_eq!(score, 1);
614 assert_eq!(sig, 0);
615 }
616
617 #[test]
618 fn mixed_workflow_scoring() {
619 let calls = vec![
620 make_call("ctx_read", None),
621 make_call("ctx_read", None),
622 make_call("edit", None),
623 make_call("edit", None),
624 make_call("ctx_shell", Some("test output")),
625 make_call("ctx_shell", None),
626 ];
627 let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&calls, None);
628 assert_eq!(score, 2 + 4 + 4 + 3 + 2);
629 assert_eq!(sig, 4);
630 }
631}