lean-ctx 3.9.11

Context Runtime for AI Agents with CCP. 71 MCP tools, 10 read modes, 95+ compression patterns, cross-session memory (CCP), persistent AI knowledge with temporal facts + contradiction detection, multi-agent context sharing, LITM-aware positioning, AAAK compact format, adaptive compression with Thompson Sampling bandits. Supports 24+ AI tools. Reduces LLM token consumption by up to 99%.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
//! Output-echo detection (#501).
//!
//! lean-ctx aggressively optimizes the *input* side, but never looked at the
//! agent's *output* — although output tokens cost 4-5x more. The most common
//! waste pattern is the code echo: the agent re-quotes file content that is
//! already in context. The `afterAgentResponse` hook delivers the reply text;
//! this module measures which share of its code lines were echoed from
//! recently read files (context radar tail) and feeds three consumers:
//!
//! 1. rolling stats (`~/.lean-ctx/output_echo.json`) for `ctx_metrics`,
//!    `ctx_session output_stats` and the dashboard,
//! 2. an automatic `LlmFeedbackEvent` so the adaptive mode policy finally
//!    receives continuous data instead of voluntary `ctx_feedback` calls,
//! 3. a stable, cache-friendly CEP nudge the MCP server appends to a tool
//!    result when echo stays high (cooldown-limited).

use std::collections::HashSet;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

const STATS_FILE: &str = "output_echo.json";
/// Rolling window of analyzed responses.
const MAX_REPORTS: usize = 50;
/// Echo lines shorter than this are noise (`}`, `);`, `end`).
const MIN_LINE_CHARS: usize = 12;
/// Nudge when the average echo ratio of the recent window exceeds this.
const NUDGE_THRESHOLD: f64 = 0.30;
/// Responses considered for the nudge decision.
const NUDGE_WINDOW: usize = 5;
/// Minimum analyzed responses between two nudges.
const NUDGE_COOLDOWN: usize = 20;
/// How much of the radar tail to scan for source content (bytes).
const RADAR_TAIL_BYTES: u64 = 262_144;
/// Cap on source documents compared against.
const MAX_SOURCES: usize = 20;

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EchoReport {
    pub response_lines: usize,
    pub code_lines: usize,
    pub echoed_lines: usize,
    pub echo_ratio: f64,
    pub recorded_unix: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EchoStats {
    pub reports: Vec<EchoReport>,
    /// Index (count of analyzed responses) at the last nudge.
    pub last_nudge_at: u64,
    /// Total responses analyzed over the lifetime of the store.
    pub total_analyzed: u64,
}

impl EchoStats {
    pub fn avg_ratio(&self, window: usize) -> f64 {
        let recent: Vec<&EchoReport> = self.reports.iter().rev().take(window).collect();
        if recent.is_empty() {
            return 0.0;
        }
        recent.iter().map(|r| r.echo_ratio).sum::<f64>() / recent.len() as f64
    }

    /// Per-day `(YYYY-MM-DD, avg_echo_ratio, samples)` over the last `days`
    /// days, ascending by day — the dashboard's learning trend (#507).
    /// Days are UTC, consistent with the ledger's day slices.
    pub fn daily_trend(&self, days: u32) -> Vec<(String, f64, u64)> {
        use std::collections::BTreeMap;
        let cutoff = now_unix().saturating_sub(u64::from(days) * 86_400);
        let mut by_day: BTreeMap<String, (f64, u64)> = BTreeMap::new();
        for r in &self.reports {
            if r.recorded_unix < cutoff {
                continue;
            }
            let Some(dt) = chrono::DateTime::from_timestamp(r.recorded_unix as i64, 0) else {
                continue;
            };
            let day = dt.format("%Y-%m-%d").to_string();
            let entry = by_day.entry(day).or_default();
            entry.0 += r.echo_ratio;
            entry.1 += 1;
        }
        by_day
            .into_iter()
            .map(|(d, (sum, n))| (d, sum / n as f64, n))
            .collect()
    }
}

fn stats_path() -> PathBuf {
    crate::core::data_dir::lean_ctx_data_dir()
        .unwrap_or_else(|_| PathBuf::from("."))
        .join(STATS_FILE)
}

fn now_unix() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |d| d.as_secs())
}

pub fn load_stats() -> EchoStats {
    std::fs::read_to_string(stats_path())
        .ok()
        .and_then(|raw| serde_json::from_str(&raw).ok())
        .unwrap_or_default()
}

