lean-ctx 3.9.17

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
//! Dashboard settings API (#427) — read + flip the four high-impact, mid-session
//! switches (compression level, tool profile, `structure_first`, terse agent)
//! without dropping to the terminal.
//!
//! Security: this is the dashboard's only *write* surface besides the existing
//! memory/knowledge POSTs, and it inherits the same protection — every `/api/*`
//! request is Bearer-token gated and (for POST) CSRF-`Origin` checked *before*
//! the router runs (see `dashboard/mod.rs::handle_request`). On top of that, the
//! value is validated against a fixed allow-list here and again by the schema
//! round-trip in `config::setter::set_by_key`, so an authenticated client can
//! only ever land a known-good value into `config.toml`.

use serde::Deserialize;

use super::helpers::json_err;
use crate::core::config::{CompressionLevel, Config, TerseAgent};
use crate::core::tool_profiles::ToolProfile;

const COMPRESSION_OPTIONS: &[&str] = &["off", "lite", "standard", "max"];
const TERSE_OPTIONS: &[&str] = &["off", "lite", "full", "ultra"];
// `lean` unpins the profile (clears the key) — same as `lean-ctx tools lean`.
const TOOL_PROFILE_OPTIONS: &[&str] = &["minimal", "standard", "power", "lean"];

pub(super) fn handle(
    path: &str,
    _query_str: &str,
    method: &str,
    body: &str,
) -> Option<(&'static str, &'static str, String)> {
    match path {
        "/api/settings" if method.eq_ignore_ascii_case("POST") => Some(post_settings(body)),
        "/api/settings" => Some(("200 OK", "application/json", settings_payload())),
        _ => None,
    }
}

#[derive(Deserialize)]
struct SettingReq {
    key: String,
    value: serde_json::Value,
}

fn post_settings(body: &str) -> (&'static str, &'static str, String) {
    let req: SettingReq = match serde_json::from_str(body) {
        Ok(r) => r,
        Err(e) => {
            return (
                "400 Bad Request",
                "application/json",
                json_err(&format!("invalid JSON: {e}")),
            );
        }
    };

    let Some(value) = normalize_value(&req.value) else {
        return (
            "400 Bad Request",
            "application/json",
            json_err("value must be a string or boolean"),
        );
    };

    match apply_setting(&req.key, &value) {
        // Echo the fresh state so the UI repaints from the source of truth.
        Ok(()) => ("200 OK", "application/json", settings_payload()),
        Err(e) => ("400 Bad Request", "application/json", json_err(&e)),
    }
}

/// Coerce a JSON setting value into the canonical string the setters expect.
/// Booleans (the `structure_first` toggle) become `"true"`/`"false"`.
fn normalize_value(v: &serde_json::Value) -> Option<String> {
    match v {
        serde_json::Value::String(s) => Some(s.trim().to_string()),
        serde_json::Value::Bool(b) => Some(b.to_string()),
        _ => None,
    }
}

/// Validate `key`/`value` against the fixed allow-list. Pure (no disk I/O) so it
/// is unit-testable and acts as the first of two validation gates.
fn validate_setting(key: &str, value: &str) -> Result<(), String> {
    match key {
        "compression_level" => ensure_in(value, COMPRESSION_OPTIONS),
        "terse_agent" => ensure_in(value, TERSE_OPTIONS),
        "tool_profile" => ensure_in(value, TOOL_PROFILE_OPTIONS),
        "structure_first" => ensure_bool(value),
        _ => Err(format!("unknown or non-editable setting: '{key}'")),
    }
}

fn apply_setting(key: &str, value: &str) -> Result<(), String> {
    validate_setting(key, value)?;
    match key {
        "compression_level" => apply_compression(value),
        "tool_profile" => apply_tool_profile(value),
        "structure_first" => crate::core::config::setter::set_by_key("structure_first", value)
            .map(|_| ())
            .map_err(|e| e.to_string()),
        "terse_agent" => apply_terse_agent(value),
        // validate_setting already rejected anything else.
        _ => Err(format!("unknown or non-editable setting: '{key}'")),
    }
}

/// Mirror a `terse_agent` change: persist it *and* re-inject the agent rules.
/// `terse_agent` is a legacy input to `CompressionLevel::effective`, and the
/// injected rules are derived from that effective level — so without a re-inject
/// the change would not reach the agent (and the UI footer's "terse changes
/// re-inject the agent rules" claim would be false).
fn apply_terse_agent(value: &str) -> Result<(), String> {
    crate::core::config::setter::set_by_key("terse_agent", value).map_err(|e| e.to_string())?;
    let home = dirs::home_dir().unwrap_or_default();
    let _ = crate::rules_inject::inject_all_rules(&home);
    Ok(())
}

/// Mirror `lean-ctx compression <level>`: persist the level *and* re-inject the
/// compression prompt into the agent rules files so the change actually lands.
fn apply_compression(value: &str) -> Result<(), String> {
    let level = CompressionLevel::from_str_label(value)
        .ok_or_else(|| format!("invalid compression level '{value}'"))?;
    let _cfg = Config::update_global(move |c| c.compression_level = level)
        .map_err(|e| format!("Error saving config: {e}"))?;
    let home = dirs::home_dir().unwrap_or_default();
    let _ = crate::rules_inject::inject_all_rules(&home);
    Ok(())
}

