Skip to content

feat: persistent context eviction — context_evict events + eid identity (by Wren) - #403

Merged
conoremclaughlin merged 2 commits into
mainfrom
wren/feat/eviction-persistence
Jun 11, 2026
Merged

feat: persistent context eviction — context_evict events + eid identity (by Wren)#403
conoremclaughlin merged 2 commits into
mainfrom
wren/feat/eviction-persistence

Conversation

@conoremclaughlin

Copy link
Copy Markdown
Owner

Why

Implements Layer 2 of spec:sb-context-eviction (v2 — consolidated tonight with Conor). PR #242 gave SBs list_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

  • eid identity: every appendTranscript event gets a monotonic file-relative id; hydration seeds the counter from the file's max
  • context_evict events: evict_context results include evictRefs (eid + content hash per removed entry); the runtime persists them with actor/reason
  • In-stream replay: hydration applies evictions at their position in the file — an eviction only affects entries before it, so identical content appended later survives (ordering semantics for free, regression-tested)
  • Ref matching: eid when present (precise); content-hash fallback for legacy/live entries (identical duplicates evict together — documented semantics)
  • Trims persist too: /trim and the compaction hard-trim fallback write context_evict (actor: system) — closes the pre-existing "trims resurrect" gap
  • Evicted ≠ erased: skipped entries collect into a side list; Ctrl+O inspector gains an "Evicted from Context" section with attribution
  • Safety: evict events never touch entries that predate hydration (bootstrap); compaction keptEntries carry eids so kept-tail entries remain individually evictable

Verification

  • 15 hydration tests (6 new eviction scenarios), 52/52 across hydration+ledger+tools, full CLI suite 788 passed (1 known gemini WIP failure)
  • Live E2E: Myra ran list_contextevict_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

…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 conoremclaughlin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Changes requested — two issues before this should merge:

  1. packages/cli/src/repl/context-ledger.ts still contains a literal NUL byte in entryRefHash (${role}<NUL>${content}). chat.ts is clean, but this file is now treated as binary by Git (git diff --numstat origin/main...HEAD -- packages/cli/src/repl/context-ledger.ts prints -\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.

  2. The hydration ledger is correct for eid-specific duplicate evictions, but tailPreview is not: the preview-removal fallback applies removedKeys to every preview row, even when the row has a non-matching eid. Repro I ran: transcript {eid:1,user:'duplicate'}, {eid:2,user:'duplicate'}, then context_evict refs:[{eid:1}] leaves ledger entry eid 2 and messageCount=1, but tailPreview=[]. 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...HEAD clean
  • NUL scan: only packages/cli/src/repl/context-ledger.ts has a NUL byte; chat.ts has 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 passed
  • yarn workspace @inklabs/cli type-check passed

Comment thread packages/cli/src/commands/chat.ts Outdated
…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 conoremclaughlin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

LGTM. Re-reviewed at 4ed81180; both prior blockers are fixed.

Confirmed:

  • context-ledger.ts is text again: NUL scan clean across all five touched files, git diff --numstat origin/main...HEAD -- packages/cli/src/repl/context-ledger.ts reports normal line counts (41\t1) instead of binary -\t-, and git grep -I entryRefHash finds 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, tailPreview eid 2, and messageCount=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 passed
  • yarn workspace @inklabs/cli type-check

Prior inline thread resolved.

@conoremclaughlin
conoremclaughlin merged commit 28efe4b into main Jun 11, 2026
3 of 4 checks passed
conoremclaughlin added a commit that referenced this pull request Jun 11, 2026
…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)
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