fn save_stats(stats: &EchoStats) {
    let path = stats_path();
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    if let Ok(json) = serde_json::to_string(stats) {
        let tmp = path.with_extension("tmp");
        if std::fs::write(&tmp, json).is_ok() {
            let _ = std::fs::rename(&tmp, &path);
        }
    }
}

/// Normalize a line for comparison: trim + collapse internal whitespace.
fn normalize_line(line: &str) -> String {
    let mut out = String::with_capacity(line.len());
    let mut last_space = false;
    for c in line.trim().chars() {
        if c.is_whitespace() {
            if !last_space {
                out.push(' ');
            }
            last_space = true;
        } else {
            out.push(c);
            last_space = false;
        }
    }
    out
}

/// Extract the code lines of a markdown response: fenced blocks plus
/// 4-space-indented lines. Prose is never counted as echo.
fn code_lines_of_response(response: &str) -> Vec<String> {
    let mut lines = Vec::new();
    let mut in_fence = false;
    for raw in response.lines() {
        let trimmed = raw.trim_start();
        if trimmed.starts_with("```") {
            in_fence = !in_fence;
            continue;
        }
        if in_fence || raw.starts_with("    ") {
            let norm = normalize_line(raw);
            if norm.chars().count() >= MIN_LINE_CHARS {
                lines.push(norm);
            }
        }
    }
    lines
}

/// Pure analysis: which share of the response's code lines appear verbatim
/// in any source document (recently read file contents)?
pub fn analyze(response: &str, sources: &[String]) -> EchoReport {
    let code_lines = code_lines_of_response(response);
    let response_lines = response.lines().count();

    if code_lines.is_empty() {
        return EchoReport {
            response_lines,
            code_lines: 0,
            echoed_lines: 0,
            echo_ratio: 0.0,
            recorded_unix: now_unix(),
        };
    }

    let mut source_set: HashSet<String> = HashSet::new();
    for src in sources.iter().take(MAX_SOURCES) {
        for line in src.lines() {
            let norm = normalize_line(line);
            if norm.chars().count() >= MIN_LINE_CHARS {
                source_set.insert(norm);
            }
        }
    }

    let echoed = code_lines
        .iter()
        .filter(|l| source_set.contains(*l))
        .count();
    let ratio = echoed as f64 / code_lines.len() as f64;

    EchoReport {
        response_lines,
        code_lines: code_lines.len(),
        echoed_lines: echoed,
        echo_ratio: ratio,
        recorded_unix: now_unix(),
    }
}

/// Read the tail of the context radar log and collect recently delivered
/// content (file reads, MCP tool results, shell output) as echo sources,
/// plus the token sum of events since the last user message (turn input
/// approximation for the feedback event).
fn radar_tail_sources() -> (Vec<String>, u64) {
    let data_dir =
        crate::core::data_dir::lean_ctx_data_dir().unwrap_or_else(|_| PathBuf::from("."));
    let path = data_dir.join("context_radar.jsonl");
    let Ok(file) = std::fs::File::open(&path) else {
        return (Vec::new(), 0);
    };
    use std::io::{Read, Seek, SeekFrom};
    let mut file = file;
    let len = file.metadata().map_or(0, |m| m.len());
    let start = len.saturating_sub(RADAR_TAIL_BYTES);
    if file.seek(SeekFrom::Start(start)).is_err() {
        return (Vec::new(), 0);
    }
    let mut raw = String::new();
    if file.read_to_string(&mut raw).is_err() {
        return (Vec::new(), 0);
    }

    let mut sources: Vec<String> = Vec::new();
    let mut turn_tokens: u64 = 0;
    // Skip the first (possibly truncated) line when we started mid-file.
    let skip_first = start > 0;
    for (i, line) in raw.lines().enumerate() {
        if skip_first && i == 0 {
            continue;
        }
        let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
            continue;
        };
        let event_type = v.get("event_type").and_then(|e| e.as_str()).unwrap_or("");
        let tokens = v
            .get("tokens")
            .and_then(serde_json::Value::as_u64)
            .unwrap_or(0);
        match event_type {
            "user_message" => turn_tokens = 0,
            "agent_response" | "thinking" => {}
            _ => turn_tokens = turn_tokens.saturating_add(tokens),
        }
        if matches!(event_type, "file_read" | "mcp_call" | "shell")
            && let Some(content) = v.get("content").and_then(|c| c.as_str())
            && !content.is_empty()
        {
            sources.push(content.to_string());
        }
    }
    if sources.len() > MAX_SOURCES {
        let excess = sources.len() - MAX_SOURCES;
        sources.drain(..excess);
    }
    (sources, turn_tokens)
}