/// Mirror `lean-ctx tools <profile>`: pin minimal/standard/power, or unpin on
/// `lean` (clear the key so the default unpinned behaviour returns).
fn apply_tool_profile(value: &str) -> Result<(), String> {
    match value {
        "minimal" | "standard" | "power" => {
            crate::core::tool_profiles::set_profile_in_config(value)
        }
        "lean" => crate::core::tool_profiles::clear_profile_in_config(),
        other => Err(format!("invalid tool profile '{other}'")),
    }
}

fn ensure_in(value: &str, allowed: &[&str]) -> Result<(), String> {
    if allowed.contains(&value) {
        Ok(())
    } else {
        Err(format!(
            "invalid value '{value}'. Allowed: {}",
            allowed.join(", ")
        ))
    }
}

fn ensure_bool(value: &str) -> Result<(), String> {
    match value {
        "true" | "false" | "1" | "0" | "yes" | "no" | "on" | "off" => Ok(()),
        _ => Err(format!("expected a boolean, got '{value}'")),
    }
}

/// Snapshot of the four settings as the UI needs them: the active value, the
/// selectable options, and whether an environment variable or a project-local
/// `.lean-ctx.toml` currently overrides the persisted config (so the UI can warn
/// that a toggle won't take effect until that source is removed).
///
/// GH #450: the top-level `config_path`/`config_exists`/`parse_error` fields let
/// the UI show *which* `config.toml` is being read — the missing piece that made
/// "my settings keep resetting" undiagnosable. `local_override` mirrors the keys
/// `Config::merge_local` honors (compression/terse/tool profile; `structure_first`
/// is never merged from local config, so it carries no `local_override`).
fn settings_payload() -> String {
    let cfg = Config::load();
    let prov = Config::provenance();
    let local = |key: &str| prov.local_overrides(key);
    let payload = serde_json::json!({
        "config_path": prov.config_path.as_ref().map(|p| p.display().to_string()),
        "config_exists": prov.config_exists,
        "parse_error": prov.parse_error,
        "settings": {
            "compression_level": {
                "value": compression_canon(&CompressionLevel::effective(&cfg)),
                "options": COMPRESSION_OPTIONS,
                "env_override": env_present("LEAN_CTX_COMPRESSION"),
                "local_override": local("compression_level"),
            },
            "tool_profile": {
                "value": tool_profile_value(&cfg),
                "options": TOOL_PROFILE_OPTIONS,
                "env_override": env_present("LEAN_CTX_TOOL_PROFILE"),
                "local_override": local("tool_profile"),
            },
            "structure_first": {
                "value": cfg.structure_first_effective(),
                "env_override": env_present("LEAN_CTX_STRUCTURE_FIRST"),
            },
            "terse_agent": {
                "value": terse_canon(&cfg.terse_agent),
                "options": TERSE_OPTIONS,
                "env_override": env_present("LEAN_CTX_TERSE_AGENT"),
                "local_override": local("terse_agent"),
            },
        }
    });
    payload.to_string()
}

fn env_present(key: &str) -> bool {
    std::env::var_os(key).is_some_and(|v| !v.is_empty())
}

fn compression_canon(level: &CompressionLevel) -> &'static str {
    match level {
        CompressionLevel::Off => "off",
        CompressionLevel::Lite => "lite",
        CompressionLevel::Standard => "standard",
        CompressionLevel::Max | CompressionLevel::Raw => "max",
    }
}

fn terse_canon(t: &TerseAgent) -> &'static str {
    match t {
        TerseAgent::Off => "off",
        TerseAgent::Lite => "lite",
        TerseAgent::Full => "full",
        TerseAgent::Ultra => "ultra",
    }
}

fn tool_profile_canon(p: &ToolProfile) -> &'static str {
    match p {
        ToolProfile::Minimal => "minimal",
        ToolProfile::Standard => "standard",
        ToolProfile::Power => "power",
        ToolProfile::Custom(_) => "custom",
        ToolProfile::Auto => "auto",
    }
}

