lean-ctx 3.9.19

Context Runtime for AI Agents with CCP. 79 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%.
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
482
//! Setup helper routines (skill install, TOML key upserts, profile + premium
//! feature configuration). Split out of `setup/mod.rs` for focus.
#![allow(clippy::items_after_test_module)]

#[allow(clippy::wildcard_imports)]
use super::*;

pub fn install_skill_files(home: &std::path::Path) -> Vec<(String, bool)> {
    crate::rules_inject::install_all_skills(home)
}

pub(crate) fn install_kiro_steering(home: &std::path::Path) {
    let cwd = std::env::current_dir().unwrap_or_else(|_| home.to_path_buf());
    let steering_dir = cwd.join(".kiro").join("steering");
    let steering_file = steering_dir.join("lean-ctx.md");

    if steering_file.exists()
        && std::fs::read_to_string(&steering_file)
            .unwrap_or_default()
            .contains("lean-ctx")
    {
        println!("  Kiro steering file already exists at .kiro/steering/lean-ctx.md");
        return;
    }

    let _ = std::fs::create_dir_all(&steering_dir);
    let _ = std::fs::write(&steering_file, crate::hooks::kiro_steering_content());
    println!(
        "  \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)"
    );
}

pub(crate) fn configure_plan_mode_settings(newly_configured: &[&str], already_configured: &[&str]) {
    use crate::terminal_ui;

    let all_configured: Vec<&str> = newly_configured
        .iter()
        .chain(already_configured.iter())
        .copied()
        .collect();

    let has_vscode = all_configured.contains(&"VS Code");
    let has_claude = all_configured.contains(&"Claude Code");
    let has_codebuddy = all_configured.contains(&"CodeBuddy");

    if !has_vscode && !has_claude && !has_codebuddy {
        return;
    }

    if has_vscode {
        match crate::core::editor_registry::plan_mode::write_vscode_plan_settings() {
            Ok(r) if r.action == WriteAction::Already => {
                terminal_ui::print_status_ok(
                    "VS Code            \x1b[2mplan mode already configured\x1b[0m",
                );
            }
            Ok(_) => {
                terminal_ui::print_status_new(
                    "VS Code            \x1b[2mplan mode tools configured\x1b[0m",
                );
            }
            Err(e) => {
                terminal_ui::print_status_warn(&format!("VS Code plan mode: {e}"));
            }
        }
    }

    if has_claude {
        match crate::core::editor_registry::plan_mode::write_claude_code_plan_permissions() {
            Ok(r) if r.action == WriteAction::Already => {
                terminal_ui::print_status_ok(
                    "Claude Code        \x1b[2mplan mode permissions present\x1b[0m",
                );
            }
            Ok(_) => {
                terminal_ui::print_status_new(
                    "Claude Code        \x1b[2mplan mode permissions added\x1b[0m",
                );
            }
            Err(e) => {
                terminal_ui::print_status_warn(&format!("Claude Code plan mode: {e}"));
            }
        }
    }

    if has_codebuddy {
        match crate::core::editor_registry::plan_mode::write_claude_code_plan_permissions() {
            Ok(r) if r.action == WriteAction::Already => {
                terminal_ui::print_status_ok(
                    "CodeBuddy          \x1b[2mplan mode permissions present\x1b[0m",
                );
            }
            Ok(_) => {
                terminal_ui::print_status_new(
                    "CodeBuddy          \x1b[2mplan mode permissions added\x1b[0m",
                );
            }
            Err(e) => {
                terminal_ui::print_status_warn(&format!("CodeBuddy plan mode: {e}"));
            }
        }
    }
}

pub(crate) fn shorten_path(path: &str, home: &str) -> String {
    if let Some(stripped) = path.strip_prefix(home) {
        format!("~{stripped}")
    } else {
        path.to_string()
    }
}

/// Returns the byte offset where the root (top-level) section ends — i.e. the
/// start of the first `[table]` / `[[array-of-tables]]` header line. Keys
/// inserted before this offset live in the root section. Inserting at the end
/// of the file instead would wrongly place a root key inside whatever table
/// happens to be last (e.g. `[updates]`). If no section header is present,
/// returns `content.len()`.
fn root_section_end(content: &str) -> usize {
    let mut offset = 0;
    for line in content.lines() {
        if line.trim_start().starts_with('[') {
            return offset;
        }
        // `lines()` strips the terminator; account for the consumed `\n`.
        offset += line.len() + 1;
    }
    content.len()
}

