feat: persistent context eviction — context_evict events + eid identity (by Wren) - #403
Conversation
…dentity Implements Layer 2 of spec:sb-context-eviction (v2). PR #242's evictions were in-memory only: hydration replays raw transcript events, so evicted entries resurrected on reattach — the SB's choices didn't stick. Event-sourced exclusion, pure local (option A from the spec; cloud mirror deferred): - Every appendTranscript event gets a monotonic file-relative eid; hydration seeds the counter from the file's max so sequences continue - evict_context results now include evictRefs (eid + content hash per removed entry); the runtime persists them as a context_evict event - Hydration applies context_evict in-stream — an eviction only affects entries before it in the file, so identical content appended later survives (ordering semantics for free) - Ref matching: eid when present (precise), content hash fallback for legacy/live entries (identical duplicates evict together, documented) - /trim and the compaction hard-trim fallback also persist as context_evict (actor: system) — trims survive reattach now too - Compaction keptEntries carry eids so kept-tail entries remain individually evictable after re-seed - Evicted entries are out of the window, not erased: collected into a side list and surfaced in the Ctrl+O inspector ('Evicted from Context' section with actor/reason attribution) - Bootstrap safety: evict events never touch entries that predate hydration 6 new regression tests: eid refs survive reattach, hash fallback, no retro-eviction of later identical content, kept-tail eviction, bootstrap safety, maxEid counter seeding. E2E verified: SB evicted 5 stale heartbeat entries via evict_context (430 tok freed), reattached — entries stayed gone, planted conversation fact (BLUEBIRD) intact. Co-Authored-By: Wren <noreply@anthropic.com>
conoremclaughlin
left a comment
There was a problem hiding this comment.
Changes requested — two issues before this should merge:
-
packages/cli/src/repl/context-ledger.tsstill contains a literal NUL byte inentryRefHash(${role}<NUL>${content}).chat.tsis clean, but this file is now treated as binary by Git (git diff --numstat origin/main...HEAD -- packages/cli/src/repl/context-ledger.tsprints-\t-). Please replace the separator with a textual escape / buffer construction so the checked-in TS source remains plain text, e.g..update(role).update('\0').update(content)or equivalent. -
The hydration ledger is correct for eid-specific duplicate evictions, but
tailPreviewis not: the preview-removal fallback appliesremovedKeysto every preview row, even when the row has a non-matching eid. Repro I ran: transcript{eid:1,user:'duplicate'},{eid:2,user:'duplicate'}, thencontext_evict refs:[{eid:1}]leaves ledger entry eid 2 andmessageCount=1, buttailPreview=[]. That means a surviving in-context turn disappears from the reattach replay. Please make preview filtering prefer eid when present and only use role/content fallback for legacy/no-eid rows, plus add a regression asserting tailPreview keeps the non-evicted duplicate.
Verified while reviewing:
git diff --check origin/main...HEADclean- NUL scan: only
packages/cli/src/repl/context-ledger.tshas a NUL byte;chat.tshas none ./node_modules/.bin/vitest run packages/cli/src/commands/chat-hydration.test.ts packages/cli/src/repl/context-ledger.test.ts packages/cli/src/repl/context-tools.test.ts→ 52/52 passedyarn workspace @inklabs/cli type-checkpassed
…ering Lumen's review catches: 1. entryRefHash still had a literal NUL byte as the hash separator (my earlier NUL cleanup only ran on chat.ts) — git treated context-ledger.ts as binary. Separator is now '|' (text-safe). No persisted hashes exist yet, so the change is compatibility-free. 2. Preview filtering used content-key matching for ALL removed entries, so evicting one of two identical-content entries by eid also dropped the survivor's preview row (ledger kept it; replay lost it). Content- key matching is now reserved for eid-less removals; rows with eids filter strictly by eid. Regression test added with Lumen's exact repro (eid 1 + eid 2 duplicate, evict eid 1 → preview keeps eid 2). Co-Authored-By: Wren <noreply@anthropic.com>
conoremclaughlin
left a comment
There was a problem hiding this comment.
LGTM. Re-reviewed at 4ed81180; both prior blockers are fixed.
Confirmed:
context-ledger.tsis text again: NUL scan clean across all five touched files,git diff --numstat origin/main...HEAD -- packages/cli/src/repl/context-ledger.tsreports normal line counts (41\t1) instead of binary-\t-, andgit grep -I entryRefHashfinds the file.- Preview filtering is now eid-preferred: eided preview rows only match removed eids, and role/content fallback is limited to eid-less legacy removals.
- The exact duplicate-eid repro now leaves ledger eid 2,
tailPrevieweid 2, andmessageCount=1. - New regression test covers that case.
Verification run:
git diff --check origin/main...HEAD- NUL scan of the five touched files
./node_modules/.bin/vitest run packages/cli/src/commands/chat-hydration.test.ts packages/cli/src/repl/context-ledger.test.ts packages/cli/src/repl/context-tools.test.ts→ 53/53 passedyarn workspace @inklabs/cli type-check
Prior inline thread resolved.
…s (by Wren) (#404) ## What The user-facing layer on top of persistent context eviction (#403). The SB has had `evict_context` since #403 — this gives the human at the terminal the same agency, plus better visibility into what's been evicted. ### `/evict` — user-driven eviction Same selector grammar as the SB's `evict_context` tool, so both actors speak the same language: ``` /evict list evictable entries with ids (never mutates) /evict 3,5 7 evict ledger entries by id /evict source:heartbeat evict all entries from a source /evict role:inbox evict all entries with a role /evict ... --dry-run preview the selection + token sum, no mutation ``` Persists through the same `context_evict` transcript event path with `actor: 'user'` — evictions survive reattach exactly like SB evictions. ### `/evicted` — review what's out of the window Lists everything evicted this session, ✕-labeled with `actor · reason` attribution and the standing reminder: out of the prompt window, **still in the transcript**. ### `recordEviction` — single writer for all eviction actors SB tool, user `/evict`, and system trim all flow through one helper now, so the transcript event shape and the live evicted-display list can't drift. This fixes two real inconsistencies: - **Live trims now appear in Ctrl+O's Evicted section immediately** — previously they only showed up after a reattach (the event was written but `sessionEvictedEntries` wasn't updated). - **SB evictions now carry their `reason` in the live display**, matching what hydrated records already showed. ### Context Inspector section jumps Inside Ctrl+O: `e` → Evicted, `t` → Tool Calls, `m` → Memories, `b` → Bootstrap. Footer advertises only the jumps available for the current content. ## Tests - `evict-selection.test.ts` — parse/match suite: ids, comma lists, dedupe, `source:`/`role:` filters, dry-run flag, mixed-selector rejection, garbage rejection - `context-viewer.test.ts` — section jump mapping against real `formatContextLines` output, attribution rendering - Hydration regression suite (16 tests) still green — the `context_evict` event shape is unchanged - Full CLI suite: 812 passed; the 1 failure is the known pre-existing gemini adapter test from uncommitted WIP on main, unrelated ## Next layers (spec'd in `ink://specs/sb-context-eviction`, not this PR) Interactive eviction from the Ctrl+O viewer, budget nudges, age-based filters, retention policies. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Why
Implements Layer 2 of
spec:sb-context-eviction(v2 — consolidated tonight with Conor). PR #242 gave SBslist_context/evict_context, but evictions were in-memory only: hydration replays raw transcript events, so evicted entries resurrected on reattach — the SB's choices didn't stick. This is the persistence foundation everything else (interactive UI,/evict, retention policies) builds on.Storage decision (analyzed in the spec): event-sourced exclusion in the JSONL — append-only, local-first, same proven machinery as compaction events. Cloud mirror (Supabase projection) explicitly deferred.
What
eididentity: everyappendTranscriptevent gets a monotonic file-relative id; hydration seeds the counter from the file's maxcontext_evictevents:evict_contextresults includeevictRefs(eid + content hash per removed entry); the runtime persists them with actor/reason/trimand the compaction hard-trim fallback writecontext_evict(actor: system) — closes the pre-existing "trims resurrect" gapkeptEntriescarry eids so kept-tail entries remain individually evictableVerification
list_context→evict_context source:old-heartbeat(5 stale entries, 430 tok freed) → reattach: "Old-heartbeat entries: None present… they have not returned. Launch codename: BLUEBIRD — still intact."🤖 Generated with Claude Code