Skip to content

feat(optimizer): context-compression + memory optimizer for Claude Code - #466

Open
initializ-mk wants to merge 18 commits into
mainfrom
feat/optimizer
Open

initializ-mk wants to merge 18 commits into
mainfrom
feat/optimizer

Conversation

@initializ-mk

Copy link
Copy Markdown
Contributor

Adds forge optimizer — a local server Claude Code (or any Anthropic Messages API client) routes through via ANTHROPIC_BASE_URL. It meters real billed token usage and, with compression on, shrinks bulky conversation content before it reaches the model, serving a context_expand tool so the model can retrieve anything dropped.

Engine (forge-core/optimizer)

  • Streaming pass-through proxy with real usage metering + gateway chaining.
  • Reversible ctxzip compression on the outbound live zone — works on raw JSON and is deterministic, so provider prompt caches keep hitting.
  • context_expand retrieval, over MCP-HTTP or resolved in-band.
  • Ephemeral per-process compression store (retrieval cache; removed on exit).
  • Local memory: episodic records distilled off the wire at task boundaries; per-session frozen recall injection (cache-safe — byte-stable across turns); background procedural consolidation; confidence feedback (explicit 👍/👎 + ambient post-recall outcomes + corroboration, minus staleness decay). Scoped per session to the working-dir repo, not the launch dir.

CLI (forge-cli/cmd)

  • forge optimizer claude — wraps Claude Code (auto-attaches to a running proxy; auto-registers context_expand over MCP).
  • forge optimizer start|stop|status — detached always-on daemon that wires ~/.claude/settings.json so every claude routes through it, and cleanly reverts on stop.
  • forge optimizer savings|bench|stats|memory — local dollar/token views + an offline compression benchmark.

Dependency

Bumps ctxzip v0.3.0 → v0.4.1 (tighter error-keep + adaptive keep-budget crushers). Also benefits the existing forge-core/compress and markdown converter. Supersedes #465 (the standalone bump).

Tests / scope

go build ./... and the optimizer + compress test suites pass on all three modules. This PR is the engine + CLI; the forge-ui dashboard page is a follow-up (its app.js integration is entangled with other in-flight UI work and will be grafted separately).

Adds `forge optimizer` — a local server Claude Code (or any Anthropic Messages
API client) routes through via ANTHROPIC_BASE_URL. It meters real token usage
and, with compression on, shrinks bulky conversation content before it reaches
the model, serving context_expand so the model can retrieve anything dropped.

Engine (forge-core/optimizer):
- streaming pass-through proxy with real billed-usage metering + gateway chaining
- reversible ctxzip compression on the outbound live zone (cache-stable: works
  on raw JSON, deterministic so provider prompt caches keep hitting)
- context_expand retrieval, either over MCP-HTTP or resolved in-band
- ephemeral per-process compression store (retrieval cache; no cross-session value)
- local episodic memory distilled off the wire at task boundaries, per-session
  frozen recall injection (cache-safe), background procedural consolidation, and
  confidence feedback (explicit + ambient outcome/corroboration, staleness decay)
- memory scoped per session to the working-directory repo, not the launch dir

CLI (forge-cli/cmd):
- `forge optimizer claude` wraps Claude Code (auto-attach to a running proxy;
  auto-registers context_expand over MCP)
- `forge optimizer start|stop|status` — detached always-on daemon that wires
  ~/.claude/settings.json so every `claude` routes through it (reverted on stop)
- `forge optimizer savings|bench|stats|memory` — local views + offline benchmark

Deps: bump ctxzip v0.3.0 -> v0.4.1 (tighter error-keep + adaptive keep-budget
crushers; also benefits forge-core/compress and the markdown converter).

The forge-ui dashboard page for the optimizer follows in a separate PR (its
app.js integration is entangled with other in-flight UI work).
Surfaces the optimizer in the forge dashboard:
- forge-ui/handlers_optimizer.go: /api/optimizer/{stats,savings,memory,daemon}
  read views + memory delete/feedback + daemon start/stop control (shells the
  forge binary, so the UI performs the exact same settings-merge/revert).
- server.go: register the routes.
- app.js: an "Optimizer" nav item + page with a Savings tab (live stats +
  windowed $), a Memory tab (episodes/procedures table, confidence bar with
  👍/👎, detail drawer, delete), and a daemon status banner with Start/Stop.
The savings ratio was saved/compressed-blocks-before, which conflates "how well
the compressed blocks shrank" with effectiveness on the addressable surface. Add
two honest denominators:

- EligibleTokens: pre-compression size of EVERY block the compressor examined as
  a candidate (compressible role, past the size gate), whether or not it shrank.
  saved/eligible = what compression achieves on what it can actually touch.
- TotalTokens: estimate of the whole outbound request (system + tools + all
  messages). saved/total = the diluted "of everything sent" (includes the frozen
  prompt + incompressible content compression can't act on).

CLI savings + the forge-ui savings rows now show "X% of compressible · Y% of all
sent". Legacy usage-log records (no eligible/total) fall back to the prior
compressed-before denominator.
…ecords

The eligible/total ratios summed SavedTokens across ALL records but the new
denominators only for records written by the newer build, so numerators
included savings whose denominators were missing — yielding >100%. Now the
numerator and denominator come from the same record set:
- EligibleTokens gets a per-record legacy fallback (compressed-before) so it
  covers every record and SavedTokens is a valid numerator.
- TotalSaved accumulates saved only for records that carry TotalTokens, so the
  "% of all sent" ratio never mixes sets.
Displays clamp to 100% defensively.
- `stop` now kills the proxy by its listening port (verified as a forge
  optimizer via /healthz) when no pid was recorded — fixes the "did not start
  the running proxy myself — leaving it up" case for adopted proxies.
- `start --ui` records the dashboard pid; `stop` stops it too. `start` also
  records an adopted proxy's pid so a later `stop` can end it.
- No-state `stop` still does a best-effort full teardown (settings + MCP + proxy).
Replace the eligible/total dual-ratio savings metric with a single honest
ratio: saved / (cache_read + cache_creation) — the billed cache token volume
that compression actually shrinks. The prior "of compressible / of sent"
denominators mixed old and new usage-log records (producing >100% skew) and
diluted the number with the frozen prompt, cached reads, and output.

Dollars now credit the cache-WRITE rate (~1.25× input) the dropped content
would have incurred, via Pricing.CacheWriteCostAvoided, since the saved bytes
are conversation history Claude Code caches.

- pricing.go: add CacheWriteCostAvoided
- usagelog.go: WindowTotals = {saved_tokens, cache_tokens, cost_avoided_usd};
  aggregate cache = cache_read + cache_creation; $ = cache-write rate
- compress.go: drop eligible/total stat plumbing
- optimizer_savings.go + app.js: single "N% saved X / Y (cache read+write)" row
Cache reads re-count the entire cached prefix on every turn, so a
read+write denominator diluted the ratio ~30× (0.4% instead of a
meaningful figure). Switch the denominator to cache-CREATION (write)
tokens only — the freshly-cached bytes each turn adds, which is the
traffic compression directly shrinks. WindowTotals.CacheTokens →
CacheWriteTokens (json cache_write_tokens); UI/CLI label "cache write".
Dollars unchanged (cache-write-priced saved tokens).
The dashboard header valued saved tokens at 1× input list price while the
Savings tab uses the 1.25× cache-write rate — same saved counts, different
dollars. Align the header to cache-write (optimizerCacheWritePrice = input ×
1.25) so both views agree; relabel the tile 'cache-write rate'.
… cache-read)

Value saved tokens across all three billing tiers they would have incurred
without compression, per the corrected model:

  - uncached INPUT (1×) and CACHE-WRITE (1.25×) on the turn a chunk is first
    removed, split by that request's input:cache_creation ratio;
  - a compounding CACHE-READ (0.1×) for every LATER turn in the session that no
    longer re-reads the removed chunk — tracked via a per-session running
    cumSaved as records stream in chronological order.

The compounding read term dominates (a chunk removed early avoids a re-read on
every subsequent turn), which is the real economic value of keeping the prefix
small across a long session.

- pricing.go: CostAvoidedBreakdown(input, cacheWrite, cacheRead); drop the
  single-tier CacheWriteCostAvoided
- usagelog.go: WindowTotals gains avoided_{input,cache_write,cache_read}_tokens;
  new all_time window; per-session cumSaved drives the read term
- optimizer_savings.go: print the avoided-token breakdown line
- app.js: savings rows show the breakdown; header Cost-avoided tile sources the
  durable all-time three-tier figure (falls back to client cache-write estimate)
…able

Add a Cost-avoided column to the Live sessions table, sourced from the durable
usage-log report's per-session three-tier figure (indexed by session id). It
climbs as the current session's turns accumulate — the log is appended each
turn and the dashboard re-fetches every 5s. Token counters stay live/in-memory;
only the dollar figure comes from the durable compounding calc.
Rework the dashboard for credibility (addresses the live-vs-durable scope
mismatch that made per-token economics look inflated):

