Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ All notable changes to this project are documented here. The format follows

### Changed

- **Statusline, cold-cache guard, and cheaper `/compact` are off by default.** `setup` /
`ensure` no longer first-install them. New machines get `/sub` (and routed subagents)
only. An already-owned status line or guard is still refreshed on upgrade; compact
model lists already in `config.toml` still apply. Opt in with `llmtrim statusline
install`, `llmtrim guard install`, or `llmtrim compact models haiku sonnet`.

- **Status watch SVGs show the 0.13 Sub tab.** The README hero
(`status-watch-dark.svg` / `status-watch-light.svg`) still rendered the
0.12 Always→Codex / kimi / grok presets. Tab 4 now matches CLIProxyAPI:
Expand Down
14 changes: 5 additions & 9 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,20 +100,16 @@ curl and the npm one-liner above run this for you. After Cargo, Homebrew, Scoop,
`LLMTRIM_NO_SETUP=1`, run it yourself:

```bash
llmtrim setup # CA + shell env + autostart + Claude Code integrations + daemon
llmtrim setup # CA + shell env + autostart + Claude Code /sub + daemon
llmtrim status # live savings dashboard
```

Open a new terminal so tools inherit `HTTPS_PROXY`. Re-running `setup` is safe (idempotent).

When Claude Code is present (`~/.claude`), `setup` also enables:

- status line
- cold-cache guard
- window-local `/sub`
- cheaper `/compact` model chain

No separate install steps for those. Later upgrades refresh them via `update` / `ensure`.
When Claude Code is present (`~/.claude`), `setup` also enables window-local `/sub`.
Status line, cold-cache guard, and cheaper `/compact` are not installed (deprecated;
opt in with `llmtrim statusline install`, `llmtrim guard install`, `llmtrim compact models …`).
Existing installs of those keep working; `ensure` refreshes an already-owned status line or guard.

