diff --git a/docs/reference/generated/config-keys.md b/docs/reference/generated/config-keys.md index 3906294595..56011efed3 100644 --- a/docs/reference/generated/config-keys.md +++ b/docs/reference/generated/config-keys.md @@ -22,6 +22,7 @@ Top-level configuration keys - `content_defined_chunking` (bool, default `false`) — Enable Rabin-Karp chunking for cache-optimal output ordering - `custom_aliases` (array, default `[]`) — Custom command aliases (array of {command, alias} entries) - `default_tool_categories` (string[], default `[]`) — Tool categories active by default (core, arch, debug, memory, metrics, session). Override via LCTX_DEFAULT_CATEGORIES +- `delta_explicit` (boolean, default `false`) — Serve explicit full/lines re-reads of changed cached files as diffs (opt-in). Override via LCTX_DELTA_EXPLICIT=1 - `disabled_tools` (string[], default `[]`) — Tools to exclude from the MCP tool list - `enable_wakeup_ctx` (bool, default `true`) — Append wakeup briefing (facts, session summary) to ctx_overview output. Set false to reduce context bloat when calling ctx_overview frequently. - `excluded_commands` (string[], default `[]`) — Commands to exclude from shell hook interception diff --git a/rust/src/core/config/mod.rs b/rust/src/core/config/mod.rs index 86dc3bb6f1..1cbfab5986 100644 --- a/rust/src/core/config/mod.rs +++ b/rust/src/core/config/mod.rs @@ -154,6 +154,13 @@ pub struct Config { /// Override via LCTX_NO_DEGRADE=1 env var. #[serde(default)] pub no_degrade: bool, + /// Serve explicit `full`/`lines:N-M` re-reads of session-cached files as + /// deltas: when the file changed on disk since it was cached, the read + /// returns `mode=diff` instead of re-emitting content the model already + /// holds. First reads are unaffected; `fresh=true` always bypasses. + /// Opt-in. Override via LCTX_DELTA_EXPLICIT=1/0 env var. + #[serde(default)] + pub delta_explicit: bool, /// Persistent profile name. Checked after LEAN_CTX_PROFILE env var. /// Set via `lean-ctx config set profile passthrough` or editing config.toml. #[serde(default)] @@ -491,6 +498,7 @@ impl Default for Config { prefer_native_editor: false, default_tool_categories: Vec::new(), no_degrade: false, + delta_explicit: false, profile: None, tool_profile: None, tools_enabled: Vec::new(), @@ -834,6 +842,21 @@ impl Config { self.no_degrade } + /// Returns `true` if explicit `full`/`lines:N-M` re-reads of + /// cached-but-changed files should be served as deltas (`mode=diff`) + /// instead of re-emitting full content. + /// + /// Checks the `LCTX_DELTA_EXPLICIT` env var first, then the config.toml + /// field. Unlike a presence-only knob, an explicit `0`/`false` in the env + /// forces the feature OFF even when the config field is `true`, so the env + /// can fully override config in both directions. + pub fn delta_explicit_effective(&self) -> bool { + if let Ok(val) = std::env::var("LCTX_DELTA_EXPLICIT") { + return val == "1" || val.eq_ignore_ascii_case("true"); + } + self.delta_explicit + } + /// Effective max_disk_mb from env or config. pub fn max_disk_mb_effective(&self) -> u64 { std::env::var("LEAN_CTX_MAX_DISK_MB") @@ -1324,6 +1347,9 @@ impl Config { if local.no_degrade { self.no_degrade = true; } + if local.delta_explicit { + self.delta_explicit = true; + } if local.profile.is_some() { self.profile = local.profile; } diff --git a/rust/src/core/config/schema/sections_core.rs b/rust/src/core/config/schema/sections_core.rs index e996ed155d..3094a22100 100644 --- a/rust/src/core/config/schema/sections_core.rs +++ b/rust/src/core/config/schema/sections_core.rs @@ -131,6 +131,14 @@ pub(super) fn build(sections: &mut BTreeMap) { "Disable all automatic read-mode degradation. Override via LCTX_NO_DEGRADE=1", ), ); + root.insert( + "delta_explicit".into(), + key( + "boolean", + serde_json::json!(cfg.delta_explicit), + "Serve explicit full/lines re-reads of changed cached files as diffs (opt-in). Override via LCTX_DELTA_EXPLICIT=1", + ), + ); root.insert( "profile".into(), key( diff --git a/rust/src/core/config/tests.rs b/rust/src/core/config/tests.rs index 6666bbbd05..3e95963e0d 100644 --- a/rust/src/core/config/tests.rs +++ b/rust/src/core/config/tests.rs @@ -366,6 +366,150 @@ mod no_degrade_tests { } } +#[cfg(test)] +mod delta_explicit_tests { + use super::super::*; + + // --- Defaults --- + + #[test] + fn default_is_false() { + let cfg = Config::default(); + assert!(!cfg.delta_explicit); + } + + #[test] + fn effective_false_when_unset() { + if std::env::var("LCTX_DELTA_EXPLICIT").is_ok() { + return; + } + let cfg = Config::default(); + assert!(!cfg.delta_explicit_effective()); + } + + // --- Config field --- + + #[test] + fn config_field_true_respected_when_no_env() { + if std::env::var("LCTX_DELTA_EXPLICIT").is_ok() { + return; + } + let cfg = Config { + delta_explicit: true, + ..Default::default() + }; + assert!(cfg.delta_explicit_effective()); + } + + #[test] + fn config_field_false_respected_when_no_env() { + if std::env::var("LCTX_DELTA_EXPLICIT").is_ok() { + return; + } + let cfg = Config { + delta_explicit: false, + ..Default::default() + }; + assert!(!cfg.delta_explicit_effective()); + } + + // --- Env override (both directions) --- + + #[test] + fn env_overrides_config_field_in_both_directions() { + // All env mutation serializes through this lock (Rust 2024 set_var is + // `unsafe`; the lock is the documented soundness precondition). + let _lock = crate::core::data_dir::test_env_lock(); + + // env=1 turns the feature ON even when the config field is false. + crate::test_env::set_var("LCTX_DELTA_EXPLICIT", "1"); + let off_cfg = Config { + delta_explicit: false, + ..Default::default() + }; + assert!( + off_cfg.delta_explicit_effective(), + "LCTX_DELTA_EXPLICIT=1 must enable the feature over a false config field" + ); + + // env=0 forces it OFF even when the config field is true. + crate::test_env::set_var("LCTX_DELTA_EXPLICIT", "0"); + let on_cfg = Config { + delta_explicit: true, + ..Default::default() + }; + assert!( + !on_cfg.delta_explicit_effective(), + "LCTX_DELTA_EXPLICIT=0 must disable the feature over a true config field" + ); + + // `true`/`false` spellings are honoured too (case-insensitive). + crate::test_env::set_var("LCTX_DELTA_EXPLICIT", "true"); + assert!(off_cfg.delta_explicit_effective()); + crate::test_env::set_var("LCTX_DELTA_EXPLICIT", "FALSE"); + assert!(!on_cfg.delta_explicit_effective()); + + // Restore: with the var removed the config field decides again. + crate::test_env::remove_var("LCTX_DELTA_EXPLICIT"); + assert!(on_cfg.delta_explicit_effective()); + assert!(!off_cfg.delta_explicit_effective()); + } + + // --- TOML deserialization --- + + #[test] + fn deserialization_true() { + let cfg: Config = toml::from_str("delta_explicit = true").unwrap(); + assert!(cfg.delta_explicit); + } + + #[test] + fn deserialization_false() { + let cfg: Config = toml::from_str("delta_explicit = false").unwrap(); + assert!(!cfg.delta_explicit); + } + + #[test] + fn deserialization_absent_defaults_false() { + let cfg: Config = toml::from_str("").unwrap(); + assert!(!cfg.delta_explicit); + } + + // --- Round-trip (serialize → deserialize preserves the field) --- + + #[test] + fn round_trip_preserves_field() { + let cfg = Config { + delta_explicit: true, + ..Default::default() + }; + let serialized = toml::to_string(&cfg).expect("Config must serialize to TOML"); + let restored: Config = + toml::from_str(&serialized).expect("serialized Config must round-trip"); + assert!( + restored.delta_explicit, + "delta_explicit must survive a TOML serialize → deserialize round-trip" + ); + } + + // --- Coexistence with other config fields --- + + #[test] + fn delta_explicit_independent_of_no_degrade() { + if std::env::var("LCTX_DELTA_EXPLICIT").is_ok() || std::env::var("LCTX_NO_DEGRADE").is_ok() + { + return; + } + let cfg = Config { + delta_explicit: true, + no_degrade: true, + ..Default::default() + }; + assert!(cfg.delta_explicit_effective()); + assert!(cfg.no_degrade_effective()); + } +} + #[cfg(test)] mod rules_scope_tests { use super::super::*; diff --git a/rust/src/tools/ctx_read/mod.rs b/rust/src/tools/ctx_read/mod.rs index 95d0af1c2c..2b3dcbb251 100644 --- a/rust/src/tools/ctx_read/mod.rs +++ b/rust/src/tools/ctx_read/mod.rs @@ -426,6 +426,89 @@ pub fn try_stub_hit_readonly(cache: &SessionCache, path: &str) -> Option, +} + +/// Decide whether an **explicit** `full`/`lines:N-M` re-read of a session-cached +/// file should be served as a delta instead of re-emitting content the model +/// already holds (the `delta_explicit` opt-in; env `LCTX_DELTA_EXPLICIT`). +/// +/// Returns the mode the read should proceed with: +/// - **Changed on disk** (verified mtime+md5 stale) and full content is cached → +/// `diff`, plus an advisory note. The diff carries exactly the new +/// information in a fraction of the tokens. +/// - **Unchanged** and the request is `lines:` of an already-fully-delivered +/// file → `full`, so the read collapses to the ~15-token `[unchanged]` stub +/// instead of re-extracting a window the model has seen. +/// - Otherwise the caller's `mode` is returned untouched. +/// +/// First reads (nothing cached) and `fresh=true` are never affected — the +/// caller gates those before calling. Staleness uses the **verified** variant +/// ([`crate::core::cache::is_cache_entry_stale_verified`]) so a same-second +/// write on a coarse-granularity filesystem cannot be mistaken for "unchanged" +/// and yield a misleading empty diff (#498 determinism). +/// +/// Pure w.r.t. (cache, path, mode, enabled): no wall-clock, counters, or +/// randomness enter the result, so identical inputs stay byte-stable. +pub fn resolve_explicit_delta_mode( + cache: &SessionCache, + path: &str, + mode: &str, + explicit_mode: bool, + fresh: bool, + enabled: bool, +) -> DeltaExplicitDecision { + let unchanged = DeltaExplicitDecision { + mode: mode.to_string(), + note: None, + }; + if fresh || !enabled || !explicit_mode || !(mode == "full" || mode.starts_with("lines:")) { + return unchanged; + } + let Some(entry) = cache.get(path) else { + // First read this session — nothing to diff against. + return unchanged; + }; + let stale = + crate::core::cache::is_cache_entry_stale_verified(path, entry.stored_mtime, &entry.hash); + if stale { + // Only divert to a diff when full content is actually cached: the diff + // base is that full content (see `handle_diff`), never a compressed + // view. Without it, `handle_diff` would have nothing to compare. + if entry.content().is_some() { + return DeltaExplicitDecision { + mode: "diff".to_string(), + note: Some(format!( + "[delta-explicit] requested mode={mode} served as a diff: the file \ + changed since your last read and the diff is the new information. \ + Pass fresh=true if you need the full content re-emitted." + )), + }; + } + return unchanged; + } + // Unchanged on disk: a `lines:` window of a file already delivered in full + // re-emits text the model holds — collapse to the full-mode stub + // (~15 tokens). A plain `full` re-read already hits that stub downstream. + if mode.starts_with("lines:") && cache.is_full_delivered(path) { + return DeltaExplicitDecision { + mode: "full".to_string(), + note: None, + }; + } + unchanged +} + fn handle_with_options_inner( cache: &mut SessionCache, path: &str, diff --git a/rust/src/tools/ctx_read/tests.rs b/rust/src/tools/ctx_read/tests.rs index fbc59ca18a..0324338514 100644 --- a/rust/src/tools/ctx_read/tests.rs +++ b/rust/src/tools/ctx_read/tests.rs @@ -792,3 +792,294 @@ fn cache_hit_stub_is_byte_stable_across_rereads() { r2.content ); } + +// --------------------------------------------------------------------------- +// delta_explicit: serve explicit full/lines re-reads of changed cached files as +// diffs (opt-in). The decision is the pure `resolve_explicit_delta_mode`; the +// end-to-end diff base is exercised via the engine. Mirrors the +// `try_stub_hit_readonly` staleness-test conventions above. +// --------------------------------------------------------------------------- + +/// Prime the cache with a full read of the file already on disk at `p`. +fn primed_full_cache(p: &str) -> SessionCache { + let mut cache = SessionCache::new(); + let _ = handle_with_task_resolved(&mut cache, p, "full", CrpMode::Off, None); + debug_assert!( + cache.is_full_delivered(p), + "fixture must deliver full content" + ); + cache +} + +#[test] +fn delta_explicit_changed_file_diverts_full_reread_to_diff() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("changed.rs"); + let p = path.to_string_lossy().to_string(); + std::fs::write(&path, "fn main() {}\n").unwrap(); + + let mut cache = primed_full_cache(&p); + + // File changes on disk after the first full read. + std::thread::sleep(Duration::from_secs(1)); + std::fs::write(&path, "fn main() { changed(); }\n").unwrap(); + + let decision = resolve_explicit_delta_mode( + &cache, &p, "full", /*explicit*/ true, /*fresh*/ false, true, + ); + assert_eq!( + decision.mode, "diff", + "changed full re-read must divert to diff" + ); + let note = decision + .note + .expect("a diff diversion must carry an advisory note"); + assert!( + note.contains("[delta-explicit]"), + "note tag missing: {note}" + ); + assert!( + note.contains("fresh=true"), + "note must mention the bypass: {note}" + ); + + // End-to-end: the engine renders the diff against the FULL cached content. + let out = handle_with_task_resolved(&mut cache, &p, "diff", CrpMode::Off, None); + assert_eq!(out.resolved_mode, "diff"); + assert!( + out.content.contains("[diff]"), + "engine must emit a diff: {}", + out.content + ); + assert!( + out.content.contains("changed()"), + "diff must reflect the new on-disk content: {}", + out.content + ); +} + +#[test] +fn delta_explicit_changed_lines_request_diverts_to_diff() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("lines.rs"); + let p = path.to_string_lossy().to_string(); + std::fs::write(&path, "fn a() {}\nfn b() {}\n").unwrap(); + + let cache = primed_full_cache(&p); + + std::thread::sleep(Duration::from_secs(1)); + std::fs::write(&path, "fn a() { x(); }\nfn b() {}\n").unwrap(); + + let decision = resolve_explicit_delta_mode(&cache, &p, "lines:1-1", true, false, true); + assert_eq!( + decision.mode, "diff", + "a changed-file lines: re-read must divert to diff, not re-extract a window" + ); + assert!(decision.note.is_some()); +} + +#[test] +fn delta_explicit_diff_base_is_full_cached_content_not_compressed() { + // Fix #2 guard: the diff base must be the full source the cache stored, even + // when the most recent read of the file was a COMPRESSED view (map). If the + // base were the compressed view, the diff would be garbage. + let _iso = crate::core::data_dir::isolated_data_dir(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("big.rs"); + let p = path.to_string_lossy().to_string(); + let mut content = String::new(); + for i in 0..60 { + content.push_str(&format!( + "pub fn original_fn_{i}(x: i32) -> i32 {{ x + {i} }}\n" + )); + } + std::fs::write(&path, &content).unwrap(); + + let mut cache = SessionCache::new(); + // Cache the full content, then read a compressed (map) view — last_mode=map, + // but the entry still stores the full source. + let _ = handle_with_task_resolved(&mut cache, &p, "full", CrpMode::Off, None); + let _ = handle_with_task_resolved(&mut cache, &p, "map", CrpMode::Off, None); + + // Change exactly one line on disk. + std::thread::sleep(Duration::from_secs(1)); + let changed = content.replace( + "pub fn original_fn_7(x: i32) -> i32 { x + 7 }", + "pub fn original_fn_7(x: i32) -> i32 { x + 70707 }", + ); + std::fs::write(&path, &changed).unwrap(); + + let out = handle_with_task_resolved(&mut cache, &p, "diff", CrpMode::Off, None); + assert!( + out.content.contains("[diff]"), + "expected a diff: {}", + out.content + ); + // The marker appears only if the diff compared against the FULL original + // source (a compressed map base would never contain this literal). + assert!( + out.content.contains("70707"), + "diff must be computed against full cached source, got: {}", + out.content + ); + // And it must be a one-line edit, not a wholesale replacement of a + // compressed base against the full file. + assert!( + out.content.contains("+1/-1") || out.content.contains("-1/+1"), + "single-line change should diff as +1/-1: {}", + out.content + ); +} + +#[test] +fn delta_explicit_unchanged_lines_collapse_to_full_stub() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("same.rs"); + let p = path.to_string_lossy().to_string(); + std::fs::write(&path, "fn a() {}\nfn b() {}\n").unwrap(); + + let cache = primed_full_cache(&p); + + // No disk change. A lines: re-read of a fully-delivered file re-emits text + // the model holds → collapse to the full-mode stub (no diff, no note). + let decision = resolve_explicit_delta_mode(&cache, &p, "lines:1-1", true, false, true); + assert_eq!( + decision.mode, "full", + "unchanged lines: of a full file must collapse to the stub" + ); + assert!( + decision.note.is_none(), + "a silent stub collapse must not carry a note" + ); +} + +#[test] +fn delta_explicit_unchanged_full_reread_is_untouched() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("same.rs"); + let p = path.to_string_lossy().to_string(); + std::fs::write(&path, "fn a() {}\n").unwrap(); + + let cache = primed_full_cache(&p); + + // An unchanged full re-read already hits the downstream `[unchanged]` stub; + // the resolver leaves it untouched. + let decision = resolve_explicit_delta_mode(&cache, &p, "full", true, false, true); + assert_eq!(decision.mode, "full"); + assert!(decision.note.is_none()); +} + +#[test] +fn delta_explicit_off_preserves_current_behavior() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("changed.rs"); + let p = path.to_string_lossy().to_string(); + std::fs::write(&path, "fn main() {}\n").unwrap(); + + let cache = primed_full_cache(&p); + + std::thread::sleep(Duration::from_secs(1)); + std::fs::write(&path, "fn main() { changed(); }\n").unwrap(); + + // enabled=false → the mode is never rewritten, no matter the disk state. + let decision = + resolve_explicit_delta_mode(&cache, &p, "full", true, false, /*enabled*/ false); + assert_eq!( + decision.mode, "full", + "feature OFF must preserve the requested mode" + ); + assert!(decision.note.is_none()); + + let lines = resolve_explicit_delta_mode(&cache, &p, "lines:1-1", true, false, false); + assert_eq!( + lines.mode, "lines:1-1", + "feature OFF must not touch lines: either" + ); + assert!(lines.note.is_none()); +} + +#[test] +fn delta_explicit_fresh_bypasses() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("changed.rs"); + let p = path.to_string_lossy().to_string(); + std::fs::write(&path, "fn main() {}\n").unwrap(); + + let cache = primed_full_cache(&p); + + std::thread::sleep(Duration::from_secs(1)); + std::fs::write(&path, "fn main() { changed(); }\n").unwrap(); + + // fresh=true → always bypass even with the feature on and a changed file. + let decision = resolve_explicit_delta_mode(&cache, &p, "full", true, /*fresh*/ true, true); + assert_eq!( + decision.mode, "full", + "fresh=true must bypass the diff diversion" + ); + assert!(decision.note.is_none()); +} + +#[test] +fn delta_explicit_first_read_unaffected() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("new.rs"); + let p = path.to_string_lossy().to_string(); + std::fs::write(&path, "fn main() {}\n").unwrap(); + + // Nothing cached yet — the very first read can never be a diff. + let cache = SessionCache::new(); + let decision = resolve_explicit_delta_mode(&cache, &p, "full", true, false, true); + assert_eq!( + decision.mode, "full", + "an uncached first read must be served normally" + ); + assert!(decision.note.is_none()); + + let lines = resolve_explicit_delta_mode(&cache, &p, "lines:1-1", true, false, true); + assert_eq!(lines.mode, "lines:1-1"); + assert!(lines.note.is_none()); +} + +#[test] +fn delta_explicit_only_fires_for_explicit_mode() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("changed.rs"); + let p = path.to_string_lossy().to_string(); + std::fs::write(&path, "fn main() {}\n").unwrap(); + + let cache = primed_full_cache(&p); + + std::thread::sleep(Duration::from_secs(1)); + std::fs::write(&path, "fn main() { changed(); }\n").unwrap(); + + // explicit_mode=false (mode was auto-resolved) → never diverted; auto-mode + // already has its own staleness handling. + let decision = + resolve_explicit_delta_mode(&cache, &p, "full", /*explicit*/ false, false, true); + assert_eq!( + decision.mode, "full", + "auto-resolved modes must not be diverted to diff" + ); + assert!(decision.note.is_none()); +} + +#[test] +fn delta_explicit_decision_is_byte_stable() { + // #498 determinism: the resolver's note carries no timestamp/counter, so + // repeated calls on the same changed-file state are byte-identical. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("changed.rs"); + let p = path.to_string_lossy().to_string(); + std::fs::write(&path, "fn main() {}\n").unwrap(); + + let cache = primed_full_cache(&p); + std::thread::sleep(Duration::from_secs(1)); + std::fs::write(&path, "fn main() { changed(); }\n").unwrap(); + + let d1 = resolve_explicit_delta_mode(&cache, &p, "full", true, false, true); + let d2 = resolve_explicit_delta_mode(&cache, &p, "full", true, false, true); + assert_eq!( + d1, d2, + "delta-explicit decision drifted between identical calls" + ); +} diff --git a/rust/src/tools/registered/ctx_read.rs b/rust/src/tools/registered/ctx_read.rs index 1e5306f0b5..1b65490da1 100644 --- a/rust/src/tools/registered/ctx_read.rs +++ b/rust/src/tools/registered/ctx_read.rs @@ -194,12 +194,41 @@ impl CtxReadTool { mode = overridden; } - let (mode, degrade_warning) = if crate::tools::ctx_read::is_instruction_file(path) { + let (mut mode, degrade_warning) = if crate::tools::ctx_read::is_instruction_file(path) { ("full".to_string(), None) } else { auto_degrade_read_mode(&mode) }; + // Delta-aware explicit re-reads (opt-in: config `delta_explicit`, env + // LCTX_DELTA_EXPLICIT). Re-requesting full/lines:N-M content for a file + // this session already read re-emits content the model already holds; + // when the file changed on disk, a diff carries the same information in + // a fraction of the tokens, and an unchanged lines: request of a + // fully-delivered file collapses to the full-mode stub. The decision is + // a pure function of (cache, path, mode) — see + // `ctx_read::resolve_explicit_delta_mode`. First reads are unaffected; + // fresh=true always bypasses. Runs BEFORE the lines:→fresh guard below + // so a changed-file lines: re-read can still be diverted to a diff. + let mut delta_explicit_note: Option = None; + if !fresh + && explicit_mode + && (mode == "full" || mode.starts_with("lines:")) + && crate::core::config::Config::load().delta_explicit_effective() + && let Ok(cache) = cache_lock.try_read() + { + let decision = crate::tools::ctx_read::resolve_explicit_delta_mode( + &cache, + path, + &mode, + explicit_mode, + fresh, + true, + ); + mode = decision.mode; + delta_explicit_note = decision.note; + } + if mode.starts_with("lines:") { fresh = true; } @@ -638,6 +667,9 @@ impl CtxReadTool { if let Some(ref w) = degrade_warning { warnings.push(w.as_str()); } + if let Some(ref w) = delta_explicit_note { + warnings.push(w.as_str()); + } let final_output = if !warnings.is_empty() { format!("{output}{hints_suffix}\n\n{}", warnings.join("\n")) } else if hints_suffix.is_empty() {