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