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