diff --git a/.claude/rules/search-strategy.md b/.claude/rules/search-strategy.md index 0b4504df7f..5e09813341 100644 --- a/.claude/rules/search-strategy.md +++ b/.claude/rules/search-strategy.md @@ -18,7 +18,9 @@ src/ ├── main.rs ← Commands enum + routing (start here for any command) ├── core/ ← Shared infrastructure │ ├── config.rs ← ~/.config/rtk/config.toml -│ ├── tracking.rs ← SQLite token metrics +│ ├── tracking.rs ← SQLite token metrics + dedup ledger +│ ├── dedup.rs ← Session output dedup (maybe_suppress) +│ ├── session.rs ← Session id (--session / RTK_SESSION_ID) │ ├── tee.rs ← Raw output recovery on failure │ ├── utils.rs ← strip_ansi, truncate, execute_command │ ├── filter.rs ← Language-aware code filtering engine diff --git a/docs/contributing/ARCHITECTURE.md b/docs/contributing/ARCHITECTURE.md index 108e58fdc3..fc095cd466 100644 --- a/docs/contributing/ARCHITECTURE.md +++ b/docs/contributing/ARCHITECTURE.md @@ -734,6 +734,28 @@ Flow: Single-threaded execution with `Mutex>` for future-proofing. No multi-threading currently, but safe concurrent access is possible if needed. +### Session Output Dedup (`src/core/dedup.rs`) + +Filtering compresses a *single* command's output. Dedup attacks a different sink: an agent re-reading the same file or re-running the same command re-emits identical output into context every turn. `dedup::maybe_suppress` suppresses a **byte-identical re-emission within one Claude Code session** to a one-line stub, so the repeated bytes aren't paid for twice. + +**Session plumbing.** RTK learns its session from the Claude Code hook: `hooks::hook_cmd::process_claude_payload` reads `session_id` from the PreToolUse payload and splices `--session ` into the rewritten command (`hook injects` → `rtk --session git status`). `core::session` resolves it (flag → `RTK_SESSION_ID` env → none) into a process-global `OnceLock`. No session (all manual invocations) ⇒ dedup no-ops and output is byte-for-byte unchanged. + +**Two-key safety model.** Correctness rests on two independent keys: +- **`content_hash`** — SHA-256 of the *raw, pre-filter* bytes — is the guarantee. Different content can never share a hash, so a changed file/command is never suppressed. The hash *is* the freshness check. +- **`identity`** (normalized command key) is cosmetic, used only in the stub message. Because the content hash is the real guard, identity collisions are harmless — at worst dedup is skipped (safe), never wrongly applied. + +**Guards** (each falls back to full output): dedup disabled, no session id, command exited non-zero (unless `suppress_on_error`), output below `min_tokens`, or any DB error. Suppression can therefore only ever *omit a repeat*, never hide new, changed, or failed output. + +**Ledger.** A `session_outputs` table in the tracking DB (keyed by `session_id` + `content_hash`) stores hashes and counters only — never content. Rows are pruned after 48h idle. `rtk gain` reports the saved tokens separately (`SUM(output_tokens * (emit_count - 1))`) so filtering and dedup never double-count. + +**Compaction protection.** Suppression is only safe while the earlier full emission is still in the agent's context — which context *compaction* can violate. Two layers guard this: +- **PostCompact ledger reset (primary).** `rtk init` registers a `PostCompact` hook (`rtk hook compact`) that clears the session's ledger (`Tracker::dedup_reset_session`). After any compaction the ledger is empty, so the next read re-emits full. Dedup therefore only ever suppresses *within a single un-compacted context epoch*. (Existing installs pick this up on re-running `rtk init`.) +- **Recency window (backstop).** `[dedup] recency_window` (default 100, 0 = unlimited) refuses to suppress if the prior emission is more than N distinct emissions behind the latest — catching any context reduction that fired no compaction signal (e.g. undocumented micro-summarization). Uses the `step_ordinal` already stored per row. + +**Config / toggle.** `[dedup]` config section (`enabled = false` by default — opt-in, since suppression changes agent-visible output; plus `min_tokens`, `suppress_on_error`, `recency_window`). All fields have serde defaults, so enabling is a one-liner (`[dedup]\nenabled = true`). `RTK_DEDUP=1` force-enables without a config file (mirrors `RTK_DB_PATH` / `RTK_HOOK_AUDIT`). + +**Seams.** Wired at `core::runner::run_captured_filter` (non-tee branch — covers git/gh/cargo and most ecosystems) and `cmds::system::read` (file + stdin). The tee and skip-filter-on-failure paths are left full (failure-heavy, low-value). + --- ## Global Flags Architecture diff --git a/src/analytics/gain.rs b/src/analytics/gain.rs index 980c04738f..dba8647358 100644 --- a/src/analytics/gain.rs +++ b/src/analytics/gain.rs @@ -119,6 +119,23 @@ pub fn run( ), ); print_efficiency_meter(summary.avg_savings_pct); + + // Session output dedup savings — reported separately from filtering so + // the two never double-count. Session-scoped (not project-scoped), so + // shown as a global figure regardless of the report scope. + if let Ok((dedup_tokens, dedup_hits)) = tracker.dedup_savings() { + if dedup_hits > 0 { + print_kpi( + "Dedup suppressed", + format!( + "{} over {} repeat-emission{}", + format_tokens(dedup_tokens as usize), + dedup_hits, + if dedup_hits == 1 { "" } else { "s" } + ), + ); + } + } println!(); // Warn about hook issues that silently kill savings (stderr, not stdout) diff --git a/src/cmds/system/read.rs b/src/cmds/system/read.rs index 141d55acf2..147771770a 100644 --- a/src/cmds/system/read.rs +++ b/src/cmds/system/read.rs @@ -75,13 +75,16 @@ pub fn run( (content.clone(), filtered.clone()) }; let shown = never_worse(&raw, &rtk_output); - print!("{}", shown); - timer.track( - &format!("cat {}", file.display()), - "rtk read", + // Suppress if this exact file content was already emitted this session. + // Reaching here means the read succeeded, so exit_ok = true. + let out = crate::core::dedup::maybe_suppress( + &format!("read:{}", file.display()), &raw, shown, + true, ); + print!("{}", out); + timer.track(&format!("cat {}", file.display()), "rtk read", &raw, shown); Ok(()) } @@ -143,7 +146,10 @@ pub fn run_stdin( (content.clone(), filtered.clone()) }; let shown = never_worse(&raw, &rtk_output); - print!("{}", shown); + // stdin has no path identity; key the stub on a fixed label (the content + // hash is what actually gates suppression). + let out = crate::core::dedup::maybe_suppress("read:(stdin)", &raw, shown, true); + print!("{}", out); timer.track("cat - (stdin)", "rtk read -", &raw, shown); Ok(()) diff --git a/src/core/config.rs b/src/core/config.rs index ed0f00c6c9..17afd891f5 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -21,6 +21,8 @@ pub struct Config { pub hooks: HooksConfig, #[serde(default)] pub limits: LimitsConfig, + #[serde(default)] + pub dedup: DedupConfig, } #[derive(Debug, Serialize, Deserialize, Default)] @@ -145,11 +147,57 @@ impl Default for LimitsConfig { } } +fn default_dedup_min_tokens() -> usize { + 200 +} + +fn default_dedup_recency_window() -> usize { + 100 +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DedupConfig { + /// Suppress re-emitting byte-identical command output already seen this + /// Claude Code session. Opt-in: suppression changes agent-visible output. + #[serde(default)] + pub enabled: bool, + /// Skip dedup for outputs smaller than this many estimated tokens. + #[serde(default = "default_dedup_min_tokens")] + pub min_tokens: usize, + /// Suppress even when the underlying command exited non-zero. Off by + /// default so command failures are always shown in full. + #[serde(default)] + pub suppress_on_error: bool, + /// Recency backstop for compaction: only suppress if the prior emission is + /// within this many distinct emissions of the most recent one. Guards + /// against suppressing output that a context reduction may have dropped + /// even if no PreCompact/PostCompact signal fired. 0 = unlimited (rely on + /// the PostCompact ledger reset alone). + #[serde(default = "default_dedup_recency_window")] + pub recency_window: usize, +} + +impl Default for DedupConfig { + fn default() -> Self { + Self { + enabled: false, + min_tokens: default_dedup_min_tokens(), + suppress_on_error: false, + recency_window: default_dedup_recency_window(), + } + } +} + /// Get limits config. Falls back to defaults if config can't be loaded. pub fn limits() -> LimitsConfig { Config::load().map(|c| c.limits).unwrap_or_default() } +/// Get dedup config. Falls back to defaults if config can't be loaded. +pub fn dedup() -> DedupConfig { + Config::load().map(|c| c.dedup).unwrap_or_default() +} + impl Config { pub fn load() -> Result { let path = get_config_path()?; @@ -281,6 +329,42 @@ enabled = true assert!(config.telemetry.consent_given.is_none()); } + #[test] + fn test_dedup_defaults_and_old_toml_parses() { + // Config predating [dedup] (here, a config with no [dedup] section at + // all) must still parse, with safe defaults. + let config: Config = toml::from_str("").expect("valid toml"); + assert!(!config.dedup.enabled); // opt-in + assert_eq!(config.dedup.min_tokens, 200); + assert!(!config.dedup.suppress_on_error); + } + + #[test] + fn test_dedup_config_roundtrip() { + let toml = r#" +[dedup] +enabled = true +min_tokens = 500 +suppress_on_error = true +"#; + let config: Config = toml::from_str(toml).expect("valid toml"); + assert!(config.dedup.enabled); + assert_eq!(config.dedup.min_tokens, 500); + assert!(config.dedup.suppress_on_error); + assert_eq!(config.dedup.recency_window, 100); // omitted -> default + } + + #[test] + fn test_dedup_partial_section_uses_field_defaults() { + // Enabling should be a one-liner; the other fields fall back to defaults. + let toml = "[dedup]\nenabled = true\n"; + let config: Config = toml::from_str(toml).expect("valid toml"); + assert!(config.dedup.enabled); + assert_eq!(config.dedup.min_tokens, 200); + assert!(!config.dedup.suppress_on_error); + assert_eq!(config.dedup.recency_window, 100); + } + #[test] fn test_telemetry_consent_roundtrip() { let toml = r#" diff --git a/src/core/dedup.rs b/src/core/dedup.rs new file mode 100644 index 0000000000..cbe2ac3ef4 --- /dev/null +++ b/src/core/dedup.rs @@ -0,0 +1,377 @@ +//! Session-level output dedup: suppress re-emitting byte-identical command +//! output already seen earlier in the same Claude Code session. +//! +//! Correctness rests on two keys. The SHA-256 of the *raw* (pre-filter) bytes +//! is the guarantee — different content can never share a hash, so a changed +//! file or command output is never suppressed. The command `identity` is only +//! a cosmetic key used in the stub message; identity collisions are therefore +//! harmless. Every guard and every failure path (disabled, no session, command +//! failure, tiny output, DB error) falls back to emitting the original output, +//! so dedup can only ever *omit a repeat*, never hide new or changed output. + +use std::borrow::Cow; + +use sha2::{Digest, Sha256}; + +use super::config; +use super::session; +use super::tracking::{estimate_tokens, DedupRow, Tracker}; + +/// The knobs `maybe_suppress` needs, resolved from `[dedup]` config. +struct Params { + enabled: bool, + min_tokens: usize, + suppress_on_error: bool, + recency_window: usize, +} + +/// SHA-256 of the raw bytes, hex-encoded. Collision-resistant, so identity +/// bucketing collisions can never cause a false suppression. +fn content_hash(raw: &str) -> String { + use std::fmt::Write as _; + let digest = Sha256::digest(raw.as_bytes()); + let mut hex = String::with_capacity(digest.len() * 2); + for b in digest { + let _ = write!(hex, "{b:02x}"); + } + hex +} + +/// Format the one-line suppression stub. Machine-parseable (`rtk-dedup:` +/// prefix), carries the recovery command so the agent can force full output. +fn format_stub(prev: &DedupRow, identity: &str) -> String { + format!( + "rtk-dedup: identical to output at step {} · {} lines / ~{} tok suppressed \ + [{}] (force full: rtk proxy )\n", + prev.step_ordinal, prev.line_count, prev.output_tokens, identity + ) +} + +/// Core logic, decoupled from global state for testing. Given an open tracker, +/// resolved params, and the session id, decide whether to suppress `shown`. +fn suppress_with<'a>( + tracker: &Tracker, + params: &Params, + session_id: Option<&str>, + identity: &str, + raw: &str, + shown: &'a str, + exit_ok: bool, +) -> Cow<'a, str> { + if !params.enabled { + return Cow::Borrowed(shown); + } + let Some(session_id) = session_id else { + return Cow::Borrowed(shown); + }; + if !exit_ok && !params.suppress_on_error { + return Cow::Borrowed(shown); + } + let shown_tokens = estimate_tokens(shown); + if shown_tokens < params.min_tokens { + return Cow::Borrowed(shown); + } + + // The hash keys on raw (pre-filter) bytes; the ledger records the size of + // what was actually emitted (`shown`) so the stub reports real savings. + let hash = content_hash(raw); + match tracker.dedup_lookup(session_id, &hash) { + Ok(Some(prev)) => { + // Recency backstop: if the prior emission is more than + // `recency_window` distinct emissions behind the latest, treat it + // as possibly-out-of-context (a reduction we didn't get a signal + // for) and re-emit full rather than suppress. 0 = unlimited. + if params.recency_window > 0 { + let max_ord = tracker + .dedup_max_ordinal(session_id) + .unwrap_or(prev.step_ordinal); + if (max_ord - prev.step_ordinal) as usize > params.recency_window { + return Cow::Borrowed(shown); + } + } + let _ = tracker.dedup_bump(prev.id); + Cow::Owned(format_stub(&prev, identity)) + } + Ok(None) => { + let lines = shown.lines().count(); + let _ = tracker.dedup_insert(session_id, &hash, identity, shown_tokens, lines); + Cow::Borrowed(shown) + } + Err(_) => Cow::Borrowed(shown), + } +} + +/// Suppress `shown` if its raw bytes were already emitted this session; else +/// emit it and remember it. `raw` is the pre-filter output (hashed); `shown` +/// is what would otherwise be printed. Any guard failure, missing session, or +/// DB error emits `shown` unchanged. +/// +/// The no-session and disabled fast paths do no I/O — session is checked first +/// (a cheap `OnceLock` read) so manual invocations never even read config, and +/// with dedup off (the default) this adds no measurable overhead. +pub fn maybe_suppress<'a>( + identity: &str, + raw: &str, + shown: &'a str, + exit_ok: bool, +) -> Cow<'a, str> { + // No session (all manual invocations, and the whole test suite) => no-op, + // before any config read or DB open. + let Some(session_id) = session::current().id() else { + return Cow::Borrowed(shown); + }; + let cfg = config::dedup(); + // `RTK_DEDUP=1` force-enables regardless of config — a no-config toggle for + // users and hermetic integration tests (mirrors RTK_DB_PATH / RTK_HOOK_AUDIT). + let enabled = cfg.enabled || matches!(std::env::var("RTK_DEDUP").as_deref(), Ok("1")); + if !enabled { + return Cow::Borrowed(shown); + } + let tracker = match Tracker::new() { + Ok(t) => t, + Err(_) => return Cow::Borrowed(shown), + }; + let params = Params { + enabled, + min_tokens: cfg.min_tokens, + suppress_on_error: cfg.suppress_on_error, + recency_window: cfg.recency_window, + }; + suppress_with( + &tracker, + ¶ms, + Some(session_id), + identity, + raw, + shown, + exit_ok, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn enabled_params() -> Params { + Params { + enabled: true, + min_tokens: 200, + suppress_on_error: false, + recency_window: 0, // unlimited unless a test overrides it + } + } + + /// A body large enough to clear the 200-token min gate (~4 chars/token). + fn big_body() -> String { + "line of output text\n".repeat(100) // ~2000 chars, ~500 tokens, 100 lines + } + + #[test] + fn hash_is_deterministic_and_content_sensitive() { + assert_eq!(content_hash("abc"), content_hash("abc")); + assert_ne!(content_hash("abc"), content_hash("abd")); + assert_eq!(content_hash("abc").len(), 64); // hex SHA-256 + } + + #[test] + fn stub_carries_recovery_and_stats() { + let row = DedupRow { + id: 1, + step_ordinal: 4, + output_tokens: 512, + line_count: 100, + }; + let stub = format_stub(&row, "read:foo.rs"); + assert!(stub.starts_with("rtk-dedup:")); + assert!(stub.contains("step 4")); + assert!(stub.contains("100 lines")); + assert!(stub.contains("512 tok")); + assert!(stub.contains("read:foo.rs")); + assert!(stub.contains("rtk proxy")); + } + + #[test] + fn disabled_emits_full() { + let t = Tracker::new_in_memory().expect("tracker"); + let params = Params { + enabled: false, + ..enabled_params() + }; + let body = big_body(); + let out = suppress_with(&t, ¶ms, Some("s1"), "id", &body, &body, true); + assert_eq!(out, Cow::Borrowed(body.as_str())); + } + + #[test] + fn no_session_emits_full() { + let t = Tracker::new_in_memory().expect("tracker"); + let body = big_body(); + let out = suppress_with(&t, &enabled_params(), None, "id", &body, &body, true); + assert!(matches!(out, Cow::Borrowed(_))); + } + + #[test] + fn command_failure_emits_full() { + let t = Tracker::new_in_memory().expect("tracker"); + let body = big_body(); + // exit_ok = false, suppress_on_error = false (default) + let out = suppress_with(&t, &enabled_params(), Some("s1"), "id", &body, &body, false); + assert!(matches!(out, Cow::Borrowed(_))); + // ...and nothing was recorded, so a later success still sees a miss. + assert!(t + .dedup_lookup("s1", &content_hash(&body)) + .unwrap() + .is_none()); + } + + #[test] + fn below_min_tokens_emits_full() { + let t = Tracker::new_in_memory().expect("tracker"); + let out = suppress_with( + &t, + &enabled_params(), + Some("s1"), + "id", + "tiny", + "tiny", + true, + ); + assert_eq!(out, Cow::Borrowed("tiny")); + } + + #[test] + fn first_emit_full_then_identical_suppressed() { + let t = Tracker::new_in_memory().expect("tracker"); + let body = big_body(); + // First emission: full, and recorded. + let first = suppress_with( + &t, + &enabled_params(), + Some("s1"), + "read:x", + &body, + &body, + true, + ); + assert!(matches!(first, Cow::Borrowed(_))); + // Second identical emission: suppressed to a stub. + let second = suppress_with( + &t, + &enabled_params(), + Some("s1"), + "read:x", + &body, + &body, + true, + ); + match second { + Cow::Owned(s) => { + assert!(s.starts_with("rtk-dedup:")); + assert!(s.contains("100 lines")); + } + Cow::Borrowed(_) => panic!("expected suppression on identical re-emit"), + } + } + + #[test] + fn changed_content_emits_full() { + let t = Tracker::new_in_memory().expect("tracker"); + let body = big_body(); + let changed = format!("{body}extra line\n"); + suppress_with( + &t, + &enabled_params(), + Some("s1"), + "read:x", + &body, + &body, + true, + ); + // Different raw bytes -> different hash -> not suppressed. + let out = suppress_with( + &t, + &enabled_params(), + Some("s1"), + "read:x", + &changed, + &changed, + true, + ); + assert!(matches!(out, Cow::Borrowed(_))); + } + + #[test] + fn suppress_on_error_allows_failure_dedup() { + let t = Tracker::new_in_memory().expect("tracker"); + let params = Params { + suppress_on_error: true, + ..enabled_params() + }; + let body = big_body(); + // exit_ok=false but suppress_on_error=true -> proceeds past the guard, + // first emit records (miss -> borrowed). + let first = suppress_with(&t, ¶ms, Some("s1"), "id", &body, &body, false); + assert!(matches!(first, Cow::Borrowed(_))); + // Second identical failing emission is now suppressed. + let second = suppress_with(&t, ¶ms, Some("s1"), "id", &body, &body, false); + assert!(matches!(second, Cow::Owned(_))); + } + + /// Emit `n` distinct big bodies to advance the session's max ordinal. + fn advance_ordinal(t: &Tracker, params: &Params, session: &str, n: usize) { + let base = big_body(); + for i in 0..n { + let b = format!("{base}variant {i}\n"); + suppress_with(t, params, Some(session), "id", &b, &b, true); + } + } + + #[test] + fn recency_window_reemits_stale_anchor() { + let t = Tracker::new_in_memory().expect("tracker"); + let params = Params { + recency_window: 2, + ..enabled_params() + }; + let body = big_body(); + // Emit body -> ordinal 1. + suppress_with(&t, ¶ms, Some("s1"), "id", &body, &body, true); + // Push the max ordinal to 4 with 3 distinct emissions (gap becomes 3). + advance_ordinal(&t, ¶ms, "s1", 3); + // gap (4 - 1) = 3 > window 2 -> re-emit full instead of a stub. + let out = suppress_with(&t, ¶ms, Some("s1"), "id", &body, &body, true); + assert!( + matches!(out, Cow::Borrowed(_)), + "stale anchor beyond recency_window must re-emit full" + ); + } + + #[test] + fn recency_window_suppresses_within_window() { + let t = Tracker::new_in_memory().expect("tracker"); + let params = Params { + recency_window: 5, + ..enabled_params() + }; + let body = big_body(); + suppress_with(&t, ¶ms, Some("s1"), "id", &body, &body, true); // ord 1 + advance_ordinal(&t, ¶ms, "s1", 2); // max -> 3, gap = 2 + let out = suppress_with(&t, ¶ms, Some("s1"), "id", &body, &body, true); + assert!( + matches!(out, Cow::Owned(_)), + "prior emission within recency_window must still suppress" + ); + } + + #[test] + fn recency_window_zero_is_unlimited() { + let t = Tracker::new_in_memory().expect("tracker"); + let params = enabled_params(); // recency_window = 0 + let body = big_body(); + suppress_with(&t, ¶ms, Some("s1"), "id", &body, &body, true); // ord 1 + advance_ordinal(&t, ¶ms, "s1", 20); // big gap + // window 0 = unlimited -> still suppresses despite the large gap. + let out = suppress_with(&t, ¶ms, Some("s1"), "id", &body, &body, true); + assert!(matches!(out, Cow::Owned(_))); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index aaa747e083..53357deb51 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -3,10 +3,12 @@ pub mod args_utils; pub mod config; pub mod constants; +pub mod dedup; pub mod display_helpers; pub mod filter; pub mod guard; pub mod runner; +pub mod session; pub mod stream; pub mod tee; pub mod telemetry; diff --git a/src/core/runner.rs b/src/core/runner.rs index b893357491..f7599a9931 100644 --- a/src/core/runner.rs +++ b/src/core/runner.rs @@ -139,10 +139,24 @@ where print_with_hint(&filtered, raw, raw_for_tracking, label, exit_code) } else { let guarded = crate::core::guard::never_worse(raw_for_tracking, &filtered).to_string(); - if opts.no_trailing_newline { - print!("{}", guarded); - } else { - println!("{}", guarded); + // Suppress a byte-identical re-emission within the session. The stub + // carries its own newline, so print it verbatim; when not suppressed, + // fall through to the exact original print path (byte-identical output, + // no snapshot churn). + match crate::core::dedup::maybe_suppress( + cmd_label, + raw_for_tracking, + &guarded, + exit_code == 0, + ) { + std::borrow::Cow::Owned(stub) => print!("{}", stub), + std::borrow::Cow::Borrowed(_) => { + if opts.no_trailing_newline { + print!("{}", guarded); + } else { + println!("{}", guarded); + } + } } guarded }; diff --git a/src/core/session.rs b/src/core/session.rs new file mode 100644 index 0000000000..36edc1e179 --- /dev/null +++ b/src/core/session.rs @@ -0,0 +1,102 @@ +//! Session context — carries the Claude Code session id when available. +//! +//! Session-scoped features (currently output dedup) key on this id so they only +//! ever act within a single Claude Code session. The id is populated once, from +//! the `--session` global flag (injected by the hook, see `hooks::hook_cmd`) or +//! the `RTK_SESSION_ID` environment variable. For manual invocations neither is +//! present, `id()` is `None`, and session-scoped features no-op — keeping raw +//! `rtk ` output byte-for-byte unchanged. + +use std::sync::OnceLock; + +static SESSION: OnceLock = OnceLock::new(); + +/// Resolved session identity for the current process. +#[derive(Debug, Clone, Default)] +pub struct SessionCtx { + id: Option, +} + +impl SessionCtx { + /// Resolve from the CLI flag, falling back to `RTK_SESSION_ID`. Blank or + /// whitespace-only values are treated as absent. + pub fn resolve(cli_flag: Option) -> Self { + Self { + id: resolve_from(cli_flag, std::env::var("RTK_SESSION_ID").ok()), + } + } + + /// The session id, if known. + pub fn id(&self) -> Option<&str> { + self.id.as_deref() + } +} + +/// Pure resolution core: flag wins over env; blank values are dropped. Kept +/// separate so it can be unit-tested without touching the real environment. +fn resolve_from(cli_flag: Option, env_val: Option) -> Option { + cli_flag + .filter(|s| !s.trim().is_empty()) + .or(env_val) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Initialize the process-global session context. The first call wins; later +/// calls are ignored, which keeps re-entrant paths and tests safe. +pub fn init(cli_flag: Option) { + let _ = SESSION.set(SessionCtx::resolve(cli_flag)); +} + +/// The process-global session context. If `init` was never called (code paths +/// that bypass `run_cli`), falls back to env-only resolution. +pub fn current() -> &'static SessionCtx { + SESSION.get_or_init(|| SessionCtx::resolve(None)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flag_takes_precedence_over_env() { + assert_eq!( + resolve_from(Some("abc123".into()), Some("env-id".into())), + Some("abc123".into()) + ); + } + + #[test] + fn env_used_when_flag_absent() { + assert_eq!( + resolve_from(None, Some("env-id".into())), + Some("env-id".into()) + ); + } + + #[test] + fn both_absent_yields_none() { + assert_eq!(resolve_from(None, None), None); + } + + #[test] + fn blank_flag_falls_through_to_env() { + assert_eq!( + resolve_from(Some(" ".into()), Some("env-id".into())), + Some("env-id".into()) + ); + } + + #[test] + fn blank_flag_and_no_env_yields_none() { + assert_eq!(resolve_from(Some(" ".into()), None), None); + } + + #[test] + fn value_is_trimmed() { + assert_eq!( + resolve_from(Some(" abc ".into()), None), + Some("abc".into()) + ); + } +} diff --git a/src/core/tracking.rs b/src/core/tracking.rs index 359e4f6885..fc123b8373 100644 --- a/src/core/tracking.rs +++ b/src/core/tracking.rs @@ -92,6 +92,17 @@ pub struct Tracker { conn: Connection, } +/// A prior emission of identical content within a session — one row of the +/// dedup ledger, returned by [`Tracker::dedup_lookup`]. Fields feed the +/// suppression stub and the `dedup_bump` call. +#[cfg_attr(test, derive(Debug, PartialEq))] +pub struct DedupRow { + pub id: i64, + pub step_ordinal: i64, + pub output_tokens: i64, + pub line_count: i64, +} + /// Individual command record from tracking history. /// /// Contains timestamp, command name, and savings metrics for a single execution. @@ -323,6 +334,29 @@ impl Tracker { [], )?; + // Session-level output dedup ledger. Keyed by (session_id, content_hash) + // so a byte-identical re-emission within one Claude Code session can be + // suppressed. Stores hashes + counters only — never content. + conn.execute( + "CREATE TABLE IF NOT EXISTS session_outputs ( + id INTEGER PRIMARY KEY, + session_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + identity TEXT NOT NULL, + output_tokens INTEGER NOT NULL, + line_count INTEGER NOT NULL, + step_ordinal INTEGER NOT NULL, + emit_count INTEGER NOT NULL DEFAULT 1, + first_seen TEXT NOT NULL, + last_seen TEXT NOT NULL + )", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_dedup_lookup ON session_outputs(session_id, content_hash)", + [], + )?; + Ok(Self { conn }) } @@ -374,6 +408,25 @@ impl Tracker { "CREATE INDEX IF NOT EXISTS idx_pf_timestamp ON parse_failures(timestamp)", [], )?; + self.conn.execute( + "CREATE TABLE IF NOT EXISTS session_outputs ( + id INTEGER PRIMARY KEY, + session_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + identity TEXT NOT NULL, + output_tokens INTEGER NOT NULL, + line_count INTEGER NOT NULL, + step_ordinal INTEGER NOT NULL, + emit_count INTEGER NOT NULL DEFAULT 1, + first_seen TEXT NOT NULL, + last_seen TEXT NOT NULL + )", + [], + )?; + self.conn.execute( + "CREATE INDEX IF NOT EXISTS idx_dedup_lookup ON session_outputs(session_id, content_hash)", + [], + )?; Ok(()) } @@ -446,9 +499,133 @@ impl Tracker { "DELETE FROM parse_failures WHERE timestamp < ?1", params![cutoff.to_rfc3339()], )?; + // Dedup ledger rows are session-scoped and short-lived; prune once a + // session has been idle beyond the TTL so the table stays tiny. + const DEDUP_SESSION_TTL_HOURS: i64 = 48; + let dedup_cutoff = Utc::now() - chrono::Duration::hours(DEDUP_SESSION_TTL_HOURS); + self.conn.execute( + "DELETE FROM session_outputs WHERE last_seen < ?1", + params![dedup_cutoff.to_rfc3339()], + )?; + Ok(()) + } + + /// Look up a prior byte-identical emission in the same session. + /// Returns `None` on the first (or a novel) emission. + pub fn dedup_lookup(&self, session_id: &str, content_hash: &str) -> Result> { + match self.conn.query_row( + "SELECT id, step_ordinal, output_tokens, line_count + FROM session_outputs WHERE session_id = ?1 AND content_hash = ?2 LIMIT 1", + params![session_id, content_hash], + |r| { + Ok(DedupRow { + id: r.get(0)?, + step_ordinal: r.get(1)?, + output_tokens: r.get(2)?, + line_count: r.get(3)?, + }) + }, + ) { + Ok(row) => Ok(Some(row)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e).context("dedup_lookup failed"), + } + } + + /// Record a first emission of `content_hash` in `session_id`. Returns the + /// 1-based `step_ordinal` assigned within the session (its Nth tracked + /// emission), used for the suppression stub message. + pub fn dedup_insert( + &self, + session_id: &str, + content_hash: &str, + identity: &str, + output_tokens: usize, + line_count: usize, + ) -> Result { + let now = Utc::now().to_rfc3339(); + // MAX-based (not COUNT) so ordinals stay monotonic even after prunes or + // a PostCompact reset deletes rows mid-session. + let step_ordinal: i64 = self.conn.query_row( + "SELECT COALESCE(MAX(step_ordinal), 0) + 1 FROM session_outputs WHERE session_id = ?1", + params![session_id], + |r| r.get(0), + )?; + self.conn.execute( + "INSERT INTO session_outputs + (session_id, content_hash, identity, output_tokens, line_count, step_ordinal, emit_count, first_seen, last_seen) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?7)", + params![ + session_id, + content_hash, + identity, + output_tokens as i64, + line_count as i64, + step_ordinal, + now + ], + )?; + Ok(step_ordinal) + } + + /// Record that a ledger row's content was emitted again (suppressed): bump + /// `emit_count` and refresh `last_seen`. + pub fn dedup_bump(&self, id: i64) -> Result<()> { + self.conn.execute( + "UPDATE session_outputs SET emit_count = emit_count + 1, last_seen = ?2 WHERE id = ?1", + params![id, Utc::now().to_rfc3339()], + )?; Ok(()) } + /// Total tokens saved by session dedup and the number of suppressed + /// re-emissions. For each ledger row, `output_tokens * (emit_count - 1)` is + /// the tokens saved by not re-emitting its repeats. Reported separately from + /// filtering savings so the two never double-count. + pub fn dedup_savings(&self) -> Result<(i64, i64)> { + let row = self.conn.query_row( + "SELECT COALESCE(SUM(output_tokens * (emit_count - 1)), 0), + COALESCE(SUM(emit_count - 1), 0) + FROM session_outputs", + [], + |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)), + )?; + Ok(row) + } + + /// Highest `step_ordinal` currently recorded for a session (0 if none). + /// Used by the recency backstop to measure how "old" a prior emission is. + pub fn dedup_max_ordinal(&self, session_id: &str) -> Result { + let max = self.conn.query_row( + "SELECT COALESCE(MAX(step_ordinal), 0) FROM session_outputs WHERE session_id = ?1", + params![session_id], + |r| r.get(0), + )?; + Ok(max) + } + + /// Clear a session's dedup ledger. Called on PostCompact so suppression + /// never spans a context reduction — after compaction the earlier full + /// emission may be gone from context, so the next read must re-emit it. + pub fn dedup_reset_session(&self, session_id: &str) -> Result { + let n = self.conn.execute( + "DELETE FROM session_outputs WHERE session_id = ?1", + params![session_id], + )?; + Ok(n) + } + + #[cfg(test)] + fn dedup_emit_count(&self, id: i64) -> i64 { + self.conn + .query_row( + "SELECT emit_count FROM session_outputs WHERE id = ?1", + params![id], + |r| r.get(0), + ) + .expect("emit_count query") + } + /// Delete all tracked data (commands + parse_failures), resetting all stats to zero. pub fn reset_all(&self) -> Result<()> { self.conn @@ -1423,6 +1600,68 @@ pub fn args_display(args: &[OsString]) -> String { mod tests { use super::*; + // --- Dedup ledger (Phase 3) --- + + #[test] + fn dedup_miss_insert_hit() { + let t = Tracker::new_in_memory().expect("tracker"); + assert!(t.dedup_lookup("s1", "h1").expect("lookup").is_none()); + let ord = t + .dedup_insert("s1", "h1", "read:foo", 100, 20) + .expect("insert"); + assert_eq!(ord, 1); + let row = t.dedup_lookup("s1", "h1").expect("lookup").expect("hit"); + assert_eq!(row.step_ordinal, 1); + assert_eq!(row.output_tokens, 100); + assert_eq!(row.line_count, 20); + } + + #[test] + fn dedup_cross_session_isolation() { + let t = Tracker::new_in_memory().expect("tracker"); + t.dedup_insert("s1", "h1", "id", 10, 2).expect("insert"); + assert!(t.dedup_lookup("s2", "h1").expect("lookup").is_none()); + assert!(t.dedup_lookup("s1", "h1").expect("lookup").is_some()); + } + + #[test] + fn dedup_ordinal_monotonic_per_session() { + let t = Tracker::new_in_memory().expect("tracker"); + assert_eq!(t.dedup_insert("s1", "h1", "a", 1, 1).expect("i"), 1); + assert_eq!(t.dedup_insert("s1", "h2", "b", 1, 1).expect("i"), 2); + // A different session restarts the ordinal. + assert_eq!(t.dedup_insert("s2", "h3", "c", 1, 1).expect("i"), 1); + } + + #[test] + fn dedup_bump_increments_emit_count() { + let t = Tracker::new_in_memory().expect("tracker"); + t.dedup_insert("s1", "h1", "id", 10, 2).expect("insert"); + let row = t.dedup_lookup("s1", "h1").expect("lookup").expect("hit"); + assert_eq!(t.dedup_emit_count(row.id), 1); + t.dedup_bump(row.id).expect("bump"); + t.dedup_bump(row.id).expect("bump"); + assert_eq!(t.dedup_emit_count(row.id), 3); + } + + #[test] + fn dedup_savings_counts_suppressed_repeats_only() { + let t = Tracker::new_in_memory().expect("tracker"); + // Fresh ledger: nothing suppressed yet. + assert_eq!(t.dedup_savings().expect("savings"), (0, 0)); + + // Row A: 100 tokens, emitted then suppressed twice -> saves 200 over 2. + t.dedup_insert("s1", "hA", "id", 100, 5).expect("insert"); + let a = t.dedup_lookup("s1", "hA").expect("l").expect("hit"); + t.dedup_bump(a.id).expect("bump"); + t.dedup_bump(a.id).expect("bump"); + + // Row B: 50 tokens, inserted but never repeated -> saves 0. + t.dedup_insert("s1", "hB", "id", 50, 3).expect("insert"); + + assert_eq!(t.dedup_savings().expect("savings"), (200, 2)); + } + // 1. estimate_tokens — verify ~4 chars/token ratio #[test] fn test_estimate_tokens() { diff --git a/src/discover/provider.rs b/src/discover/provider.rs index d8f73c6fa6..2e85e9d404 100644 --- a/src/discover/provider.rs +++ b/src/discover/provider.rs @@ -117,15 +117,15 @@ impl ClaudeProvider { /// Encode a filesystem path to Claude Code's directory name format. /// - /// Claude Code replaces `/`, `.`, `_`, `\`, and any non-ASCII character + /// Claude Code replaces `/`, `.`, `_`, `\`, `:`, and any non-ASCII character /// with `-` when computing the project directory slug under `~/.claude/projects/`. /// /// `/Users/foo/bar` → `-Users-foo-bar` /// `/Users/first.last/bar` → `-Users-first-last-bar` /// `/home/chris/2_project` → `-home-chris-2-project` - /// `C:\Users\foo\bar` → `C:-Users-foo-bar` + /// `C:\Users\foo\bar` → `C--Users-foo-bar` pub fn encode_project_path(path: &str) -> String { - const SANITIZED_CHARS: &[char] = &['/', '.', '_', '\\', ' ', '[', ']']; + const SANITIZED_CHARS: &[char] = &['/', '.', '_', '\\', ':', ' ', '[', ']']; path.chars() .map(|c| { @@ -403,10 +403,10 @@ mod tests { #[test] fn test_encode_project_path_windows() { - // Windows backslashes are also replaced with '-' + // Windows backslashes and drive-letter colons are also replaced with '-' assert_eq!( ClaudeProvider::encode_project_path(r"C:\Users\foo\bar"), - "C:-Users-foo-bar" + "C--Users-foo-bar" ); } diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 4caaf94473..125090c12f 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -6,10 +6,13 @@ pub const SETTINGS_JSON: &str = "settings.json"; pub const SETTINGS_LOCAL_JSON: &str = "settings.local.json"; pub const HOOKS_JSON: &str = "hooks.json"; pub const PRE_TOOL_USE_KEY: &str = "PreToolUse"; +pub const POST_COMPACT_KEY: &str = "PostCompact"; pub const BEFORE_TOOL_KEY: &str = "BeforeTool"; /// Native Rust hook command for Claude Code (replaces rtk-rewrite.sh). pub const CLAUDE_HOOK_COMMAND: &str = "rtk hook claude"; +/// Claude Code PostCompact hook — resets the session dedup ledger. +pub const CLAUDE_COMPACT_HOOK_COMMAND: &str = "rtk hook compact"; /// Native Rust hook command for Cursor (replaces rtk-rewrite.sh). pub const CURSOR_HOOK_COMMAND: &str = "rtk hook cursor"; /// Native Rust hook command for Factory Droid. diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 40451b93c0..ab9ffbb4b5 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -339,6 +339,59 @@ enum PayloadAction { Ignore, } +/// Whether a session id is safe to splice into a shell command as a bare token. +/// Claude Code session ids are UUIDs; we accept only `[A-Za-z0-9_-]` and reject +/// anything else rather than risk shell injection via a crafted id. +fn session_id_is_safe(id: &str) -> bool { + !id.is_empty() + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// True if `prefix` (the text preceding a candidate `rtk`) ends at a command +/// boundary — its last non-space byte is a shell operator (`&`, `|`, `;`). +fn command_boundary_before(prefix: &str) -> bool { + matches!( + prefix.trim_end().as_bytes().last(), + Some(b'&' | b'|' | b';') + ) +} + +/// Inject `--session ` into a freshly rewritten command so the executed +/// `rtk` process can scope session-only features (output dedup). The flag is +/// inserted after the first `rtk` command word — start of string, or the first +/// `rtk` at a command boundary (immediately after `&&`, `||`, `;`, `|`, `&`). +/// Additional `rtk` invocations in a compound command are left untouched: they +/// run without a session id and session-scoped features no-op there (safe +/// degradation, never incorrect). Returns the input unchanged if the id is +/// unsafe or no `rtk` command word is found. +fn inject_session(rewritten: &str, session_id: &str) -> String { + if !session_id_is_safe(session_id) { + return rewritten.to_string(); + } + let bytes = rewritten.as_bytes(); + let mut from = 0; + while let Some(rel) = rewritten[from..].find("rtk") { + let idx = from + rel; + let after = idx + 3; + // `rtk` must be a whole word (followed by a space or end of string)... + let right_ok = after == rewritten.len() || bytes[after] == b' '; + // ...at a command position (start, or after a shell operator). + let left_ok = idx == 0 || command_boundary_before(&rewritten[..idx]); + if right_ok && left_ok { + let mut out = String::with_capacity(rewritten.len() + session_id.len() + 12); + out.push_str(&rewritten[..after]); + out.push_str(" --session "); + out.push_str(session_id); + out.push_str(&rewritten[after..]); + return out; + } + from = after; + } + rewritten.to_string() +} + fn process_claude_payload(v: &Value) -> PayloadAction { let cmd = match v .pointer("/tool_input/command") @@ -366,10 +419,19 @@ fn process_claude_payload(v: &Value) -> PayloadAction { HookDecision::AskRewrite(r) => (r, false), }; + // Splice the Claude Code session id into the executed command (best-effort) + // so `rtk` can scope session-only features. Absent/unsafe id => the command + // runs without it and those features no-op. Audit still logs the clean + // `rewritten` form, keeping the volatile id out of the audit trail. + let executed = match v.get("session_id").and_then(|s| s.as_str()) { + Some(sid) => inject_session(&rewritten, sid), + None => rewritten.clone(), + }; + let updated_input = { let mut ti = v.get("tool_input").cloned().unwrap_or_else(|| json!({})); if let Some(obj) = ti.as_object_mut() { - obj.insert("command".into(), Value::String(rewritten.clone())); + obj.insert("command".into(), Value::String(executed)); } ti }; @@ -429,6 +491,39 @@ pub fn run_claude() -> Result<()> { Ok(()) } +/// Reset a session's dedup ledger from a PostCompact payload. Returns the +/// number of ledger rows cleared. No `session_id` ⇒ no-op. +fn compact_reset(v: &Value, tracker: &crate::core::tracking::Tracker) -> usize { + match v + .get("session_id") + .and_then(|s| s.as_str()) + .filter(|s| !s.is_empty()) + { + Some(sid) => tracker.dedup_reset_session(sid).unwrap_or(0), + None => 0, + } +} + +/// Run the Claude Code PostCompact hook: clear this session's dedup ledger so +/// output suppression never spans a context reduction (after compaction the +/// earlier full emission may be gone, so the next read must re-emit it). +/// Best-effort and silent — PostCompact is informational and always exits 0. +pub fn run_compact() -> Result<()> { + let input = read_stdin_limited()?; + let input = input.trim(); + if input.is_empty() { + return Ok(()); + } + let v: Value = match serde_json::from_str(input) { + Ok(v) => v, + Err(_) => return Ok(()), // malformed payload -> no-op + }; + if let Ok(tracker) = crate::core::tracking::Tracker::new() { + let _ = compact_reset(&v, &tracker); + } + Ok(()) +} + #[cfg(test)] fn run_claude_inner(input: &str) -> Option { let v: Value = serde_json::from_str(input).ok()?; @@ -668,6 +763,97 @@ mod tests { crate::discover::registry::rewrite_command(cmd, excluded, &[]) } + // --- Session id injection (Phase 2) --- + + #[test] + fn session_id_safety() { + assert!(session_id_is_safe("abc-123_DEF")); + assert!(session_id_is_safe("a1b2c3d4-5e6f-7890-abcd-ef1234567890")); + assert!(!session_id_is_safe("")); // empty + assert!(!session_id_is_safe("has space")); + assert!(!session_id_is_safe("evil;rm -rf")); // shell metachar + assert!(!session_id_is_safe("$(whoami)")); + } + + #[test] + fn inject_into_standalone_command() { + assert_eq!( + inject_session("rtk git status", "sid1"), + "rtk --session sid1 git status" + ); + } + + #[test] + fn inject_into_bare_rtk() { + assert_eq!(inject_session("rtk", "sid1"), "rtk --session sid1"); + } + + #[test] + fn inject_finds_first_rtk_after_non_rtk_segment() { + // `cd foo && rtk ls` -> flag lands on the rtk segment. + assert_eq!( + inject_session("cd foo && rtk ls -la", "sid1"), + "cd foo && rtk --session sid1 ls -la" + ); + } + + #[test] + fn inject_first_only_in_compound() { + // Only the first rtk gets the flag; the second safely runs session-less. + assert_eq!( + inject_session("rtk git status && rtk ls", "sid1"), + "rtk --session sid1 git status && rtk ls" + ); + } + + #[test] + fn inject_skips_rtk_substring_in_quotes() { + // `rtk` inside a quoted arg is not a command word; the real rtk is found. + assert_eq!( + inject_session("echo \"rtk\" && rtk ls", "sid1"), + "echo \"rtk\" && rtk --session sid1 ls" + ); + } + + #[test] + fn inject_ignores_rtkfoo_word() { + // `rtkfoo` is not the `rtk` command (right boundary fails); unchanged. + assert_eq!(inject_session("rtkfoo bar", "sid1"), "rtkfoo bar"); + } + + #[test] + fn inject_noop_on_unsafe_id() { + assert_eq!(inject_session("rtk git status", "bad id"), "rtk git status"); + } + + #[test] + fn inject_noop_when_no_rtk_command() { + assert_eq!(inject_session("git status", "sid1"), "git status"); + } + + // --- PostCompact ledger reset (Phase 8b) --- + + #[test] + fn compact_reset_clears_only_the_named_session() { + let t = crate::core::tracking::Tracker::new_in_memory().expect("tracker"); + t.dedup_insert("s1", "h1", "id", 100, 5).expect("insert"); + t.dedup_insert("s2", "h2", "id", 100, 5).expect("insert"); + let payload = json!({ "session_id": "s1", "hook_event_name": "PostCompact" }); + assert_eq!(compact_reset(&payload, &t), 1); + assert!(t.dedup_lookup("s1", "h1").expect("lookup").is_none()); + // Other sessions are untouched. + assert!(t.dedup_lookup("s2", "h2").expect("lookup").is_some()); + } + + #[test] + fn compact_reset_noop_without_session_id() { + let t = crate::core::tracking::Tracker::new_in_memory().expect("tracker"); + t.dedup_insert("s1", "h1", "id", 100, 5).expect("insert"); + let payload = json!({ "hook_event_name": "PostCompact" }); + assert_eq!(compact_reset(&payload, &t), 0); + assert!(t.dedup_lookup("s1", "h1").expect("lookup").is_some()); + } + // --- Copilot format detection --- fn vscode_input(tool: &str, cmd: &str) -> Value { diff --git a/src/hooks/init.rs b/src/hooks/init.rs index c02dc1e116..6395e1ee57 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -13,12 +13,13 @@ use crate::hooks::constants::{ }; use super::constants::{ - BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, DROID_DIR, - DROID_EXECUTE_MATCHER, DROID_HOME_ENV, DROID_HOOKS_FILE, DROID_HOOKS_SUBDIR, - DROID_HOOK_COMMAND, DROID_SETTINGS_FILE, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, - HERMES_PLUGIN_INIT_FILE, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, - HOOKS_SUBDIR, PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_LOCAL_DIR, - PI_PLUGIN_FILE, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, + BEFORE_TOOL_KEY, CLAUDE_COMPACT_HOOK_COMMAND, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, + CURSOR_HOOK_COMMAND, DROID_DIR, DROID_EXECUTE_MATCHER, DROID_HOME_ENV, DROID_HOOKS_FILE, + DROID_HOOKS_SUBDIR, DROID_HOOK_COMMAND, DROID_SETTINGS_FILE, GEMINI_HOOK_FILE, HERMES_DIR, + HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, HERMES_PLUGIN_MANIFEST_FILE, + HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, PI_CODING_AGENT_DIR_ENV, PI_DIR, + PI_EXTENSIONS_SUBDIR, PI_LOCAL_DIR, PI_PLUGIN_FILE, POST_COMPACT_KEY, PRE_TOOL_USE_KEY, + REWRITE_HOOK_FILE, SETTINGS_JSON, }; use super::integrity; use super::is_claude_hook_command; @@ -517,56 +518,113 @@ fn prompt_telemetry_consent() -> Result<()> { Ok(()) } -fn print_manual_instructions(hook_command: &str, include_opencode: bool) { +fn print_manual_instructions( + hook_command: &str, + compact_command: &str, + pre_present: bool, + post_present: bool, + include_opencode: bool, +) { let settings_path = resolve_claude_dir() .unwrap_or_else(|_| PathBuf::from(format!("~/{}", CLAUDE_DIR))) .join(SETTINGS_JSON); - println!("\n MANUAL STEP: Add this to {}:", settings_path.display()); - println!(" {{"); - println!(" \"hooks\": {{ \"PreToolUse\": [{{"); - println!(" \"matcher\": \"Bash\","); - println!(" \"hooks\": [{{ \"type\": \"command\","); - println!(" \"command\": \"{}\"", hook_command); - println!(" }}]"); - println!(" }}]}}"); - println!(" }}"); - if include_opencode { - println!("\n Then restart Claude Code and OpenCode. Test with: git status\n"); + if pre_present { + println!( + "\n MANUAL STEP: RTK's PostCompact hook (dedup ledger reset) is missing.\n Merge this into {}:", + settings_path.display() + ); } else { - println!("\n Then restart Claude Code. Test with: git status\n"); + println!( + "\n MANUAL STEP: Merge this into {}:", + settings_path.display() + ); } -} - -fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { - let hooks = match root - .get_mut("hooks") - .and_then(|h| h.get_mut(PRE_TOOL_USE_KEY)) + for line in + manual_instructions_snippet(hook_command, compact_command, pre_present, post_present) + .lines() { - Some(pre_tool_use) => pre_tool_use, - None => return false, + println!(" {}", line); + } + let restart = if include_opencode { + "Claude Code and OpenCode" + } else { + "Claude Code" }; + if pre_present { + // Only the PostCompact hook is missing — `git status` exercises nothing new. + println!("\n Then restart {}.\n", restart); + } else { + println!("\n Then restart {}. Test with: git status\n", restart); + } +} - let pre_tool_use_array = match hooks.as_array_mut() { - Some(arr) => arr, - None => return false, +/// JSON snippet covering exactly the RTK hooks missing from settings.json, so +/// a user following the manual step never silently drops the PostCompact hook. +fn manual_instructions_snippet( + hook_command: &str, + compact_command: &str, + pre_present: bool, + post_present: bool, +) -> String { + let mut hooks = serde_json::Map::new(); + if !pre_present { + hooks.insert( + "PreToolUse".to_string(), + serde_json::json!([{ + "matcher": "Bash", + "hooks": [{ "type": "command", "command": hook_command }] + }]), + ); + } + if !post_present { + hooks.insert( + POST_COMPACT_KEY.to_string(), + serde_json::json!([{ + "hooks": [{ "type": "command", "command": compact_command }] + }]), + ); + } + serde_json::to_string_pretty(&serde_json::json!({ "hooks": hooks })) + .expect("JSON snippet built from string keys and literals cannot fail to serialize") +} + +/// True if a settings.json hook entry carries a command matching `pred`. +fn entry_command_matches(entry: &serde_json::Value, pred: impl Fn(&str) -> bool) -> bool { + entry + .get("hooks") + .and_then(|h| h.as_array()) + .is_some_and(|arr| { + arr.iter() + .filter_map(|h| h.get("command").and_then(|c| c.as_str())) + .any(&pred) + }) +} + +/// Retain-out RTK entries from a named hook array; returns true if any removed. +fn strip_rtk_entries(root: &mut serde_json::Value, key: &str, pred: impl Fn(&str) -> bool) -> bool { + let Some(arr) = root + .get_mut("hooks") + .and_then(|h| h.get_mut(key)) + .and_then(|p| p.as_array_mut()) + else { + return false; }; + let before = arr.len(); + arr.retain(|entry| !entry_command_matches(entry, &pred)); + arr.len() < before +} - let original_len = pre_tool_use_array.len(); - pre_tool_use_array.retain(|entry| { - if let Some(hooks_array) = entry.get("hooks").and_then(|h| h.as_array()) { - for hook in hooks_array { - if let Some(command) = hook.get("command").and_then(|c| c.as_str()) { - // Match both legacy script path and new binary command - if command.contains(REWRITE_HOOK_FILE) || is_claude_hook_command(command) { - return false; - } - } - } - } - true +fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { + // PreToolUse: legacy script path or the native claude command + // (is_claude_hook_command matches both bare and absolute-path forms). + let pre = strip_rtk_entries(root, PRE_TOOL_USE_KEY, |cmd| { + cmd.contains(REWRITE_HOOK_FILE) || is_claude_hook_command(cmd) }); - - pre_tool_use_array.len() < original_len + // PostCompact: the dedup-reset command. + let post = strip_rtk_entries(root, POST_COMPACT_KEY, |cmd| { + cmd == CLAUDE_COMPACT_HOOK_COMMAND || cmd.ends_with("hook compact") + }); + pre || post } /// Remove RTK hook from settings.json file @@ -966,10 +1024,22 @@ fn patch_settings_json_command( serde_json::json!({}) }; - // Check idempotency - if hook_already_present(&root, hook_command) { + // The PostCompact hook (dedup ledger reset) shares the binary invocation, + // swapping the subcommand. Derived from hook_command so a path-prefixed + // command keeps its prefix; falls back to the constant. + let compact_command = hook_command + .strip_suffix("claude") + .map(|p| format!("{p}compact")) + .unwrap_or_else(|| CLAUDE_COMPACT_HOOK_COMMAND.to_string()); + + // Check idempotency for BOTH hooks. Existing users (installed before the + // PostCompact hook existed) have PreToolUse but not PostCompact — re-running + // init should add the missing one rather than early-return. + let pre_present = hook_already_present(&root, hook_command); + let post_present = postcompact_already_present(&root, &compact_command); + if pre_present && post_present { if verbose > 0 { - eprintln!("settings.json: hook already present"); + eprintln!("settings.json: hooks already present"); } return Ok(PatchResult::AlreadyPresent); } @@ -977,7 +1047,13 @@ fn patch_settings_json_command( // Handle mode match mode { PatchMode::Skip => { - print_manual_instructions(hook_command, include_opencode); + print_manual_instructions( + hook_command, + &compact_command, + pre_present, + post_present, + include_opencode, + ); return Ok(PatchResult::Skipped); } PatchMode::Ask => { @@ -988,7 +1064,13 @@ fn patch_settings_json_command( settings_path.display() ); } else if !prompt_user_consent(&settings_path)? { - print_manual_instructions(hook_command, include_opencode); + print_manual_instructions( + hook_command, + &compact_command, + pre_present, + post_present, + include_opencode, + ); return Ok(PatchResult::Declined); } } @@ -997,7 +1079,12 @@ fn patch_settings_json_command( } } - insert_hook_entry(&mut root, hook_command)?; + if !pre_present { + insert_hook_entry(&mut root, hook_command)?; + } + if !post_present { + insert_postcompact_entry(&mut root, &compact_command)?; + } let serialized = serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?; @@ -1105,6 +1192,53 @@ fn insert_hook_entry(root: &mut serde_json::Value, hook_command: &str) -> Result Ok(()) } +/// Deep-merge the PostCompact dedup-reset hook into settings.json. +/// No matcher, so it fires for both `auto` and `manual` compaction. +fn insert_postcompact_entry(root: &mut serde_json::Value, compact_command: &str) -> Result<()> { + let root_obj = root + .as_object_mut() + .context("settings root is not an object")?; + + let hooks = root_obj + .entry("hooks") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .context("hooks value is not an object")?; + + let post_compact = hooks + .entry(POST_COMPACT_KEY) + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .context("PostCompact value is not an array")?; + + post_compact.push(serde_json::json!({ + "hooks": [{ + "type": "command", + "command": compact_command + }] + })); + Ok(()) +} + +/// Whether the RTK PostCompact hook is already registered in settings.json. +fn postcompact_already_present(root: &serde_json::Value, compact_command: &str) -> bool { + let post_compact_array = match root + .get("hooks") + .and_then(|h| h.get(POST_COMPACT_KEY)) + .and_then(|p| p.as_array()) + { + Some(arr) => arr, + None => return false, + }; + + post_compact_array + .iter() + .filter_map(|entry| entry.get("hooks")?.as_array()) + .flatten() + .filter_map(|hook| hook.get("command")?.as_str()) + .any(|cmd| cmd == compact_command || cmd == CLAUDE_COMPACT_HOOK_COMMAND) +} + /// Check if RTK hook is already present in settings.json /// Matches on legacy rtk-rewrite.sh path OR new `rtk hook claude` command fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool { @@ -6195,6 +6329,148 @@ mod tests { assert_eq!(command, hook_command); } + // Tests for PostCompact dedup-reset hook (Phase 8b) + #[test] + fn test_insert_postcompact_entry() { + let mut root = serde_json::json!({}); + insert_postcompact_entry(&mut root, CLAUDE_COMPACT_HOOK_COMMAND).unwrap(); + let arr = root["hooks"]["PostCompact"].as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert!(arr[0].get("matcher").is_none()); // no matcher -> auto + manual + assert_eq!( + arr[0]["hooks"][0]["command"].as_str().unwrap(), + CLAUDE_COMPACT_HOOK_COMMAND + ); + } + + #[test] + fn test_postcompact_already_present() { + let mut root = serde_json::json!({}); + assert!(!postcompact_already_present( + &root, + CLAUDE_COMPACT_HOOK_COMMAND + )); + insert_postcompact_entry(&mut root, CLAUDE_COMPACT_HOOK_COMMAND).unwrap(); + assert!(postcompact_already_present( + &root, + CLAUDE_COMPACT_HOOK_COMMAND + )); + } + + #[test] + fn test_manual_snippet_both_missing_includes_both_hooks() { + let snippet = manual_instructions_snippet( + CLAUDE_HOOK_COMMAND, + CLAUDE_COMPACT_HOOK_COMMAND, + false, + false, + ); + let parsed: serde_json::Value = + serde_json::from_str(&snippet).expect("snippet must be valid JSON"); + assert_eq!( + parsed["hooks"]["PreToolUse"][0]["matcher"] + .as_str() + .unwrap(), + "Bash" + ); + assert_eq!( + parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + .as_str() + .unwrap(), + CLAUDE_HOOK_COMMAND + ); + assert_eq!( + parsed["hooks"]["PostCompact"][0]["hooks"][0]["command"] + .as_str() + .unwrap(), + CLAUDE_COMPACT_HOOK_COMMAND + ); + // PostCompact entries carry no matcher. + assert!(parsed["hooks"]["PostCompact"][0].get("matcher").is_none()); + } + + #[test] + fn test_manual_snippet_only_postcompact_missing() { + let snippet = manual_instructions_snippet( + CLAUDE_HOOK_COMMAND, + CLAUDE_COMPACT_HOOK_COMMAND, + true, + false, + ); + let parsed: serde_json::Value = + serde_json::from_str(&snippet).expect("snippet must be valid JSON"); + assert!(parsed["hooks"].get("PreToolUse").is_none()); + assert_eq!( + parsed["hooks"]["PostCompact"][0]["hooks"][0]["command"] + .as_str() + .unwrap(), + CLAUDE_COMPACT_HOOK_COMMAND + ); + } + + #[test] + fn test_manual_snippet_only_pretooluse_missing() { + let snippet = manual_instructions_snippet( + CLAUDE_HOOK_COMMAND, + CLAUDE_COMPACT_HOOK_COMMAND, + false, + true, + ); + let parsed: serde_json::Value = + serde_json::from_str(&snippet).expect("snippet must be valid JSON"); + assert!(parsed["hooks"].get("PostCompact").is_none()); + assert_eq!( + parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + .as_str() + .unwrap(), + CLAUDE_HOOK_COMMAND + ); + } + + #[test] + fn test_manual_snippet_preserves_path_prefixed_compact_command() { + // A path-prefixed hook command must keep its prefix on the compact hook. + let snippet = manual_instructions_snippet( + "/usr/local/bin/rtk hook claude", + "/usr/local/bin/rtk hook compact", + false, + false, + ); + let parsed: serde_json::Value = + serde_json::from_str(&snippet).expect("snippet must be valid JSON"); + assert_eq!( + parsed["hooks"]["PostCompact"][0]["hooks"][0]["command"] + .as_str() + .unwrap(), + "/usr/local/bin/rtk hook compact" + ); + } + + #[test] + fn test_remove_hook_from_json_strips_postcompact_preserving_others() { + let mut root = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ "type": "command", "command": CLAUDE_HOOK_COMMAND }] + }], + "PostCompact": [ + { "hooks": [{ "type": "command", "command": "/other/compact-hook.sh" }] }, + { "hooks": [{ "type": "command", "command": CLAUDE_COMPACT_HOOK_COMMAND }] } + ] + } + }); + assert!(remove_hook_from_json(&mut root)); + assert!(root["hooks"]["PreToolUse"].as_array().unwrap().is_empty()); + // The RTK PostCompact entry is gone; the unrelated one is preserved. + let post = root["hooks"]["PostCompact"].as_array().unwrap(); + assert_eq!(post.len(), 1); + assert_eq!( + post[0]["hooks"][0]["command"].as_str().unwrap(), + "/other/compact-hook.sh" + ); + } + #[test] fn test_insert_hook_entry_preserves_existing() { let mut json_content = serde_json::json!({ diff --git a/src/main.rs b/src/main.rs index 1c85bb55f9..49e0435954 100644 --- a/src/main.rs +++ b/src/main.rs @@ -76,6 +76,11 @@ struct Cli { /// Set SKIP_ENV_VALIDATION=1 for child processes (Next.js, tsc, lint, prisma) #[arg(long = "skip-env", global = true)] skip_env: bool, + + /// Claude Code session id (injected by the hook) — scopes session-only + /// features like output dedup. Absent for manual invocations. + #[arg(long = "session", global = true)] + session: Option, } #[derive(Debug, Subcommand)] @@ -857,6 +862,8 @@ enum HookCommands { Copilot, /// Process Factory Droid PreToolUse hook (reads JSON from stdin) Droid, + /// Process Claude Code PostCompact hook — reset the session dedup ledger (reads JSON from stdin) + Compact, /// Check how a command would be rewritten by the hook engine (dry-run) Check { /// Target agent @@ -1246,14 +1253,87 @@ enum GoCommands { Other(Vec), } +#[derive(Debug, PartialEq, Eq)] +struct FallbackArgs { + command: Vec, + session: Option, + skip_env: bool, +} + +/// Remove RTK-owned leading global flags before executing a command through the +/// generic passthrough. Clap can reject a native flag that overlaps with an RTK +/// subcommand flag (for example, native `grep -l`). In that case the fallback +/// must execute the native command, not an injected RTK global such as +/// `--session`. +fn normalize_fallback_args(raw_args: Vec) -> FallbackArgs { + let mut index = 0; + let mut session = None; + let mut skip_env = false; + + while index < raw_args.len() { + match raw_args[index].as_str() { + "--session" => { + let Some(value) = raw_args.get(index + 1) else { + index = raw_args.len(); + break; + }; + if value.starts_with('-') { + index = raw_args.len(); + break; + } + session = Some(value.clone()); + index += 2; + } + arg if arg.starts_with("--session=") => { + session = Some(arg["--session=".len()..].to_string()); + index += 1; + } + "--ultra-compact" => { + index += 1; + } + "--skip-env" => { + skip_env = true; + index += 1; + } + "--verbose" => { + index += 1; + } + arg if arg + .strip_prefix('-') + .is_some_and(|value| !value.is_empty() && value.chars().all(|ch| ch == 'v')) => + { + index += 1; + } + _ => break, + } + } + + FallbackArgs { + command: raw_args[index..].to_vec(), + session, + skip_env, + } +} + fn run_fallback(parse_error: clap::Error) -> Result { - let args: Vec = std::env::args().skip(1).collect(); + let raw_args: Vec = std::env::args().skip(1).collect(); // No args → show Clap's error (user ran just "rtk" with bad syntax) + if raw_args.is_empty() { + parse_error.exit(); + } + + let fallback_args = normalize_fallback_args(raw_args); + let args = fallback_args.command; + + // A malformed global flag or globals without a command are still RTK + // syntax errors and must not be treated as native executables. if args.is_empty() { parse_error.exit(); } + core::session::init(fallback_args.session.clone()); + // RTK meta-commands should never fall back to raw execution. // e.g. `rtk gain --badtypo` should show Clap's error, not try to run `gain` from $PATH. if core::constants::RTK_META_COMMANDS.contains(&args[0].as_str()) { @@ -1265,6 +1345,14 @@ fn run_fallback(parse_error: clap::Error) -> Result { // Start timer before execution to capture actual command runtime let timer = core::tracking::TimedExecution::start(); + let build_command = || { + let mut command = core::utils::resolved_command(&args[0]); + command.args(&args[1..]); + if fallback_args.skip_env { + command.env("SKIP_ENV_VALIDATION", "1"); + } + command + }; // TOML filter lookup — bypass with RTK_NO_TOML=1 // Use basename of args[0] so absolute paths (/usr/bin/make) still match "^make\b". @@ -1288,15 +1376,13 @@ fn run_fallback(parse_error: clap::Error) -> Result { // TOML match: capture stdout for filtering let result = if filter.filter_stderr { // Merge stderr into stdout so the filter can strip banners emitted by tools like liquibase - core::utils::resolved_command(&args[0]) - .args(&args[1..]) + build_command() .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) // captured for merging .output() } else { - core::utils::resolved_command(&args[0]) - .args(&args[1..]) + build_command() .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::piped()) // capture .stderr(std::process::Stdio::inherit()) // stderr always direct @@ -1364,8 +1450,7 @@ fn run_fallback(parse_error: clap::Error) -> Result { } } else { // No TOML match: original passthrough behaviour (Stdio::inherit, streaming) - let status = core::utils::resolved_command(&args[0]) - .args(&args[1..]) + let status = build_command() .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) @@ -1553,6 +1638,11 @@ fn run_cli() -> Result { } }; + // Populate the process-global session context (from --session or + // RTK_SESSION_ID) before any command runs, so session-scoped features + // (output dedup) can key on it. Absent for manual invocations. + core::session::init(cli.session.clone()); + // Warn if installed hook is outdated/missing (1/day, non-blocking). // Skip for Gain — it shows its own inline hook warning. if !matches!(cli.command, Commands::Gain { .. }) { @@ -2392,6 +2482,10 @@ fn run_cli() -> Result { hooks::hook_cmd::run_droid()?; 0 } + HookCommands::Compact => { + hooks::hook_cmd::run_compact()?; + 0 + } HookCommands::Check { agent: _, command } => { use crate::discover::registry::rewrite_command; let raw = command.join(" "); @@ -2729,6 +2823,41 @@ mod tests { use clap::Parser; use std::cell::Cell; + #[test] + fn test_fallback_args_strip_leading_rtk_globals() { + let normalized = normalize_fallback_args(vec![ + "--session".into(), + "sid-123".into(), + "--ultra-compact".into(), + "--skip-env".into(), + "-vv".into(), + "grep".into(), + "-l".into(), + "-i".into(), + "win32com|pythoncom".into(), + "gate.py".into(), + ]); + + assert_eq!(normalized.session.as_deref(), Some("sid-123")); + assert!(normalized.skip_env); + assert_eq!( + normalized.command, + vec!["grep", "-l", "-i", "win32com|pythoncom", "gate.py"] + ); + + let equals_form = normalize_fallback_args(vec![ + "--session=sid-456".into(), + "grep".into(), + "--session".into(), + "native-value".into(), + ]); + assert_eq!(equals_form.session.as_deref(), Some("sid-456")); + assert_eq!( + equals_form.command, + vec!["grep", "--session", "native-value"] + ); + } + #[test] fn test_git_commit_single_message() { let cli = Cli::try_parse_from(["rtk", "git", "commit", "-m", "fix: typo"]).unwrap(); diff --git a/tests/dedup_integration_test.rs b/tests/dedup_integration_test.rs new file mode 100644 index 0000000000..1482ac37aa --- /dev/null +++ b/tests/dedup_integration_test.rs @@ -0,0 +1,150 @@ +//! End-to-end proof of session-level output dedup (src/core/dedup.rs). +//! +//! Drives the real binary over the stdin read path with `RTK_DEDUP=1` and an +//! isolated `RTK_DB_PATH`, exercising the full chain: session resolution -> +//! config/env gate -> ledger lookup/insert/bump -> stub emission. + +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +/// A body large enough to clear the 200-token min gate (~4 chars/token). +fn big_input() -> String { + (0..80) + .map(|i| format!("line number {i} with some representative content here\n")) + .collect() +} + +fn fresh_db(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!("rtk_dedup_it_{name}.db")); + for suffix in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{suffix}", path.display())); + } + path +} + +/// Run `rtk read -` with dedup forced on and an isolated DB. `session` is the +/// optional session id. Returns stdout. +fn rtk_read(db: &PathBuf, session: Option<&str>, input: &str) -> String { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_rtk")); + cmd.args(["read", "-"]) + .env("RTK_DEDUP", "1") + .env("RTK_DB_PATH", db) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + if let Some(s) = session { + cmd.env("RTK_SESSION_ID", s); + } else { + cmd.env_remove("RTK_SESSION_ID"); + } + let mut child = cmd.spawn().expect("spawn rtk"); + child + .stdin + .take() + .expect("stdin") + .write_all(input.as_bytes()) + .expect("write stdin"); + let out = child.wait_with_output().expect("wait rtk"); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +#[test] +fn suppresses_identical_reread_within_session() { + let db = fresh_db("within_session"); + let input = big_input(); + + let first = rtk_read(&db, Some("sess-A"), &input); + assert!( + !first.contains("rtk-dedup:"), + "first emission must be full, got stub: {first}" + ); + assert!( + first.contains("line number 79"), + "first should show content" + ); + + let second = rtk_read(&db, Some("sess-A"), &input); + assert!( + second.contains("rtk-dedup:"), + "identical re-read must be suppressed to a stub, got: {second}" + ); + assert!( + second.contains("force full: rtk proxy"), + "stub must carry the recovery command" + ); +} + +#[test] +fn no_session_is_never_suppressed() { + let db = fresh_db("no_session"); + let input = big_input(); + + // RTK_DEDUP=1 but no session id -> always full, even on repeat. + let first = rtk_read(&db, None, &input); + let second = rtk_read(&db, None, &input); + assert!(!first.contains("rtk-dedup:")); + assert!( + !second.contains("rtk-dedup:"), + "no session id must disable suppression entirely" + ); +} + +/// Fire the PostCompact hook (`rtk hook compact`) for a session. +fn rtk_compact(db: &PathBuf, session: &str) { + let payload = format!("{{\"session_id\":\"{session}\",\"hook_event_name\":\"PostCompact\"}}"); + let mut child = Command::new(env!("CARGO_BIN_EXE_rtk")) + .args(["hook", "compact"]) + .env("RTK_DB_PATH", db) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn rtk"); + child + .stdin + .take() + .expect("stdin") + .write_all(payload.as_bytes()) + .expect("write stdin"); + assert!(child.wait().expect("wait rtk").success()); +} + +#[test] +fn postcompact_reset_reemits_full() { + let db = fresh_db("compact_reset"); + let input = big_input(); + + rtk_read(&db, Some("sess-C"), &input); // full (records) + let suppressed = rtk_read(&db, Some("sess-C"), &input); + assert!( + suppressed.contains("rtk-dedup:"), + "second read should suppress" + ); + + // Compaction clears the session ledger... + rtk_compact(&db, "sess-C"); + + // ...so the next read re-emits full (the earlier output may be gone from context). + let after = rtk_read(&db, Some("sess-C"), &input); + assert!( + !after.contains("rtk-dedup:"), + "read after PostCompact reset must re-emit full, got a stub: {after}" + ); +} + +#[test] +fn sessions_are_isolated() { + let db = fresh_db("isolated"); + let input = big_input(); + + let a = rtk_read(&db, Some("sess-X"), &input); + // Same content, different session: its context never saw the first + // emission, so it must not be suppressed. + let b = rtk_read(&db, Some("sess-Y"), &input); + assert!(!a.contains("rtk-dedup:")); + assert!( + !b.contains("rtk-dedup:"), + "a different session must see full output, not a stub" + ); +}