//! Advanced config sections (proxy, memory subsystems, custom_aliases, setup, llm).
//! Split out of `schema/mod.rs`; `use super::*` re-imports helpers + `SectionSchema`.
#[allow(clippy::wildcard_imports)]
use super::*;
use std::collections::BTreeMap;
pub(super) fn build(sections: &mut BTreeMap<String, SectionSchema>) {
let cfg = crate::core::config::Config::default();
let mut proxy = BTreeMap::new();
proxy.insert(
"anthropic_upstream".into(),
key(
"string?",
serde_json::json!(cfg.proxy.anthropic_upstream),
"Custom upstream URL for Anthropic API proxy",
),
);
proxy.insert(
"openai_upstream".into(),
key(
"string?",
serde_json::json!(cfg.proxy.openai_upstream),
"Custom upstream URL for OpenAI API proxy",
),
);
proxy.insert(
"chatgpt_upstream".into(),
key(
"string?",
serde_json::json!(cfg.proxy.chatgpt_upstream),
"Custom upstream URL for ChatGPT/Codex subscription API proxy",
),
);
proxy.insert(
"gemini_upstream".into(),
key(
"string?",
serde_json::json!(cfg.proxy.gemini_upstream),
"Custom upstream URL for Gemini API proxy",
),
);
proxy.insert(
"history_mode".into(),
key_enum_with_env(
&["cache-aware", "rolling", "off"],
"cache-aware",
"History pruning strategy. cache-aware: frozen boundaries that keep provider prompt caches valid (default). rolling: legacy moving window (max raw savings, breaks prompt caching). off: never prune",
"LEAN_CTX_PROXY_HISTORY_MODE",
),
);
proxy.insert(
"allow_insecure_http_upstream".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.allow_insecure_http_upstream.unwrap_or(false)),
"Allow a non-loopback plaintext http:// upstream (trusted local network only, e.g. http://host.docker.internal:2455 in front of codex-lb). Opt-in; default false",
"LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM",
),
);
proxy.insert(
"allow_custom_upstream".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.allow_custom_upstream.unwrap_or(false)),
"Allow a custom (non-allowlisted) HTTPS upstream host, e.g. a corporate gateway in front of the provider API. Opt-in; default false. Unlike the env var, this config flag reaches the managed (service-spawned) proxy started by `proxy enable`/`restart` (#590)",
"LEAN_CTX_ALLOW_CUSTOM_UPSTREAM",
),
);
proxy.insert(
"meter_openai_usage".into(),
key(
"bool",
serde_json::json!(cfg.proxy.meters_openai_usage()),
"Inject stream_options.include_usage into streamed OpenAI Chat Completions so the final chunk reports real token usage for the measured spend meter. Default true",
),
);
proxy.insert(
"cost_response_header".into(),
key(
"string",
serde_json::json!(cfg.proxy.cost_response_header()),
"Extra response header carrying the upstream gateway's billed USD for the turn (e.g. a corporate gateway's cost header). LiteLLM's x-litellm-response-cost is always recognized. Measured header costs beat table estimates; body-reported costs (OpenRouter usage.cost) beat headers",
),
);
proxy.insert(
"cold_prefix_repack".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.repacks_cold_prefix()),
"Opt-in big-gap cold-prefix repack (#480): on a session-resume request the proxy may predict (from idle time vs the provider cache TTL) that the client-cached prefix has already expired, then prune that now-cold prefix to re-seed a leaner cache and keep applying the same deterministic compression on later turns so warm follow-ups hit it (sticky; baselines persist across restarts, #499). A wrong guess re-bills cache reads as writes (~12x), so default false",
"LEAN_CTX_PROXY_COLD_PREFIX_REPACK",
),
);
proxy.insert(
"live_compress".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.live_compresses()),
"Live-compress non-protected tool_result content on the wire (#481). Default true. Set false for a meter-only proxy — real billed/cache token metering with zero request rewriting (combine with history_mode = \"off\" and no role_aggressiveness for a byte-unchanged body)",
"LEAN_CTX_PROXY_LIVE_COMPRESS",
),
);
proxy.insert(
"project_root".into(),
key_with_env(
"string",
serde_json::json!(cfg.proxy.configured_project_root()),
"Absolute project root the proxy acts for: its admissions apply that project's policy pack, and sources of provider-sealed items and recovery copies are re-admitted for it. Operator-set only — no request header, tag or body field selects it. Verified at every use (exists, canonical, not a broad or unsafe root, equal to LEAN_CTX_REQUIRED_POLICY_ROOT when that is set; a mismatch refuses every request). Unset: no configured project — under a policy pack, governed or sovereign mode, sealed provider items with sources and recovery copies then need a launch-pinned root with a verified pack",
"LEAN_CTX_PROXY_PROJECT_ROOT",
),
);
proxy.insert(
"live_compress_exclude".into(),
key(
"string[]",
serde_json::json!(cfg.proxy.live_compress_exclude_patterns()),
"Tool-name patterns (case-insensitive substring) whose tool_result is never live-compressed — treated as protected, like a file read (#481). Unset protects Serena's code-reading tools; set an explicit list to narrow it, or [] to disable",
),
);
proxy.insert(
"compress_protect".into(),
key(
"string[]",
serde_json::json!(cfg.proxy.compress_protect.clone().unwrap_or_default()),
"File-path globs whose reads are never compressed (#1150): a matching path is returned verbatim (full) by the read tools, for files where exact bytes matter more than token savings (golden snapshots, byte-asserted fixtures, security-sensitive configs). Globs (*/**/?) match the path and its file name, so *.snap, **/golden/**, tests/fixtures/* all work. Empty (default) protects nothing — the lossless crushers and beneficial gate already keep compression safe; this is an explicit escape hatch",
),
);
proxy.insert(
"ccr_inband".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.ccr_inband_enabled()),
"Opt-in in-band CCR retrieval for a remote proxy with no shared filesystem (#493). When on, a lossy stub advertises a compact <lc_expand:HASH> marker instead of a local tee path; when the model echoes that marker, the proxy splices the verbatim original (from its local tee store) back inline next turn — one turn of latency, no MCP/filesystem on the agent host. The splice is a strict no-op on marker-less turns, so it never perturbs the provider cache prefix unless the model asked to expand. Default false",
"LEAN_CTX_PROXY_CCR_INBAND",
),
);
proxy.insert(
"counterfactual_metering".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.counterfactual_metering_enabled()),
"Opt-in counterfactual savings metering (#701): each rewritten Anthropic /v1/messages request fires a free count_tokens probe with the original, uncompressed body, concurrently with the real forward. The provider-counted answer is paired with the same response's billed usage — provider-authoritative savings receipts ('would have cost N, billed M') instead of local tokenizer estimates, shown as verified_savings on /status. The probe never mutates or delays the forwarded request; failures degrade to the estimate. Default false (one extra free HTTP call per compressed request)",
"LEAN_CTX_PROXY_COUNTERFACTUAL",
),
);
proxy.insert(
"cache_breakpoint".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.cache_breakpoint_enabled()),
"Opt-in active prompt-cache breakpoint injection for Anthropic (#939). When on and the client set no cache_control of its own, the proxy adds one cache_control: {type:ephemeral} marker to the system field so an otherwise-uncached, stable system prompt bills later turns at the cached rate (the win a raw API client leaves on the table). Anthropic-only: OpenAI/Gemini cache prefixes automatically and ignore the marker, so those paths stay byte-unchanged. Deterministic, never adds a second breakpoint, and skipped below Anthropic's minimum cacheable size. Default false",
"LEAN_CTX_PROXY_CACHE_BREAKPOINT",
),
);
proxy.insert(
"cache_ttl_loop".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.cache_ttl_loop_enabled()),
"Agent-loop cache lifetime for Anthropic. While a request ends in tool results, the conversation's cache markers are written for 5 minutes instead of 1 hour (1.25x instead of 2x the input price); the person's last prompt keeps a 1 h marker of its own, so a long pause loses at most this turn's tool steps. System and tool markers keep their lifetime; headless clients (User-Agent entrypoint sdk-…, e.g. claude -p) get 5 minutes from the first request. Measured -17.9 % cost on Claude Code -p (Haiku 5.5). Default true",
"LEAN_CTX_PROXY_CACHE_TTL_LOOP",
),
);
proxy.insert(
"long_session_elide".into(),
key_with_env(
"bool",
serde_json::json!(false),
"Long-session elision for Anthropic. Once a conversation's messages pass 180 000 characters, tool output older than the live window (at least the last 8 messages) and longer than 2 000 characters is replaced by a one-line stub; the full text is archived in a private, content-addressed file under the lean-ctx state directory that the stub names (readable with cat). The window advances in steps of 16 messages, so the cached prefix is rewritten once per step, not every turn. It applies to every turn of such a session, prompt turns included, so they send the same prefix as the tool turn before. Opt-in: on long Claude Code sessions (Haiku 5.5, three runs, 44 tasks per arm) it cut cost to x0.67-0.92 against native but solved 37 of 44 tasks against 42 of 44, when the model answered without reading back an elided early output. Default false",
"LEAN_CTX_PROXY_LONG_SESSION_ELIDE",
),
);
proxy.insert(
"cache_aligner".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.cache_aligner_enabled()),
"Cache-aligner volatile-field telemetry (#940), on by default. The proxy scans each unanchored Anthropic system prompt for volatile, cache-busting fields (ISO dates/datetimes, UUIDs, git SHAs) and reports how many it found on /status cache_safety (volatile_system_requests, volatile_fields_detected) - purely to quantify how much prompt-cache the client leaks. Measurement only: the request body is never mutated, so it is strictly cache-safe, which is why it ships on for every proxy (#986 premium defaults). The deterministic scan is the precursor to the opt-in tail-relocate below. Set false to opt out of the per-request scan. Default true",
"LEAN_CTX_PROXY_CACHE_ALIGNER",
),
);
proxy.insert(
"cache_align_relocate".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.cache_align_relocate_enabled()),
"Opt-in active cache-aligner relocate (#974). When on, the proxy rewrites an unanchored Anthropic system prompt into a stable block (volatile values - ISO dates/datetimes, UUIDs, git SHAs - replaced by constant placeholders) carrying the cache_control breakpoint, plus an uncached trailing block that re-states the relocated values. The cacheable prefix then stays byte-stable turn-to-turn and finally caches; only the small tail is reprocessed. Anthropic-only, Treatment-arm, gated on a client that anchored nothing and on Anthropic's minimum cacheable size. Deterministic (#498) and idempotent. The cache_aligner telemetry is the precursor that quantifies the saving. Default false",
"LEAN_CTX_PROXY_CACHE_ALIGN_RELOCATE",
),
);
proxy.insert(
"cache_policy".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.cache_policy_enabled()),
"Cache-economics (#986), on by default. Enables prompt-cache miss attribution telemetry (per turn, classify the outcome as cold start / warm reuse / TTL lapse / prefix change and report cumulative gauges on /status cache_attribution) plus a net-cost gate on the cold-prefix repack that skips re-seeding prefixes too small to be cached (below Anthropic's ~1024-token minimum). The telemetry never mutates the body and the gate only makes repacking more conservative, so it can never bust a cache that would otherwise have been kept - both halves are strictly safe, so every proxy gets them out of the box (#986 premium defaults). Set false to opt out (drops the /status attribution gauges and the per-request prefix hash). Default true",
"LEAN_CTX_PROXY_CACHE_POLICY",
),
);
proxy.insert(
"effort".into(),
key_enum_with_env(
&["off", "minimal", "low", "medium", "high"],
"off",
"Cache-safe cross-provider reasoning-effort control (#834). off (default) = no-op. minimal|low|medium|high pins the model's reasoning depth across providers: lean-ctx translates it to OpenAI reasoning_effort / reasoning.effort, Anthropic output_config.effort, and Gemini thinkingConfig (thinkingLevel on 3.x, thinkingBudget on 2.5 pro/flash), only on models that accept it and only when the client didn't set its own value. The level is a constant, so it never breaks the provider prompt cache (unlike per-turn effort routing). Anthropic is dialed only when the client already requested adaptive thinking",
"LEAN_CTX_PROXY_EFFORT",
),
);
proxy.insert(
"prose_ranker".into(),
key_enum_with_env(
&["auto", "extractive", "truncate"],
"auto",
"How the proxy squeezes prose it must shrink (#895). auto (default) and extractive use embedding-based extractive ranking — keeping the most central sentences instead of just the prefix — when the local embedding engine is available, else fall back to truncation; truncate keeps the original deterministic FIFO squeeze and never loads the engine. Wire rewrites are memoized per content so the engine's cold→warm transition never changes an already-emitted frozen-region rewrite (cache-safe, #448/#498)",
"LEAN_CTX_PROXY_PROSE_RANKER",
),
);
proxy.insert(
"output_holdout".into(),
key_with_env(
"f64",
serde_json::json!(cfg.proxy.output_holdout_fraction()),
"Fraction 0.0-1.0 of conversations placed in the output-savings control arm (#895). 0 (default) = no holdout (every conversation is output-shaped). When > 0, a deterministic cohort = blake3(system + first user message) puts ~this fraction in a control arm that skips output-shaping (effort control + verbosity steer) but is still metered, yielding an honest measured output-token reduction (lean-ctx output-savings). The cohort is a pure function of conversation identity, so a conversation keeps one arm across all turns - cache-safe",
"LEAN_CTX_PROXY_OUTPUT_HOLDOUT",
),
);
proxy.insert(
"compression_holdout".into(),
key_with_env(
"f64",
serde_json::json!(cfg.proxy.compression_holdout_fraction()),
"Fraction 0.0-1.0 of conversations placed in the input-compression control arm (#1905). 0 (default) = no holdout. When > 0, a deterministic cohort (salted independently of output_holdout) puts ~this fraction in a control arm forwarded without any of the proxy's input compression (tool-output compression, history pruning, prose rewriting, cold-prefix repack) but still metered, so the input-token reduction is measured against a real uncompressed baseline (lean-ctx output-savings). Answer quality is not measured by this holdout and is reported as unknown",
"LEAN_CTX_PROXY_COMPRESSION_HOLDOUT",
),
);
proxy.insert(
"verbosity_steer".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.verbosity_steer_enabled()),
"Opt-in cache-safe wire verbosity steer (#895). When true, the proxy appends a single constant 'be concise' instruction to the last user turn of each request - output-shaping for raw API clients that do not load lean-ctx rules. The suffix is constant and appended strictly after the last cache_control breakpoint (a new trailing text block, never modifying a cache-anchored block), so the provider prompt-cache prefix stays byte-stable. Under an output_holdout the control arm skips it so its effect is measured. Default false",
"LEAN_CTX_PROXY_VERBOSITY_STEER",
),
);
proxy.insert(
"codex_chatgpt_proxy".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.codex_chatgpt_proxy_enabled()),
"Opt-in routing of a Codex ChatGPT-subscription login through the proxy for model-turn compression (#603/#616). Default false leaves Codex native (history visible, cloud/remote intact, no #597). When true, setup pins model_provider = leanctx-chatgpt + chatgpt_base_url + a [model_providers.leanctx-chatgpt] block, so model turns route through /backend-api/codex/responses (the proxy strips the responses-lite marker so every model incl. gpt-5.5 works); pinning a provider scopes Codex history to it (#597), hence opt-in. Toggle durably with `lean-ctx proxy codex-chatgpt on|off|status`. Default false",
"LEAN_CTX_CODEX_CHATGPT_PROXY",
),
);
sections.insert(
"proxy".into(),
SectionSchema {
description: "Proxy upstream configuration for API routing".into(),
keys: proxy,
},
);
let mut role_aggr = BTreeMap::new();
role_aggr.insert(
"system".into(),
key_with_env(
"f64",
serde_json::json!(cfg.proxy.role_aggressiveness.system),
"Opt-in prose compression intensity (0.0–1.0) for system prompts in the proxy's frozen request region. Unset = leave untouched. Higher = more aggressive. Cache-safe (deterministic, never touches the client-cached prefix)",
"LEAN_CTX_PROXY_SYSTEM_AGGR",
),
);
role_aggr.insert(
"user".into(),
key_with_env(
"f64",
serde_json::json!(cfg.proxy.role_aggressiveness.user),
"Opt-in prose compression intensity (0.0–1.0) for free-text user turns (never tool results) in the proxy's frozen request region. Unset = leave untouched",
"LEAN_CTX_PROXY_USER_AGGR",
),
);
sections.insert(
"proxy.role_aggressiveness".into(),
SectionSchema {
description: "Opt-in per-role prose compression for the proxy's frozen request region (#710). Assistant turns are always passed through verbatim".into(),
keys: role_aggr,
},
);
let mut cost = BTreeMap::new();
cost.insert(
"default_model".into(),
key(
"string?",
serde_json::json!(cfg.cost.default_model),
"Fallback pricing model for MCP-only IDEs whose real model lean-ctx cannot observe (Cursor, Copilot, Windsurf, …). Unset → blended heuristic. Per-IDE overrides live in [cost.models]",
),
);
cost.insert(
"prices".into(),
key(
"table?",
serde_json::json!(cfg.cost.prices.keys().collect::<Vec<_>>()),
"Operator price overrides per model, USD per million tokens: [cost.prices.\"<model>\"] with input_per_m / output_per_m / cache_write_per_m / cache_read_per_m. For negotiated enterprise rates (committed-use discounts, Azure PTU, zero-rated internal models); overrides embedded and live catalog rows, only a provider-measured bill beats it",
),
);
sections.insert(
"cost".into(),
SectionSchema {
description: "Model declaration for measured-vs-estimated cost reporting".into(),
keys: cost,
},
);
let mem = &cfg.memory;
let mut mem_knowledge = BTreeMap::new();
mem_knowledge.insert(
"max_facts".into(),
key(
"usize",
serde_json::json!(mem.knowledge.max_facts),
"Deprecated and ignored since 3.12.1: knowledge has no fact limit. An existing value still loads; facts are archived only by quality (decay, near-duplicate merge, low confidence, staleness), never by count",
),
);
mem_knowledge.insert(
"max_patterns".into(),
key(
"usize",
serde_json::json!(mem.knowledge.max_patterns),
"Maximum number of patterns stored",
),
);
mem_knowledge.insert(
"max_history".into(),
key(
"usize",
serde_json::json!(mem.knowledge.max_history),
"Maximum history entries retained",
),
);
mem_knowledge.insert(
"contradiction_threshold".into(),
key(
"f32",
clean_f32(mem.knowledge.contradiction_threshold),
"Confidence threshold for contradiction detection",
),
);
mem_knowledge.insert(
"recall_facts_limit".into(),
key(
"usize",
serde_json::json!(mem.knowledge.recall_facts_limit),
"Maximum facts returned per recall query",
),
);
mem_knowledge.insert(
"rooms_limit".into(),
key(
"usize",
serde_json::json!(mem.knowledge.rooms_limit),
"Maximum number of rooms returned",
),
);
mem_knowledge.insert(
"timeline_limit".into(),
key(
"usize",
serde_json::json!(mem.knowledge.timeline_limit),
"Maximum number of timeline entries returned",
),
);
mem_knowledge.insert(
"relations_limit".into(),
key(
"usize",
serde_json::json!(mem.knowledge.relations_limit),
"Maximum number of relations returned",
),
);
sections.insert(
"memory.knowledge".into(),
SectionSchema {
description: "Knowledge memory budgets (facts, patterns, gotchas)".into(),
keys: mem_knowledge,
},
);
let mut mem_episodic = BTreeMap::new();
mem_episodic.insert(
"max_episodes".into(),
key(
"usize",
serde_json::json!(mem.episodic.max_episodes),
"Retired since 3.13.0 with the episode and procedure memory: accepted so existing configs keep loading, no effect",
),
);
mem_episodic.insert(
"max_actions_per_episode".into(),
key(
"usize",
serde_json::json!(mem.episodic.max_actions_per_episode),
"Retired since 3.13.0 with the episode and procedure memory: accepted so existing configs keep loading, no effect",
),
);
mem_episodic.insert(
"summary_max_chars".into(),
key(
"usize",
serde_json::json!(mem.episodic.summary_max_chars),
"Retired since 3.13.0 with the episode and procedure memory: accepted so existing configs keep loading, no effect",
),
);
sections.insert(
"memory.episodic".into(),
SectionSchema {
description: "Retired (no effect): episode memory budgets, accepted so existing configs keep loading".into(),
keys: mem_episodic,
},
);
let mut mem_procedural = BTreeMap::new();
mem_procedural.insert(
"max_procedures".into(),
key(
"usize",
serde_json::json!(mem.procedural.max_procedures),
"Retired since 3.13.0 with the episode and procedure memory: accepted so existing configs keep loading, no effect",
),
);
mem_procedural.insert(
"min_repetitions".into(),
key(
"usize",
serde_json::json!(mem.procedural.min_repetitions),
"Retired since 3.13.0 with the episode and procedure memory: accepted so existing configs keep loading, no effect",
),
);
mem_procedural.insert(
"min_sequence_len".into(),
key(
"usize",
serde_json::json!(mem.procedural.min_sequence_len),
"Retired since 3.13.0 with the episode and procedure memory: accepted so existing configs keep loading, no effect",
),
);
mem_procedural.insert(
"max_window_size".into(),
key(
"usize",
serde_json::json!(mem.procedural.max_window_size),
"Retired since 3.13.0 with the episode and procedure memory: accepted so existing configs keep loading, no effect",
),
);
sections.insert(
"memory.procedural".into(),
SectionSchema {
description: "Retired (no effect): procedure memory budgets, accepted so existing configs keep loading".into(),
keys: mem_procedural,
},
);
let mut mem_lifecycle = BTreeMap::new();
mem_lifecycle.insert(
"decay_rate".into(),
key(
"f32",
clean_f32(mem.lifecycle.decay_rate),
"Rate at which knowledge confidence decays over time",
),
);
mem_lifecycle.insert(
"low_confidence_threshold".into(),
key(
"f32",
clean_f32(mem.lifecycle.low_confidence_threshold),
"Threshold below which facts are considered low-confidence",
),
);
mem_lifecycle.insert(
"stale_days".into(),
key(
"i64",
serde_json::json!(mem.lifecycle.stale_days),
"Days after which unused facts are considered stale",
),
);
mem_lifecycle.insert(
"similarity_threshold".into(),
key(
"f32",
clean_f32(mem.lifecycle.similarity_threshold),
"Similarity threshold for deduplication",
),
);
mem_lifecycle.insert(
"forgetting_model".into(),
key(
"string",
serde_json::json!(mem.lifecycle.forgetting_model),
"Forgetting curve: ebbinghaus (default, exponential + spacing) or linear",
),
);
mem_lifecycle.insert(
"base_stability_days".into(),
key(
"f32",
clean_f32(mem.lifecycle.base_stability_days),
"Characteristic memory stability (days) for the Ebbinghaus curve",
),
);
mem_lifecycle.insert(
"archetype_aware_decay".into(),
key(
"bool",
serde_json::json!(mem.lifecycle.archetype_aware_decay),
"Scale Ebbinghaus stability by fact archetype so structural evidence decays slower than inference (default false)",
),
);
mem_lifecycle.insert(
"reclaim_headroom_pct".into(),
key_with_env(
"f32",
clean_f32(mem.lifecycle.reclaim_headroom_pct),
"Proactive headroom on a capacity reclaim: settle a full store at 1 - this fraction (0.25 = 75%) instead of churning at the cap. Lossless — the reclaimed tail is archived and restorable",
"LEAN_CTX_LIFECYCLE_RECLAIM_HEADROOM_PCT",
),
);
mem_lifecycle.insert(
"reclaim_enabled".into(),
key_with_env(
"bool",
serde_json::json!(mem.lifecycle.reclaim_enabled),
"Master switch for the proactive capacity reclaim (#995). false trims only the overflow (escape hatch, no headroom); eviction stays lossless either way",
"LEAN_CTX_LIFECYCLE_RECLAIM_ENABLED",
),
);
sections.insert(
"memory.lifecycle".into(),
SectionSchema {
description: "Knowledge lifecycle policy (decay, staleness, dedup)".into(),
keys: mem_lifecycle,
},
);
let mut mem_gotcha = BTreeMap::new();
mem_gotcha.insert(
"max_gotchas_per_project".into(),
key(
"usize",
serde_json::json!(mem.gotcha.max_gotchas_per_project),
"Maximum gotchas kept per project (lowest confidence dropped first)",
),
);
sections.insert(
"memory.gotcha".into(),
SectionSchema {
description: "Gotcha memory settings (project-specific warnings and pitfalls)".into(),
keys: mem_gotcha,
},
);
let mut mem_embeddings = BTreeMap::new();
mem_embeddings.insert(
"max_facts".into(),
key(
"usize",
serde_json::json!(mem.embeddings.max_facts),
"Maximum number of facts with a semantic-search vector (index budget; it never removes or archives a fact, the others stay recallable by text)",
),
);
sections.insert(
"memory.embeddings".into(),
SectionSchema {
description: "Embeddings memory settings for semantic search".into(),
keys: mem_embeddings,
},
);
let mut aliases = BTreeMap::new();
aliases.insert(
"command".into(),
key(
"string",
serde_json::json!(""),
"The command pattern to match (e.g. 'deploy')",
),
);
aliases.insert(
"alias".into(),
key(
"string",
serde_json::json!(""),
"The alias definition to execute",
),
);
sections.insert("custom_aliases".into(), SectionSchema {
description: "Custom command aliases (array of {command, alias} entries). Note: field names are 'command' and 'alias' (not 'name')".into(),
keys: aliases,
});
if let Some(root_section) = sections.get_mut("root") {
root_section.keys.insert(
"custom_aliases".into(),
key(
"array",
serde_json::json!([]),
"Custom command aliases (array of {command, alias} entries)",
),
);
}
let mut setup_keys = BTreeMap::new();
setup_keys.insert(
"auto_inject_rules".into(),
key(
"bool?",
serde_json::json!(null),
"Inject agent rule files during setup/update. null=auto (inject if already present), true=always, false=never",
),
);
setup_keys.insert(
"auto_inject_skills".into(),
key(
"bool?",
serde_json::json!(null),
"Install SKILL.md files during setup/update. null=auto (install if rules present), true=always, false=never",
),
);
setup_keys.insert(
"auto_update_mcp".into(),
key(
"bool",
serde_json::json!(true),
"Register lean-ctx MCP server in editor configs during setup/update",
),
);
setup_keys.insert(
"manage_hooks".into(),
key(
"bool",
serde_json::json!(true),
"Refresh already-installed agent hooks, permissions.deny and status line on MCP server start. false = only explicit setup/init/doctor --fix writes them",
),
);
setup_keys.insert(
"agents".into(),
key(
"string[]",
serde_json::json!(null),
"AI tools (agent keys, e.g. claude,cursor) that setup, onboard and updates may configure. null=no choice yet: setup offers every detected tool, updates refresh only already-connected ones. Set by the `lean-ctx setup` selection or --agents",
),
);
sections.insert(
"setup".into(),
SectionSchema {
description: "Controls what lean-ctx injects during setup and updates. Fresh installs default to non-invasive (rules/skills off, MCP on).".into(),
keys: setup_keys,
},
);
sections.insert("governor".into(), super::governor_section());
// #1605: these defaults are derived from `cfg.llm`, never written out by
// hand. The literal copies that used to live here had drifted from
// `LlmConfig`: `model` advertised `llama3.2` while the struct defaulted to
// `qwen2.5-coder:1.5b`, `base_url` was missing, and `api_key` named a field
// that does not exist — so `config set llm.api_key …` reported a save that
// was silently discarded. The key is gone. The whole section is retired
// (DEPRECATIONS.toml `config-llm`): every key is accepted and has no effect.
let mut llm_keys = BTreeMap::new();
llm_keys.insert(
"enabled".into(),
key(
"bool",
serde_json::json!(cfg.llm.enabled),
"No effect (kept so existing configs load): LeanCTX calls no model itself",
),
);
let backend_default = serde_json::to_value(cfg.llm.backend.clone())
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_else(|| "ollama".to_string());
llm_keys.insert(
"backend".into(),
key_enum(
&["ollama", "openrouter", "anthropic"],
&backend_default,
"No effect (kept so existing configs load): LeanCTX calls no model itself",
),
);
llm_keys.insert(
"model".into(),
key(
"string",
serde_json::json!(cfg.llm.model),
"No effect (kept so existing configs load): LeanCTX calls no model itself",
),
);
llm_keys.insert(
"base_url".into(),
key(
"string",
serde_json::json!(cfg.llm.base_url.clone().unwrap_or_default()),
"No effect (kept so existing configs load): LeanCTX calls no model itself",
),
);
llm_keys.insert(
"timeout_secs".into(),
key(
"u64",
serde_json::json!(cfg.llm.timeout_secs),
"No effect (kept so existing configs load): LeanCTX calls no model itself",
),
);
sections.insert("llm".into(), SectionSchema {
description: "Retired, no effect: query expansion and observation synthesis, the only consumers of these settings, are gone (LeanCTX calls no model itself). The keys stay accepted so existing configs keep loading; earliest removal 3.15.0.".into(),
keys: llm_keys,
});
let mut decision_loop = BTreeMap::new();
decision_loop.insert(
"enabled".into(),
key(
"bool",
serde_json::json!(cfg.decision_loop.enabled),
"Enable the MCP decision-loop runtime",
),
);
decision_loop.insert(
"max_filter_level".into(),
key(
"u8",
serde_json::json!(cfg.decision_loop.max_filter_level),
"Maximum lossy post-dispatch triage level (0 disables output filtering)",
),
);
sections.insert(
"decision_loop".into(),
SectionSchema {
description: "MCP decision-loop runtime settings".into(),
keys: decision_loop,
},
);
let mut protection = BTreeMap::new();
protection.insert(
"file_patterns".into(),
key(
"list<string>",
serde_json::json!(cfg.protection.file_patterns),
"Glob patterns for path-like tool arguments; a hit exempts the call's output from every lossy filter (same standard as raw=true)",
),
);
protection.insert(
"tags".into(),
key(
"bool",
serde_json::json!(cfg.protection.tags),
"Honor inline <protect> spans: matching output bypasses lossy line filtering and history pruning",
),
);
sections.insert(
"protection".into(),
SectionSchema {
description: "User-controlled never-lossy zones (#1570 P4)".into(),
keys: protection,
},
);
let mut agents = BTreeMap::new();
agents.insert(
"serialize_build_commands".into(),
key(
"bool",
serde_json::json!(cfg.agents.serialize_build_commands),
"Opt-in: run only one cargo/npm build or test at a time across all lean-ctx sessions on this machine; ctx_shell waits for the slot. Off by default",
),
);
agents.insert(
"cargo_build_jobs".into(),
key(
"usize",
serde_json::json!(cfg.agents.cargo_build_jobs),
"Opt-in: CARGO_BUILD_JOBS for builds run through ctx_shell. 0 (default) leaves cargo's own setting alone",
),
);
agents.insert(
"shared_cargo_target".into(),
key(
"bool",
serde_json::json!(cfg.agents.shared_cargo_target),
"Opt-in: builds run through ctx_shell use one machine-wide CARGO_TARGET_DIR (<data dir>/build-cache/cargo-target) instead of ./target. Off by default — when on, a binary run from ./target is stale. An explicit CARGO_TARGET_DIR always wins",
),
);
sections.insert(
"agents".into(),
SectionSchema {
description: "Build handling for ctx_shell in multi-agent setups. All opt-in; by default lean-ctx neither queues, throttles nor relocates your builds".into(),
keys: agents,
},
);
}