fn upsert_toml_key(content: &mut String, key: &str, value: &str) {
    let pattern = format!("{key} = ");
    let root_end = root_section_end(content);
    // Only touch a root-section occurrence so we never clobber a same-named key
    // inside a table (e.g. `compression_level` under `[profiles.cloud]`).
    if let Some(start) = content[..root_end].find(&pattern) {
        let line_end = content[start..]
            .find('\n')
            .map_or(content.len(), |p| start + p);
        content.replace_range(start..line_end, &format!("{key} = \"{value}\""));
    } else {
        // Insert into the root section — before the first `[section]` header —
        // never at the end of the file (which would land the key in the last
        // table, e.g. `[updates]`).
        let insert_at = root_end;
        let mut prefix = String::new();
        if insert_at > 0 && !content[..insert_at].ends_with('\n') {
            prefix.push('\n');
        }
        let new_line = format!("{prefix}{key} = \"{value}\"\n");
        content.insert_str(insert_at, &new_line);
    }
}

fn remove_toml_key(content: &mut String, key: &str) {
    let pattern = format!("{key} = ");
    let root_end = root_section_end(content);
    if let Some(start) = content[..root_end].find(&pattern) {
        let line_end = content[start..]
            .find('\n')
            .map_or(content.len(), |p| start + p + 1);
        content.replace_range(start..line_end, "");
    }
}

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

    #[test]
    fn upsert_inserts_new_key_into_root_before_section() {
        let mut content =
            String::from("memory_profile = \"balanced\"\n\n[updates]\nauto_update = true\n");
        upsert_toml_key(&mut content, "compression_level", "lite");
        let updates_pos = content.find("[updates]").unwrap();
        let key_pos = content.find("compression_level").unwrap();
        assert!(key_pos < updates_pos, "root key must precede [updates]");
        assert!(
            !content[updates_pos..].contains("compression_level"),
            "key must not leak into [updates]"
        );
        assert!(content.contains("compression_level = \"lite\""));
    }

    #[test]
    fn upsert_inserts_root_key_when_section_is_first_line() {
        let mut content = String::from("[updates]\nauto_update = true\n");
        upsert_toml_key(&mut content, "compression_level", "standard");
        assert!(
            content.starts_with("compression_level = \"standard\"\n"),
            "root key should be inserted before the leading section header"
        );
    }

    #[test]
    fn upsert_updates_only_root_occurrence() {
        let mut content = String::from(
            "compression_level = \"off\"\n\n[profiles.cloud]\ncompression_level = \"max\"\n",
        );
        upsert_toml_key(&mut content, "compression_level", "lite");
        assert!(content.starts_with("compression_level = \"lite\""));
        assert!(
            content.contains("[profiles.cloud]\ncompression_level = \"max\""),
            "profile-scoped value must be left untouched"
        );
    }

    #[test]
    fn upsert_inserts_into_empty_content() {
        let mut content = String::new();
        upsert_toml_key(&mut content, "compression_level", "lite");
        assert_eq!(content, "compression_level = \"lite\"\n");
    }

    #[test]
    fn remove_only_deletes_root_occurrence() {
        let mut content =
            String::from("terse_agent = true\n\n[profiles.local]\nterse_agent = true\n");
        remove_toml_key(&mut content, "terse_agent");
        assert!(
            !content.starts_with("terse_agent"),
            "root key should be removed"
        );
        assert!(
            content.contains("[profiles.local]\nterse_agent = true"),
            "profile-scoped value must remain"
        );
    }

    #[test]
    fn root_section_end_handles_no_section() {
        assert_eq!(root_section_end("a = 1\nb = 2\n"), 12);
        assert_eq!(root_section_end(""), 0);
    }

    #[test]
    fn root_section_end_finds_first_header() {
        assert_eq!(root_section_end("a = 1\n[updates]\nb = 2\n"), 6);
        assert_eq!(root_section_end("[updates]\n"), 0);
        assert_eq!(root_section_end("a = 1\n[[items]]\n"), 6);
    }
}