llmtrim is a local MITM proxy (plus optional Claude Code hooks).
`llmtrim uninstall` reverses it. How traffic reaches tools: [README](README.md#what-it-does).
Expand Down
73 changes: 7 additions & 66 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@

<p align="center">
<sub>
Using <b>Claude Code</b>? One install also gets you a live <a href="#claude-code">status line</a>,
a <a href="#claude-code">cold-cache guard</a>, cheaper <a href="#claude-code"><code>/compact</code></a>,
and <a href="#claude-code"><code>/sub</code></a> to serve it through CLIProxyAPI.
Using <b>Claude Code</b>? One install also gets you <a href="#claude-code"><code>/sub</code></a>
to serve it through CLIProxyAPI.
</sub>
</p>

Expand Down Expand Up @@ -76,7 +75,7 @@ llmtrim sits on your machine as a local proxy, trims the waste, and forwards a s

Compression cannot raise your bill or break a request; worst case is zero savings. Everything runs locally, nothing is sent to us. [In action →](#in-action)

For Claude Code the same install goes further: a status line with live trim % and rate limits, a guard that warns before an expired prompt cache re-bills your whole context, `/compact` on a cheaper model, and `/sub` to route sessions through another subscription. [Details →](#claude-code)
For Claude Code the same install also wires `/sub` to route sessions through another subscription. [Details →](#claude-code)

---

Expand All @@ -88,7 +87,7 @@ npm install -g @llmtrim/cli@latest && llmtrim setup
llmtrim status
```

That's it. `setup` starts a local proxy, wires your shell, and enables recoverable tool-output shaping. When Claude Code is present, it also turns on the status line, cold-cache guard, `/sub`, and cheaper `/compact`. You do not run a separate install for each of those.
That's it. `setup` starts a local proxy, wires your shell, and enables recoverable tool-output shaping. When Claude Code is present, it also turns on `/sub`. You do not run a separate install for that.

| You want | Run |
|---|---|
Expand Down Expand Up @@ -165,7 +164,7 @@ llmtrim ensure # match the recommended install state on this machine
| Force one session through llmtrim | `llmtrim wrap claude` |
| Remove everything | `llmtrim uninstall` |

After `setup`, `update`, or `ensure`, owned Claude Code pieces (status line, guard, `/sub`, compact defaults) stay in sync with the binary. You should not need `statusline install` or similar after an upgrade.
After `setup`, `update`, or `ensure`, owned Claude Code `/sub` stays in sync with the binary.

Time series: `llmtrim status --daily` · `--weekly` · `--monthly` · `--json` · `--csv`.

Expand Down Expand Up @@ -242,69 +241,12 @@ Default `auto` enables each stage only where it pays. `safe` is lossless-only. [

## Claude Code

When `~/.claude` exists, `setup`, `update`, and `ensure` wire these. No separate install commands.
When `~/.claude` exists, `setup`, `update`, and `ensure` wire `/sub` (and routed subagents). No separate install command for that.

| Feature | What you get |
|---|---|
| Status line | Model, context gauge, trim %, rate limits, cache warm/cold |
| Guard | Blocks one turn if a cold-cache resume would rewrite a huge context (and bill for it) |
| `/compact` models | Prefer Haiku → Sonnet before your selected model, but only when the prompt cache is cold |
| `/sub` | Per-window: `/sub on [optional:codex\|kimi\|grok]` · `/sub off` · `/sub status` |

```text
◆ Opus→gpt-5.6-terra ▓▓▓▓▓░░░ 142k ✂ 6.8% ◔ 3h·24% · 4d·12% ♻ 63% cached
```

<details>
<summary><b>Status line details</b></summary>

Claude Code [custom status line](https://code.claude.com/docs/en/statusline). The arrow is the backend that answered the last turn (not merely what is configured). In `sub` fallback mode it stays off while Anthropic serves and shows up when a chain provider does.

- `✂`: trim for this session (`✂ –` until something is saved)
- `◔`: rate-limit windows (time left · % used) — Claude.ai when Anthropic is serving; under `/sub` the active plan's windows (Codex today: weekly always, 5h when the plan reports one). Kimi/Grok fill in when their usage APIs are wired
- Context gauge: fill of the serving model's real window (green under 40%, orange 40-65%, red above)
- `♻`: prompt-cache reuse; becomes `♻ cache cold` after the cache TTL

Owned settings rewrite themselves when the binary path or payload changes. To opt out, leave your own status line in place, or uninstall ours (`llmtrim statusline uninstall`).

</details>

<details>
<summary><b>Cold-cache guard</b></summary>

Resuming a large session after the prompt-cache TTL rewrites the whole context at cache-write rates (often a few dollars) with no warning at the prompt.

Guard is a free `UserPromptSubmit` hook. It blocks one turn, prints idle time, context size, estimated cost, and the draft you typed (Claude Code clears the input box and would otherwise drop it), then lets a resend through. If you type something else next, the blocked text is also reinjected as model context. `/compact` pays that cold write too, because it has to read the full context to summarize. Local-only slash commands that never hit the model (today: `/sub`) pass through without acking the gap, so the next real prompt still warns.

Opt out: `llmtrim guard uninstall`. `ensure` remembers that choice.

```text
Idle 6h 19m, 347k tokens of context. The prompt cache has expired, so the next turn
rewrites the whole context (about $3.47 before any work happens).
```

</details>

<details>
<summary><b>Cheaper `/compact`</b></summary>

```bash
llmtrim compact models haiku sonnet # setup already sets this by default
llmtrim compact status
llmtrim compact off
```

```toml
[compact]
models = ["haiku", "sonnet"]
```

Candidates run in order when they fit the compressed request. Claude's selected model is always the last fallback (do not put it in the list). Empty `models = []` records opt-out.

The redirect only fires once the prompt cache has gone cold. `/compact` re-sends the conversation Claude Code has been caching against your selected model, so while that cache is warm a cache-read there costs less than a cold read on a smaller model, and the compact stays home. After the cache expires the cheaper model wins, so that is when the redirect takes over.

</details>

<details>
<summary><b>Subscription reroute (`sub`)</b> (opt-in; may conflict with provider ToS)</summary>

Expand Down Expand Up @@ -540,8 +482,7 @@ These knobs are orthogonal to compression. Each resolves env-first, then from th

</details>

Claude Code options (compact models, subscription reroute) are under
[Claude Code](#claude-code).
Claude Code options (subscription reroute) are under [Claude Code](#claude-code).

<details>
<summary><b>Upstream proxy</b> (corporate egress or chaining local tools)</summary>
Expand Down
1 change: 1 addition & 0 deletions crates/llmtrim-cli/src/compact.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Claude Code compaction request detection and ordered model planning.
//!
//! Opt-in via `[compact].models` / `llmtrim compact models` — `setup` does not write defaults.
//! Claude Code handles `/compact` locally and sends a normal Anthropic Messages request. The
//! internal summarization prompt is the protocol marker; ordinary user text mentioning `/compact`
//! must never trigger model substitution.
Expand Down
130 changes: 48 additions & 82 deletions crates/llmtrim-cli/src/ensure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
//!
//! Happy path: `llmtrim setup` / `llmtrim update` / `llmtrim doctor --fix` / status `f` all
//! land here. Power-user install/uninstall commands remain as escape hatches; humans should not
//! need them after a release. Owned Claude Code hooks and the status line are rewritten in place
//! when the binary path or feature payload changes so changelogs never say "re-run install".
//! need them after a release. Owned `/sub` hooks (and a previously installed status line or
//! guard) are rewritten in place when the binary path or feature payload changes so changelogs
//! never say "re-run install". Statusline, guard, and cheaper `/compact` are deprecated: new
//! installs do not wire them.

use std::path::{Path, PathBuf};

Expand Down Expand Up @@ -124,7 +126,7 @@ fn save_at(path: &Path, state: &State) -> Result<()> {
/// How ensure was invoked — controls prompting and how loud the report is.
#[derive(Debug, Clone, Copy)]
pub struct Options {
/// May prompt on a TTY (first-run compact/tray choices). Non-interactive defaults to yes
/// May prompt on a TTY (first-run tray choice). Non-interactive defaults to yes
/// for recommended items, no for optional network downloads.
pub interactive: bool,
/// Skip the success panel (daemon start / quiet migrations).
Expand All @@ -134,7 +136,8 @@ pub struct Options {
/// Allow downloading the Linux tray binary when missing (interactive confirm, or forced).
pub download_tray: bool,
/// When false (quiet auto-heal), only refresh *owned* stale integrations and daemon skew —
/// never first-install statusline/guard//sub/compact or enable tray autostart.
/// never first-install `/sub` / subagents or enable tray autostart. Statusline, guard, and
/// compact are never first-installed even when this is true (deprecated).
pub install_missing: bool,
}

Expand Down Expand Up @@ -185,36 +188,33 @@ fn probe_with(state: &State) -> Vec<Gap> {
let mut gaps = Vec::new();
let claude = crate::statusline::claude_code_present();

if claude && !state.opt_out.statusline {
match crate::statusline::owned_status() {
crate::statusline::OwnedStatus::Missing => gaps.push(Gap {
id: "statusline",
label: "Statusline".into(),
detail: "not installed — recommended for Claude Code".into(),
}),
crate::statusline::OwnedStatus::Stale => gaps.push(Gap {
id: "statusline",
label: "Statusline".into(),
detail: "stale (binary path or refresh settings)".into(),
}),
crate::statusline::OwnedStatus::Current | crate::statusline::OwnedStatus::Foreign => {}
}
if claude
&& !state.opt_out.statusline
&& matches!(
crate::statusline::owned_status(),
crate::statusline::OwnedStatus::Stale
)
{
// Deprecated: missing is not a gap. Refresh only an already-owned stale line.
gaps.push(Gap {
id: "statusline",
label: "Statusline".into(),
detail: "stale (binary path or refresh settings)".into(),
});
}

if claude && !state.opt_out.guard {
match crate::guard::owned_status() {
crate::guard::OwnedStatus::Missing => gaps.push(Gap {
id: "guard",
label: "Guard".into(),
detail: "not installed — warns before cold-cache resumes".into(),
}),
crate::guard::OwnedStatus::Stale => gaps.push(Gap {
id: "guard",
label: "Guard".into(),
detail: "stale (binary path)".into(),
}),
crate::guard::OwnedStatus::Current => {}
}
if claude
&& !state.opt_out.guard
&& matches!(
crate::guard::owned_status(),
crate::guard::OwnedStatus::Stale
)
{
gaps.push(Gap {
id: "guard",
label: "Guard".into(),
detail: "stale (binary path)".into(),
});
}

if claude && !state.opt_out.window_sub {
Expand Down Expand Up @@ -250,14 +250,6 @@ fn probe_with(state: &State) -> Vec<Gap> {
}
}

if claude && !state.opt_out.compact && !llmtrim_core::config::compact_models_configured() {
gaps.push(Gap {
id: "compact",
label: "Compact".into(),
detail: "cheaper /compact models not configured".into(),
});
}

// Version skew: daemon older than this binary.
if let Some(d) = crate::daemon::running()
&& let Some(v) = d.version.as_deref()
Expand Down Expand Up @@ -316,8 +308,8 @@ pub fn apply(opts: Options) -> Result<Report> {

if claude && !state.opt_out.statusline {
let status = crate::statusline::owned_status();
let skip_missing =
!opts.install_missing && matches!(status, crate::statusline::OwnedStatus::Missing);
// Deprecated: never first-install. Refresh an already-owned (or foreign-skip) line.
let skip_missing = matches!(status, crate::statusline::OwnedStatus::Missing);
if !skip_missing {
match crate::statusline::sync_owned() {
Ok(crate::statusline::SyncOutcome::Installed) => {
Expand Down Expand Up @@ -363,8 +355,7 @@ pub fn apply(opts: Options) -> Result<Report> {

if claude && !state.opt_out.guard {
let gstatus = crate::guard::owned_status();
let skip_missing =
!opts.install_missing && matches!(gstatus, crate::guard::OwnedStatus::Missing);
let skip_missing = matches!(gstatus, crate::guard::OwnedStatus::Missing);
if !skip_missing {
match crate::guard::sync_owned() {
Ok(true) => {
Expand Down Expand Up @@ -469,43 +460,8 @@ pub fn apply(opts: Options) -> Result<Report> {
.push((ui::NOTE, "Subagents".into(), "opted out".into()));
}

if claude
&& opts.install_missing
&& !state.opt_out.compact
&& !llmtrim_core::config::compact_models_configured()
{
let want = if opts.interactive {
confirm_default_yes("Use cheaper models for Claude Code /compact (Haiku → Sonnet)?")
} else {
true
};
if want {
match llmtrim_core::config::write_compact_models(&["haiku".into(), "sonnet".into()]) {
Ok(()) => {
report.applied.push("compact");
report.rows.push((
ui::OK,
"Compact".into(),
"Haiku → Sonnet → original model".into(),
));
}
Err(e) => {
report
.rows
.push((ui::WARN, "Compact".into(), format!("not configured: {e:#}")))
}
}
} else {
// Remember opt-out as empty models list + flag.
let _ = llmtrim_core::config::write_compact_models(&[]);
state.opt_out.compact = true;
report.rows.push((
ui::OK,
"Compact".into(),
"original model only (remembered)".into(),
));
}
} else if claude && llmtrim_core::config::compact_models_configured() {
// Deprecated: never write `[compact].models` on setup/ensure. Leave an existing list alone.
if claude && llmtrim_core::config::compact_models_configured() {
report
.rows
.push((ui::OK, "Compact".into(), "configured".into()));
Expand Down Expand Up @@ -700,7 +656,8 @@ pub fn maybe_auto() -> Result<bool> {
.iter()
.any(|g| g.id == "daemon" || g.detail.contains("stale"));
// Quiet path never first-installs missing integrations — only refresh owned stale
// pieces / daemon skew, or stamp the version after a binary bump.
// pieces / daemon skew, or stamp the version after a binary bump. Statusline/guard/compact
// are never first-installed on any path.
if !version_mismatch && !needs_refresh {
return Ok(false);
}
Expand Down Expand Up @@ -1006,6 +963,15 @@ mod tests {
assert!(!s.opt_out.window_sub);
}

#[test]
fn probe_never_flags_unconfigured_compact() {
let gaps = probe_with(&State::default());
assert!(
gaps.iter().all(|g| g.id != "compact"),
"compact is deprecated and must not be a recommended gap: {gaps:?}"
);
}

#[cfg(feature = "intercept")]
#[test]
fn route_agents_opt_out_is_sidecar_not_optout_field() {
Expand Down
3 changes: 2 additions & 1 deletion crates/llmtrim-cli/src/guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,8 @@ pub enum OwnedStatus {
Current,
}

/// Whether ensure should wire / refresh the cold-cache guard.
/// Ownership of the guard hook relative to this binary. `ensure` refreshes Stale only;
/// it does not first-install Missing (deprecated).
pub fn owned_status() -> OwnedStatus {
let Ok(path) = claude_settings_path() else {
return OwnedStatus::Missing;
Expand Down
Loading
Loading