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