Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/rules/search-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions docs/contributing/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,28 @@ Flow:

Single-threaded execution with `Mutex<Option<Tracker>>` 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 <id>` into the rewritten command (`hook injects` → `rtk --session <id> 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
Expand Down
17 changes: 17 additions & 0 deletions src/analytics/gain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 11 additions & 5 deletions src/cmds/system/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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(())
Expand Down
84 changes: 84 additions & 0 deletions src/core/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<Self> {
let path = get_config_path()?;
Expand Down Expand Up @@ -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#"
Expand Down
Loading