/// Hook entry point: analyze an agent response against the radar tail,
/// persist rolling stats and emit the automatic feedback event.
pub fn analyze_and_record(response: &str) {
    let (sources, turn_input_tokens) = radar_tail_sources();
    let report = analyze(response, &sources);

    let mut stats = load_stats();
    stats.total_analyzed = stats.total_analyzed.saturating_add(1);
    stats.reports.push(report.clone());
    if stats.reports.len() > MAX_REPORTS {
        let excess = stats.reports.len() - MAX_REPORTS;
        stats.reports.drain(..excess);
    }
    save_stats(&stats);

    emit_feedback_event(response, turn_input_tokens);
}

/// Automatic `LlmFeedbackEvent` (#501): the adaptive mode policy used to
/// depend on voluntary `ctx_feedback` calls that practically never happened.
/// Mode attribution comes from the session's `files_touched.last_mode`
/// aggregation — a session-window approximation, honest and available.
fn emit_feedback_event(response: &str, turn_input_tokens: u64) {
    let output_tokens = crate::core::tokens::count_tokens(response) as u64;
    if output_tokens == 0 {
        return;
    }

    let session = crate::core::session::SessionState::load_latest();
    let modes: Option<std::collections::BTreeMap<String, u64>> = session.as_ref().map(|s| {
        let mut m = std::collections::BTreeMap::new();
        for f in &s.files_touched {
            if !f.last_mode.is_empty() {
                *m.entry(f.last_mode.clone()).or_insert(0) += u64::from(f.read_count.max(1));
            }
        }
        m
    });
    let modes = modes.filter(|m| !m.is_empty());

    let model = crate::hook_handlers::load_detected_model().map(|(name, _)| name);

    let ev = crate::core::llm_feedback::LlmFeedbackEvent {
        agent_id: "output_echo_auto".to_string(),
        intent: session
            .as_ref()
            .and_then(|s| s.task.as_ref())
            .and_then(|t| t.intent.clone()),
        model,
        llm_input_tokens: turn_input_tokens.max(1),
        llm_output_tokens: output_tokens,
        latency_ms: None,
        note: None,
        ctx_read_last_mode: None,
        ctx_read_modes: modes,
        timestamp: chrono::Utc::now().to_rfc3339(),
    };

    let mut policy = crate::core::adaptive_mode_policy::AdaptiveModePolicyStore::load();
    policy.update_from_feedback(&ev);
    let _ = policy.save();
    let _ = crate::core::llm_feedback::LlmFeedbackStore::record(ev);
}

/// CEP nudge for the MCP server to append to a tool result. Stable text in
/// 10%-steps (prompt-cache friendly, #498), cooldown-limited. Consuming the
/// nudge advances the cooldown marker.
pub fn take_pending_nudge() -> Option<String> {
    let mut stats = load_stats();
    if stats.reports.len() < NUDGE_WINDOW {
        return None;
    }
    if stats.total_analyzed.saturating_sub(stats.last_nudge_at) < NUDGE_COOLDOWN as u64 {
        return None;
    }
    let avg = stats.avg_ratio(NUDGE_WINDOW);
    if avg < NUDGE_THRESHOLD {
        return None;
    }
    let rounded = ((avg * 10.0).round() * 10.0) as u32;
    stats.last_nudge_at = stats.total_analyzed;
    save_stats(&stats);
    Some(format!(
        "\n[CEP: ~{rounded}% of your recent replies echoed file content already in context — reference lines (F1:42-58) instead of re-quoting]"
    ))
}

/// Average echo ratio over the rolling window — CEP score input.
pub fn current_avg_ratio() -> f64 {
    load_stats().avg_ratio(MAX_REPORTS)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn file_content() -> String {
        (0..30)
            .map(|i| format!("pub fn compute_value_{i}(input: u32) -> u32 {{ input * {i} }}"))
            .collect::<Vec<_>>()
            .join("\n")
    }

    #[test]
    fn echo_detected_for_quoted_file_content() {
        let src = file_content();
        let quoted: Vec<&str> = src.lines().take(10).collect();
        let response = format!(
            "Here is the relevant code:\n\n```rust\n{}\n```\n",
            quoted.join("\n")
        );
        let report = analyze(&response, &[src]);
        assert_eq!(report.code_lines, 10);
        assert_eq!(report.echoed_lines, 10);
        assert!(report.echo_ratio > 0.99);
    }

    #[test]
    fn prose_response_has_zero_echo() {
        let src = file_content();
        let response = "The cache works by storing entries keyed by path. \
                        No code needed here — the fix is a one-line change.";
        let report = analyze(response, &[src]);
        assert_eq!(report.code_lines, 0);
        assert!(report.echo_ratio.abs() < f64::EPSILON);
    }

    #[test]
    fn daily_trend_groups_by_utc_day_and_averages() {
        let now = now_unix();
        // Anchor both "today" samples to the start of the current UTC day. A naive
        // `now` / `now - 60` pair straddles midnight when the suite runs within 60s
        // of a UTC day rollover, yielding a spurious third day (flaky in CI).
        let day = now - (now % 86_400);
        let stats = EchoStats {
            reports: vec![
                // Two samples on the same UTC day (today): avg of 0.2 and 0.6 = 0.4.
                EchoReport {
                    echo_ratio: 0.2,
                    recorded_unix: day + 100,
                    ..Default::default()
                },
                EchoReport {
                    echo_ratio: 0.6,
                    recorded_unix: day + 200,
                    ..Default::default()
                },
                // One sample two UTC days ago.
                EchoReport {
                    echo_ratio: 1.0,
                    recorded_unix: day - 2 * 86_400 + 100,
                    ..Default::default()
                },
                // Outside the 14-day window — must be excluded.
                EchoReport {
                    echo_ratio: 1.0,
                    recorded_unix: day - 30 * 86_400,
                    ..Default::default()
                },
            ],
            ..Default::default()
        };
        let trend = stats.daily_trend(14);
        assert_eq!(trend.len(), 2, "two distinct days inside the window");
        // Ascending by day: the older day first.
        assert_eq!(trend[0].2, 1, "older day has one sample");
        assert!((trend[0].1 - 1.0).abs() < f64::EPSILON);
        assert_eq!(trend[1].2, 2, "today has two samples");
        assert!((trend[1].1 - 0.4).abs() < 1e-9);
    }

    #[test]
    fn short_lines_are_ignored() {
        let src = "}\n);\nend\nfn x() {}\n".to_string();
        let response = "```rust\n}\n);\nend\n```\n";
        let report = analyze(response, &[src]);
        assert_eq!(report.code_lines, 0, "sub-12-char lines never count");
    }

    #[test]
    fn novel_code_is_not_echo() {
        let src = file_content();
        let response = "```rust\npub fn completely_new_function(a: u64) -> u64 { a + 42 }\nlet result = completely_new_function(7);\n```";
        let report = analyze(response, &[src]);
        assert_eq!(report.echoed_lines, 0);
        assert!(report.echo_ratio.abs() < f64::EPSILON);
    }

    #[test]
    fn whitespace_differences_still_match() {
        let src = "pub fn   spaced_out(value:    u32)   -> u32 { value }".to_string();
        let response = "```rust\npub fn spaced_out(value: u32) -> u32 { value }\n```";
        let report = analyze(response, &[src]);
        assert_eq!(report.echoed_lines, 1);
    }

    #[test]
    fn avg_ratio_windows_correctly() {
        let mut stats = EchoStats::default();
        for ratio in [0.0, 0.2, 0.4, 0.6, 0.8] {
            stats.reports.push(EchoReport {
                response_lines: 10,
                code_lines: 10,
                echoed_lines: (ratio * 10.0) as usize,
                echo_ratio: ratio,
                recorded_unix: 0,
            });
        }
        assert!((stats.avg_ratio(5) - 0.4).abs() < 1e-9);
        assert!((stats.avg_ratio(2) - 0.7).abs() < 1e-9);
    }

    #[test]
    fn indented_code_outside_fences_counts() {
        let src = "    let total = items.iter().map(|i| i.price).sum::<f64>();".to_string();
        let response = "The sum is computed like this:\n\n    let total = items.iter().map(|i| i.price).sum::<f64>();\n";
        let report = analyze(response, &[src]);
        assert_eq!(report.code_lines, 1);
        assert_eq!(report.echoed_lines, 1);
    }
}