pub(crate) fn configure_tool_profile() {
    use crate::terminal_ui;
    use std::io::Write;

    let cfg = crate::core::config::Config::load();
    let current = cfg.tool_profile_effective();
    let pinned = cfg.tool_profile.is_some() || std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok();

    // An explicitly pinned non-power profile is a deliberate, bounded choice —
    // don't re-nag. Power (pinned or legacy fallback) re-prompts because it
    // advertises every tool schema, the single largest fixed cost (#575).
    if pinned && !matches!(current, crate::core::tool_profiles::ToolProfile::Power) {
        terminal_ui::print_status_ok(&format!(
            "Tool profile: {} ({} tools)",
            current.as_str(),
            current.tool_count()
        ));
        return;
    }

    let dim = "\x1b[2m";
    let bold = "\x1b[1m";
    let cyan = "\x1b[36m";
    let rst = "\x1b[0m";

    let registry_count = crate::server::registry::tool_count();
    let lazy_count = crate::tool_defs::core_tool_names().len();

    println!("  {dim}Control how many MCP tool schemas your AI agent sees.{rst}");
    println!("  {dim}Fewer advertised tools = less context overhead. Every tool stays{rst}");
    println!("  {dim}callable through ctx_call, even when its schema is not advertised.{rst}");
    println!();
    println!(
        "  {cyan}lean{rst}      — {lazy_count} tools  {dim}(lazy core, recommended — lowest token overhead){rst}"
    );
    println!(
        "  {cyan}minimal{rst}   — 5 tools  {dim}(ctx_read, ctx_shell, ctx_search, ctx_glob, ctx_tree){rst}"
    );
    println!("  {cyan}standard{rst}  — 16 tools  {dim}(balanced set for most workflows){rst}");
    println!(
        "  {cyan}power{rst}     — {registry_count} tools  {dim}(everything advertised, costs the most context){rst}"
    );
    println!();
    print!("  Tool profile? {bold}[lean/minimal/standard/power]{rst} {dim}(default: lean){rst} ");
    std::io::stdout().flush().ok();

    let mut profile_input = String::new();
    let profile_name = if std::io::stdin().read_line(&mut profile_input).is_ok() {
        let trimmed = profile_input.trim().to_lowercase();
        match trimmed.as_str() {
            "minimal" | "min" => "minimal",
            "standard" | "std" => "standard",
            "power" | "full" | "all" => "power",
            _ => "lean",
        }
    } else {
        "lean"
    };

    if profile_name == "lean" {
        match crate::core::tool_profiles::clear_profile_in_config() {
            Ok(()) => terminal_ui::print_status_ok(&format!(
                "Tool profile: lean ({lazy_count} tools advertised, all reachable via ctx_call)"
            )),
            Err(e) => terminal_ui::print_status_warn(&format!("Could not save tool profile: {e}")),
        }
        return;
    }

    match crate::core::tool_profiles::set_profile_in_config(profile_name) {
        Ok(()) => {
            let profile = crate::core::tool_profiles::ToolProfile::parse(profile_name)
                .unwrap_or(crate::core::tool_profiles::ToolProfile::Standard);
            let count = match &profile {
                crate::core::tool_profiles::ToolProfile::Power => registry_count,
                other => other.tool_count(),
            };
            terminal_ui::print_status_ok(&format!("Tool profile: {profile_name} ({count} tools)"));
        }
        Err(e) => {
            terminal_ui::print_status_warn(&format!("Could not save tool profile: {e}"));
        }
    }
}

pub(crate) fn configure_premium_features(home: &std::path::Path) {
    use crate::terminal_ui;
    use std::io::Write;

    let config_path = crate::core::config::Config::path()
        .unwrap_or_else(|| home.join(".config/lean-ctx").join("config.toml"));
    if let Some(dir) = config_path.parent() {
        let _ = std::fs::create_dir_all(dir);
    }
    let mut config_content = std::fs::read_to_string(&config_path).unwrap_or_default();

    let dim = "\x1b[2m";
    let bold = "\x1b[1m";
    let cyan = "\x1b[36m";
    let rst = "\x1b[0m";

    // Unified Compression Level (replaces terse_agent + output_density)
    println!("\n  {bold}Compression Level{rst} {dim}(controls all token optimization layers){rst}");
    println!("  {dim}Applies to tool output, agent prompts, and protocol mode.{rst}");
    println!();
    println!("  {cyan}off{rst}      — No compression (full verbose output)");
    println!(
        "  {cyan}lite{rst}     — Light: concise output, basic terse filtering {dim}(~25% savings){rst}"
    );
    println!(
        "  {cyan}standard{rst} — Dense output + compact protocol + pattern-aware {dim}(~45% savings){rst}"
    );
    println!(
        "  {cyan}max{rst}      — Expert mode: TDD protocol, all layers active {dim}(~65% savings){rst}"
    );
    println!();
    print!("  Compression level? {bold}[off/lite/standard/max]{rst} {dim}(default: off){rst} ");
    std::io::stdout().flush().ok();

    let mut level_input = String::new();
    let level = if std::io::stdin().read_line(&mut level_input).is_ok() {
        match level_input.trim().to_lowercase().as_str() {
            "lite" => "lite",
            "standard" | "std" => "standard",
            "max" => "max",
            _ => "off",
        }
    } else {
        "off"
    };

    // Stage the compression change in the config text; the success line is only
    // emitted after the write below actually persists (#415).
    let (effective_level, compression_status) = if level != "off" {
        upsert_toml_key(&mut config_content, "compression_level", level);
        remove_toml_key(&mut config_content, "terse_agent");
        remove_toml_key(&mut config_content, "output_density");
        (
            crate::core::config::CompressionLevel::from_str_label(level),
            StatusLine::ok(format!("Compression: {level}")),
        )
    } else if config_content.contains("compression_level") {
        upsert_toml_key(&mut config_content, "compression_level", "off");
        (
            Some(crate::core::config::CompressionLevel::Off),
            StatusLine::ok("Compression: off".to_string()),
        )
    } else {
        (
            Some(crate::core::config::CompressionLevel::Off),
            StatusLine::skip(
                "Compression: off (change later with: lean-ctx compression <level>)".to_string(),
            ),
        )
    };

    // Tool Result Archive
    println!(
        "\n  {bold}Tool Result Archive{rst} {dim}(zero-loss: large outputs archived, retrievable via ctx_expand){rst}"
    );
    print!("  Enable auto-archive? {bold}[Y/n]{rst} ");
    std::io::stdout().flush().ok();

    let mut archive_input = String::new();
    let archive_on = if std::io::stdin().read_line(&mut archive_input).is_ok() {
        let a = archive_input.trim().to_lowercase();
        a.is_empty() || a == "y" || a == "yes"
    } else {
        true
    };

    let archive_status = if archive_on && !config_content.contains("[archive]") {
        if !config_content.is_empty() && !config_content.ends_with('\n') {
            config_content.push('\n');
        }
        config_content.push_str("\n[archive]\nenabled = true\n");
        Some(StatusLine::ok("Tool Result Archive: enabled".to_string()))
    } else if !archive_on {
        Some(StatusLine::skip(
            "Archive: off (enable later in config.toml)".to_string(),
        ))
    } else {
        None
    };

    // Single atomic write. Only claim success — and only inject the rules prompt —
    // once the config has genuinely been persisted; a swallowed write error here
    // is exactly what made setup report settings it never applied (#415).
    match crate::config_io::write_atomic_with_backup(&config_path, &config_content) {
        Ok(()) => {
            compression_status.emit();
            if effective_level.is_some() {
                let home = dirs::home_dir().unwrap_or_default();
                let result = crate::rules_inject::inject_all_rules(&home);
                if !result.updated.is_empty() {
                    terminal_ui::print_status_ok(&format!(
                        "Updated {} rules file(s) with compression prompt",
                        result.updated.len()
                    ));
                }
            }
            if let Some(status) = archive_status {
                status.emit();
            }
        }
        Err(e) => {
            terminal_ui::print_status_warn(&format!(
                "Could not save settings to {}: {e}",
                config_path.display()
            ));
            terminal_ui::print_status_warn(
                "Premium features were not applied — re-run `lean-ctx setup` or edit config.toml manually",
            );
        }
    }
}

/// A setup status line whose emission is deferred until the underlying config
/// write succeeds, so the wizard never reports a setting it failed to persist.
struct StatusLine {
    skip: bool,
    msg: String,
}

impl StatusLine {
    fn ok(msg: String) -> Self {
        Self { skip: false, msg }
    }
    fn skip(msg: String) -> Self {
        Self { skip: true, msg }
    }
    fn emit(&self) {
        if self.skip {
            crate::terminal_ui::print_status_skip(&self.msg);
        } else {
            crate::terminal_ui::print_status_ok(&self.msg);
        }
    }
}