- Single scope: header tiles and the session table now source the DURABLE
  usage-log report, so token counts and dollars share one all-time scope. New
  ReportTotals carries all-time requests/tokens/spend/avoided.
- Ground it: track actual SpendUSD per record/window/session (Pricing.SpendUSD)
  and show "avoided vs spent", leverage (×), and Effective savings %
  (avoided / (avoided+spent)). The progress bars now plot effective savings, so
  bar and dollar tell one story.
- Explain the multiplier: a callout states the saved tokens vs the compounding
  cache-reads avoided, so the large read term reads as mechanism, not padding.
- Show composition: per-tier avoided dollars (input/write/read) surface as a
  stacked bar + line (Pricing.AvoidedTiers).
- Merge the Live + Previous session tables into one durable table with a live
  dot for currently-active sessions.
…(htm trims whitespace at element boundaries)
- errcheck: wrap unchecked Close() in tests and fmt.Fprint* in the CLI
  (optimizer.go, optimizer_bench.go)
- govet: check the http.Get error before using fresp in session_test.go
  (was "using fresp before checking for errors")
- staticcheck S1021: merge the non-recursive find closure decl+assign
- staticcheck QF1008: drop the embedded Totals selector (ps.add)

Verified: golangci-lint run ./... = 0 issues across all three modules;
go vet + go test ./... green.
…iters)

TestRecall_WrapsStringSystem intermittently failed in CI with "directory
not empty": the former's fire-and-forget store writers (recall recording,
consolidation, distillation) could write into the test's t.TempDir() while
RemoveAll was running.

Track those goroutines on a MemoryFormer.bg WaitGroup and add a test-only
newFormer(t, cfg) helper that registers former.waitAsync via t.Cleanup —
LIFO ensures it drains before the TempDir is removed. Routed all test former
constructors through the helper.

