Skip to main content

lean_ctx/tools/
server_metrics.rs

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    /// Records a tool call's token savings without timing information.
9    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    /// Records a tool call like `record_call`, but includes an optional file path for observability.
21    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    /// Records a tool call's token savings, duration, and emits events and stats.
34    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    /// Increments the call counter and returns true if a checkpoint is due.
111    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    /// Generates a compressed context checkpoint with session state and multi-agent sync.
118    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    /// Appends a tool call entry to the rotating `tool-calls.log` file.
243    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    /// Persists a CEP (Cognitive Efficiency Protocol) score snapshot for analytics.
360    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}