/// Canonical value for the dashboard's tool-profile toggle.
///
/// The unpinned default and an explicit `power` pin both resolve to
/// `ToolProfile::Power` internally, so reporting the *effective* profile made
/// the UI snap "Lean" back to "Power" the instant it was selected (#431). This
/// mirrors `ToolProfile::from_config`'s precedence but maps the unpinned state
/// to the `lean` sentinel the UI understands.
fn tool_profile_value(cfg: &Config) -> &'static str {
    // An env override wins and is surfaced separately via `env_override`.
    if env_present("LEAN_CTX_TOOL_PROFILE") {
        return tool_profile_canon(&cfg.tool_profile_effective());
    }
    // A real pin (minimal/standard/power) takes precedence. Unpin aliases
    // (`lean`/`lazy`/`reset`) and unknown literals fail to parse and fall
    // through to the same default resolution `from_config` uses.
    if let Some(name) = cfg.tool_profile.as_deref()
        && let Some(profile) = ToolProfile::parse(name)
    {
        return tool_profile_canon(&profile);
    }
    if !cfg.tools_enabled.is_empty() {
        return "custom";
    }
    "lean"
}

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

    #[test]
    fn validate_accepts_known_enum_values() {
        assert!(validate_setting("compression_level", "max").is_ok());
        assert!(validate_setting("terse_agent", "ultra").is_ok());
        assert!(validate_setting("tool_profile", "power").is_ok());
        assert!(validate_setting("tool_profile", "lean").is_ok());
        assert!(validate_setting("structure_first", "true").is_ok());
        assert!(validate_setting("structure_first", "false").is_ok());
    }

    #[test]
    fn validate_rejects_bad_values() {
        assert!(validate_setting("compression_level", "ultra").is_err());
        assert!(validate_setting("terse_agent", "max").is_err());
        assert!(validate_setting("tool_profile", "turbo").is_err());
        assert!(validate_setting("structure_first", "maybe").is_err());
    }

    #[test]
    fn validate_rejects_unknown_or_dangerous_keys() {
        assert!(validate_setting("proxy.anthropic_upstream", "http://evil").is_err());
        assert!(validate_setting("allow_paths", "/etc").is_err());
        assert!(validate_setting("", "x").is_err());
    }

    #[test]
    fn normalize_bool_and_string_values() {
        assert_eq!(
            normalize_value(&serde_json::json!(true)).as_deref(),
            Some("true")
        );
        assert_eq!(
            normalize_value(&serde_json::json!(false)).as_deref(),
            Some("false")
        );
        assert_eq!(
            normalize_value(&serde_json::json!("max")).as_deref(),
            Some("max")
        );
        assert_eq!(
            normalize_value(&serde_json::json!("  power  ")).as_deref(),
            Some("power")
        );
        assert!(normalize_value(&serde_json::json!(42)).is_none());
        assert!(normalize_value(&serde_json::json!(null)).is_none());
    }

    /// GH #431: the unpinned default and a real `power` pin both resolve to
    /// `ToolProfile::Power`, but the UI must tell them apart so selecting "Lean"
    /// does not snap back to "Power".
    #[test]
    fn tool_profile_value_distinguishes_lean_from_power() {
        let _env_lock = crate::core::data_dir::test_env_lock();
        // Avoid env interference from the host running the suite.
        crate::test_env::remove_var("LEAN_CTX_TOOL_PROFILE");

        let unpinned = Config {
            tool_profile: None,
            tools_enabled: vec![],
            ..Default::default()
        };
        assert_eq!(tool_profile_value(&unpinned), "lean");

        // A persisted unpin alias self-heals to lean instead of "power".
        let aliased = Config {
            tool_profile: Some("lean".into()),
            ..Default::default()
        };
        assert_eq!(tool_profile_value(&aliased), "lean");

        let pinned = Config {
            tool_profile: Some("power".into()),
            ..Default::default()
        };
        assert_eq!(tool_profile_value(&pinned), "power");

        let minimal = Config {
            tool_profile: Some("minimal".into()),
            ..Default::default()
        };
        assert_eq!(tool_profile_value(&minimal), "minimal");

        let custom = Config {
            tool_profile: None,
            tools_enabled: vec!["ctx_read".into()],
            ..Default::default()
        };
        assert_eq!(tool_profile_value(&custom), "custom");
    }

    #[test]
    fn payload_is_valid_json_with_all_four_settings() {
        let raw = settings_payload();
        let v: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON");
        let s = &v["settings"];
        for key in [
            "compression_level",
            "tool_profile",
            "structure_first",
            "terse_agent",
        ] {
            assert!(s.get(key).is_some(), "missing setting {key}");
            assert!(
                s[key].get("env_override").is_some(),
                "missing env_override for {key}"
            );
        }
    }

    /// GH #450: the payload must carry the resolved config provenance so the UI
    /// can show *which* config.toml is read and warn on a project-local override.
    #[test]
    fn payload_exposes_config_provenance() {
        let raw = settings_payload();
        let v: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON");

        assert!(v.get("config_path").is_some(), "missing config_path");
        assert!(
            v.get("config_exists")
                .is_some_and(serde_json::Value::is_boolean),
            "config_exists must be a bool"
        );
        assert!(v.get("parse_error").is_some(), "missing parse_error key");

        let s = &v["settings"];
        // The three locally-mergeable settings expose local_override…
        for key in ["compression_level", "tool_profile", "terse_agent"] {
            assert!(
                s[key]
                    .get("local_override")
                    .is_some_and(serde_json::Value::is_boolean),
                "missing local_override bool for {key}"
            );
        }
        // …structure_first is never merged from local config, so it has none.
        assert!(
            s["structure_first"].get("local_override").is_none(),
            "structure_first must not carry local_override"
        );
    }
}