Verified: go test -race -count=5 ./optimizer/ green; golangci-lint = 0 issues.
The Build matrix (which only ran once Lint/Test passed) failed on windows/*:
optimizer_daemon.go used POSIX-only syscall.Kill and SysProcAttr{Setsid},
which don't exist on Windows.

Extract the three OS-specific operations behind build tags:
  - processAlive(pid), terminatePID(pid), detachSysProcAttr()
  - optimizer_daemon_unix.go   (!windows): signal 0 probe, SIGTERM, Setsid
  - optimizer_daemon_windows.go (windows): os.FindProcess handle check,
    Process.Kill (TerminateProcess), DETACHED_PROCESS

Verified: builds for linux/darwin/windows × amd64/arm64; golangci-lint = 0
issues (native and GOOS=windows); go test ./cmd green.

@initializ-mk initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — context-compression + memory optimizer for Claude Code

A large, well-architected subsystem. I reviewed the security core (proxy, daemon, MCP expand, compression-store lifecycle) plus the memory subsystem and the CLI/pricing/UI surface. No HIGH-severity findings — the credential posture is genuinely careful (caller auth forwarded untouched, none at rest; distillation auth headers used only for the side call and never persisted — Episode has no header field; stores at 0600/dir 0700; telemetry off by default, no hidden endpoint). All 10 CI checks green. Issues are two security-hardening MEDIUMs, a savings-accounting MEDIUM, a memory-correctness cluster, and LOWs.

🟠 MEDIUM #1 — forge-ui optimizer endpoints are unauthenticated with Access-Control-Allow-Origin: *

corsMiddleware sends ACAO: * and there's no auth/CSRF, so while the dashboard runs, any website the user visits can fetch('http://127.0.0.1:<port>/api/optimizer/memory') and read it cross-origin — returning distilled episodic memory (Summary/Lesson/Files/TaskSignature, i.e. summaries of the user's private code work) — and POST /api/optimizer/daemon/start|stop, which rewrites ~/.claude/settings.json. Inherits forge-ui's pre-existing no-auth-localhost posture but materially widens it with sensitive read + state-changing endpoints (live now, even though the dashboard UI is deferred). Fix: drop ACAO: * for these routes (or gate with same-origin/CSRF), treat daemon start/stop as state-changing.

🟠 MEDIUM #2 — "avoided cache-read" savings are an unbounded optimistic estimate shown as concrete dollars

usagelog.go credits every previously-compressed token in the session as an avoided cache-read on every subsequent turn, so the headline $ avoided grows ~S·N(N-1)/2 (100 turns × 10k saved ≈ 49.5M "avoided read tokens", 10–50× the real per-turn value). Comments call it directional, but forge optimizer savings + the dashboard render it as a hard figure. Fix: label it an upper-bound estimate or cap the compounding horizon.

🟠 MEDIUM #3 — memory staleness decays against the wrong repo's commit

memory_recall.go gates the ×0.85 staleness penalty on f.commit — the launch-dir commit — not the session repo's resolved commit (Inject discards the resolved commit; computeRecall never receives it). For a proxy serving multiple repos, episodes from repo B are checked against repo A's HEAD → false decay or none. Breaks per-session scoping on the staleness axis, and it's untested. Fix: thread the resolved commit into computeRecall.

🟠 MEDIUM #4frozenBlock compute is non-atomic → concurrent first-turns can break the cache-safety guarantee

frozenBlock releases the mutex, computes recall unlocked, then re-locks to cache. Two overlapping first-turns of one session both miss, both compute, and each returns its own block (last writer wins the cache); if the store changed between them, the two in-flight turns inject non-identical blocks — the exact cache-bust the design forbids — plus double-recorded recall. Sequential turns are fine (tests pass), but it isn't atomic. Fix: hold the lock across compute, or single-flight per session.

🟠 MEDIUM #5 — memory scope basename-collision merges distinct repos

memory_scope.go default key is filepath.Base(cwd), so ~/a/client and ~/work/client collapse to "client" and cross-inject. Fix: include a path hash, or require the git-toplevel resolver.

🟠 MEDIUM #6 — failed distillation is silently never retried (episode lost)

Observe advances distilled[sessionID] to completed before dispatch; on failure dispatch deletes the span from seenSpans "to allow retry," but a later Observe returns early (completed <= already), so it's never revisited — a transient 401/timeout permanently drops that task's memory. Fix: roll back distilled on failure (or drop the misleading delete+comment).

🟠 MEDIUM #7 — test gaps on load-bearing invariants

No pricing.go tests (the dollar math); no test for feedback bounds under extreme input, the staleness branch (would've caught #3), or concurrent first-turns (would've caught #4); no regression guard that the usage log/reports never contain prompt content or keys.

🟡 LOW cluster

  • Listen not guarded to loopback (server.go) — non-loopback bind → open forward-proxy + /stats exposure; warn/refuse.
  • ~/.claude/settings.json written 0644 (should be 0600) and non-atomically (temp+rename; claude reads it concurrently).
  • Distilled errors/summary can persist a secret if the distiller echoes one (durable at 0600, no new wire exposure) — consider a scrub pass.
  • cwd is client-supplied → memory scope spoofable (fine under local single-user trust; note it).
  • Ephemeral store not removed on SIGKILL/crash (temp bbolt with prompt content lingers).
  • Upstream URL with embedded creds echoed to banner/0600 log; [1m] long-context models priced at base rate (under-report); UI returns raw FS error strings; unbounded per-request telemetry goroutines (2s-capped); UTF-8 rune-splitting in transcript truncation; orphaned recall/feedback on delete (content-hash IDs resurrect stale aggregates); no-git default → staleness inert.

✅ Verified correct

  • No key leakage: proxy forwards caller auth untouched with none at rest; distillation auth used only for the side call, never persisted; usage log + telemetry token-counts-only; telemetry off by default, no hidden endpoint.
  • context_expand MCP tool is a content-addressed hash lookup bounded to this process's store — no file/path access, no traversal, no cross-session leak, miss non-fatal, logs no content.
  • Stores 0600/dir 0700; ephemeral store MkdirTemp+RemoveAll on shutdown; daemon preserves other settings.json keys and cleanly reverts.
  • Pricing rates + cache multipliers match the current reference exactly.
  • Feedback math bounded (∈ (0,1], no NaN/negative/overflow); frozen recall byte-stable across sequential turns (real invariant test); scoping correct on the normal path; consolidation single-flighted, goroutines bounded (no leak).

Note — ctxzip v0.4.1 + reversibility

The bump is a library I've reviewed before; the reversibility mechanism here is sound (store holds the Original, marker→context_expand returns it byte-exact). Caveat worth documenting: reversibility is bounded by the ephemeral store's 30-min TTL and process lifetime — a marker expanded after eviction/restart misses (non-fatal, but that turn's dropped content is unrecoverable). Deterministic re-compression refreshes entries for content still in the live window, mitigating within an active conversation.

Verdict: changes requested — no HIGH, but MEDIUM #1 (cross-origin memory disclosure + daemon control) and the memory-correctness pair (#3 wrong-commit staleness, #4 frozen-block race) are the ones to prioritize; the rest is hardening/accounting/test coverage.

"store_path": path,
"count": len(eps),
"recalled_count": recalled,
"episodes": eps,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MEDIUM #1 — this returns private episodic memory to any cross-origin caller. The route is wrapped only in corsMiddleware, which sends Access-Control-Allow-Origin: * with no auth/CSRF. So any page the user visits while the dashboard runs can fetch('http://127.0.0.1:<port>/api/optimizer/memory') and read these episodes (Summary/Lesson/Files/TaskSignature = summaries of the user's private code work) cross-origin, and POST .../daemon/start|stop (which rewrites ~/.claude/settings.json). Drop ACAO:* for these routes or gate with a same-origin/CSRF check; treat daemon start/stop as state-changing.

Comment thread forge-core/optimizer/memory_recall.go Outdated
}
conf := EffectiveConfidence(e.Confidence, fb[e.ID])
// Staleness: down-weight memory formed against a now-moved commit.
if f.commit != "" && e.CodeState.Commit != "" && e.CodeState.Commit != f.commit {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MEDIUM #3 — staleness decays against the wrong repo's commit. f.commit is the launch-dir commit, but the episode was scoped to the session repo (Inject resolves the repo then discards the commit; computeRecall never receives it). For a proxy serving multiple repos, an episode from repo B is staleness-checked against repo A's HEAD → false decay when they differ, no decay when B has moved. Thread the resolved commit into computeRecall and compare against that. (Also untested — a multi-repo recall test would catch it.)

Comment thread forge-core/optimizer/memory_recall.go Outdated

// frozenBlock returns the session-frozen, task-agnostic recall block (block,
// count, ids), computing it once on first use.
func (f *MemoryFormer) frozenBlock(sessionID, repo string) (string, int, []string) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MEDIUM #4 — non-atomic frozen-block compute can break the byte-stable-recall guarantee under concurrency. This releases f.mu, runs computeRecall unlocked, then re-locks to store the cache. Two overlapping first-turns of the same new session both miss, both compute (each also firing RecordRecall + a log line), and each returns its own block; last writer wins the cache. If the store changed between the two computes, the two in-flight turns inject non-identical blocks — the exact cache-bust the design forbids — plus duplicated recall attribution. Sequential turns are safe (so tests pass), but the guarantee isn't atomic: hold the lock across compute, or single-flight per session.

…labeling, tests

MEDIUM #1 (cross-origin memory disclosure + daemon control): the optimizer
endpoints return private distilled memory and can rewrite ~/.claude/settings.json.
corsMiddleware no longer sends ACAO:* for /api/optimizer/* and refuses cross-site
requests via the Fetch Metadata Sec-Fetch-Site header (blocks cross-origin reads
and CSRF); same-origin dashboard and non-browser clients still work.

MEDIUM #3 (wrong-commit staleness): thread the SESSION repo's resolved commit
through Inject → frozenBlock → computeRecall and judge staleness against it, not
the launch-dir f.commit.

MEDIUM #4 (frozen-block race): hold f.mu across computeRecall so overlapping
first-turns of a session can't each compute and return non-identical (cache-
busting) blocks. computeRecall takes no lock, so no re-entrancy.

MEDIUM #5 (basename scope collision): the no-git fallback appends a short FNV
path hash so ~/a/client and ~/work/client don't collapse to one scope.

MEDIUM #6 (lost distillation): bounded retry — roll back distilled[session] and
seenSpans on transient failure so a later turn revisits the span; give up after
maxDistillAttempts to avoid a permanent-failure storm.

MEDIUM #2 (optimistic savings shown as hard $): label cost-avoided an upper-bound
estimate (≈, "up to", est.) in the UI tiles, callout, and CLI; the compounding
cache-read note now states the cache-warm assumption.

LOW (settings.json): write 0600 and atomically (temp+rename); mkdir 0700.

MEDIUM #7 (test gaps): add pricing_test.go (rates, prefix match, unknown
fallback, overrides, AvoidedTiers, SpendUSD) and tests for the staleness
session-commit path (#3), concurrent frozen-block byte-stability (#4, -race),
basename-collision disambiguation (#5), and transient-distill retry (#6).

Verified: go test -race -count=3 ./optimizer green; golangci-lint = 0 issues
(native + GOOS=windows); all six cross-compiles build.
@initializ-mk

Copy link
Copy Markdown
Contributor Author

Review fixes — pushed in 1529cd7

Thanks for the thorough pass. Addressed as follows:

Remaining LOWs (loopback-bind guard, secret-scrub pass on distilled text, ephemeral-store crash cleanup, [1m] pricing, raw FS error strings) are noted and can be a follow-up if you'd like them in-scope.

Verified locally: go test -race -count=3 ./optimizer green; golangci-lint = 0 issues (native + GOOS=windows); all six cross-compiles build.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant