From 2ecc30e641e267b184228776908c320788236ff4 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Wed, 10 Jun 2026 23:54:45 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(cli):=20persistent=20context=20evictio?= =?UTF-8?q?n=20=E2=80=94=20context=5Fevict=20events=20+=20eid=20identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../cli/src/commands/chat-hydration.test.ts | 118 ++++++++- packages/cli/src/commands/chat.ts | 238 ++++++++++++++++-- packages/cli/src/repl/context-ledger.ts | Bin 9718 -> 11396 bytes packages/cli/src/repl/context-tools.ts | 12 +- packages/cli/src/repl/ink/context-viewer.tsx | 22 ++ 5 files changed, 368 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/commands/chat-hydration.test.ts b/packages/cli/src/commands/chat-hydration.test.ts index 5e09fc60..5ea9a1f5 100644 --- a/packages/cli/src/commands/chat-hydration.test.ts +++ b/packages/cli/src/commands/chat-hydration.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ContextLedger } from '../repl/context-ledger.js'; +import { ContextLedger, entryRefHash } from '../repl/context-ledger.js'; import { hydrateLedgerFromTranscript } from './chat.js'; describe('hydrateLedgerFromTranscript — compaction events', () => { @@ -244,3 +244,119 @@ describe('hydrateLedgerFromTranscript — compaction events', () => { expect(entries[2].content).toBe('valid entry'); }); }); + +describe('hydrateLedgerFromTranscript — context_evict events', () => { + let dir: string; + let transcriptPath: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'ink-evict-test-')); + transcriptPath = join(dir, 'session-test.jsonl'); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + const writeTranscript = (events: Array>) => { + writeFileSync(transcriptPath, events.map((e) => JSON.stringify(e)).join('\n') + '\n'); + }; + + it('evicted entries stay gone on reattach (eid refs)', () => { + writeTranscript([ + { eid: 1, type: 'user', content: 'keep me' }, + { eid: 2, type: 'assistant', content: 'stale heartbeat result', backend: 'claude' }, + { eid: 3, type: 'assistant', content: 'keep me too', backend: 'claude' }, + { eid: 4, type: 'context_evict', actor: 'sb', reason: 'stale', refs: [{ eid: 2 }] }, + ]); + + const ledger = new ContextLedger(); + const result = hydrateLedgerFromTranscript(ledger, transcriptPath); + + const contents = ledger.listEntries().map((e) => e.content); + expect(contents).toEqual(['keep me', 'keep me too']); + expect(result.tailPreview.map((p) => p.content)).not.toContain('stale heartbeat result'); + expect(result.messageCount).toBe(2); + expect(result.evictedEntries).toHaveLength(1); + expect(result.evictedEntries[0].content).toBe('stale heartbeat result'); + expect(result.evictedEntries[0].actor).toBe('sb'); + expect(result.maxEid).toBe(4); + }); + + it('evicts by content hash when events lack eids (legacy transcripts)', () => { + const hash = entryRefHash('user', 'old noise'); + writeTranscript([ + { type: 'user', content: 'old noise' }, + { type: 'user', content: 'signal' }, + { type: 'context_evict', actor: 'sb', refs: [{ hash }] }, + ]); + + const ledger = new ContextLedger(); + hydrateLedgerFromTranscript(ledger, transcriptPath); + + expect(ledger.listEntries().map((e) => e.content)).toEqual(['signal']); + }); + + it('does not retro-evict identical content appended AFTER the evict event', () => { + const hash = entryRefHash('user', 'repeated message'); + writeTranscript([ + { type: 'user', content: 'repeated message' }, + { type: 'context_evict', actor: 'sb', refs: [{ hash }] }, + { type: 'user', content: 'repeated message' }, // written after — must survive + ]); + + const ledger = new ContextLedger(); + const result = hydrateLedgerFromTranscript(ledger, transcriptPath); + + expect(ledger.listEntries().map((e) => e.content)).toEqual(['repeated message']); + expect(result.messageCount).toBe(1); + }); + + it('evicts kept-tail entries re-seeded by a compaction event', () => { + writeTranscript([ + { eid: 1, type: 'user', content: 'old' }, + { + eid: 2, + type: 'compaction', + summary: 'the summary', + keptEntries: [ + { role: 'user', content: 'kept but stale', source: 'repl-history', eid: 1 }, + { role: 'assistant', content: 'kept and useful', source: 'claude' }, + ], + }, + { eid: 3, type: 'context_evict', actor: 'sb', refs: [{ eid: 1 }] }, + ]); + + const ledger = new ContextLedger(); + hydrateLedgerFromTranscript(ledger, transcriptPath); + + const contents = ledger.listEntries().map((e) => e.content); + expect(contents).toEqual(['the summary', 'kept and useful']); + }); + + it('never evicts entries that predate hydration (bootstrap safety)', () => { + const hash = entryRefHash('system', 'bootstrap identity block'); + writeTranscript([ + { type: 'user', content: 'hello' }, + { type: 'context_evict', actor: 'sb', refs: [{ hash }] }, + ]); + + const ledger = new ContextLedger(); + ledger.addEntry('system', 'bootstrap identity block', 'bootstrap'); + + hydrateLedgerFromTranscript(ledger, transcriptPath); + + expect(ledger.listEntries().map((e) => e.content)).toContain('bootstrap identity block'); + }); + + it('reports maxEid so the append counter continues the sequence', () => { + writeTranscript([ + { eid: 7, type: 'user', content: 'a' }, + { eid: 12, type: 'assistant', content: 'b', backend: 'claude' }, + ]); + + const ledger = new ContextLedger(); + const result = hydrateLedgerFromTranscript(ledger, transcriptPath); + expect(result.maxEid).toBe(12); + }); +}); diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index 6b506465..0de338a9 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -27,7 +27,12 @@ import { type BackendAuthBackend, } from '../lib/backend-auth.js'; import { startBackendTurn, runBackendTurn } from '../repl/backend-runner.js'; -import { ContextLedger, estimateTokens, type LedgerRole } from '../repl/context-ledger.js'; +import { + ContextLedger, + entryRefHash, + estimateTokens, + type LedgerRole, +} from '../repl/context-ledger.js'; import { parseSlashCommand } from '../repl/slash.js'; import { ToolMode, ToolPolicyScopeKind, ToolPolicyState } from '../repl/tool-policy.js'; import { formatBackendTokenUsage, type BackendTokenUsage } from '../repl/token-usage.js'; @@ -393,6 +398,8 @@ interface HistoryHydrationResult { ts?: string; /** Display label for system entries (e.g., "heartbeat", "continuation") */ label?: string; + /** Transcript event id (for eviction filtering of the replay) */ + eid?: number; }>; seenInboxIds?: string[]; seenActivityIds?: string[]; @@ -400,6 +407,18 @@ interface HistoryHydrationResult { compactionCollapsed?: boolean; } +/** An entry excluded from the window by a context_evict event — kept for display */ +interface EvictedEntryRecord { + role: LedgerRole; + content: string; + source?: string; + eid?: number; + actor?: string; + reason?: string; +} + +const EVICTED_DISPLAY_MAX = 100; + interface SessionContextMessage { role: 'user' | 'assistant' | 'inbox' | 'system'; content: string; @@ -612,15 +631,21 @@ export function hydrateLedgerFromTranscript( seenActivityIds: string[]; recoveredMemoryIds: string[]; compactionCollapsed: boolean; + /** Entries excluded by context_evict events — for evicted-content display */ + evictedEntries: EvictedEntryRecord[]; + /** Highest event id seen — seeds the append counter so new eids continue */ + maxEid: number; } { const events = readTranscriptEvents(transcriptPath); let loaded = 0; let messageCount = 0; let compactionCollapsed = false; + let maxEid = 0; const preview: HistoryHydrationResult['tailPreview'] = []; const seenInboxIds = new Set(); const seenActivityIds = new Set(); const recoveredMemoryIds: string[] = []; + const evictedEntries: EvictedEntryRecord[] = []; // Entries added by THIS hydration pass — a compaction event collapses them // (and only them; entries that pre-date hydration are left alone). const hydratedEntryIds: number[] = []; @@ -629,9 +654,10 @@ export function hydrateLedgerFromTranscript( role: 'user' | 'assistant' | 'inbox' | 'system', content: string, ts?: string, - label?: string + label?: string, + eid?: number ) => { - preview.push({ role, content: compactForHistoryPreview(role, content), ts, label }); + preview.push({ role, content: compactForHistoryPreview(role, content), ts, label, eid }); if (preview.length > HISTORY_PREVIEW_MAX) { preview.shift(); } @@ -639,6 +665,73 @@ export function hydrateLedgerFromTranscript( for (const event of events) { const type = typeof event.type === 'string' ? event.type : ''; + const eid = typeof event.eid === 'number' ? event.eid : undefined; + if (eid !== undefined && eid > maxEid) maxEid = eid; + if (type === 'context_evict' && Array.isArray(event.refs)) { + // Apply the eviction exactly as it happened live: remove matching + // entries that exist at this point in the replay. Entries appended + // AFTER this event (even with identical content) are unaffected — + // in-stream ordering gives exclusion the right semantics for free. + const refs = (event.refs as Array>) + .filter((r) => r && typeof r === 'object') + .map((r) => ({ + eid: typeof r.eid === 'number' ? r.eid : undefined, + hash: typeof r.hash === 'string' ? r.hash : undefined, + })); + const hydratedSet = new Set(hydratedEntryIds); + const matchIds = ledger.findEntriesByRefs(refs).filter((id) => hydratedSet.has(id)); + if (matchIds.length === 0) continue; + const evictResult = ledger.evictEntries(matchIds); + const removedLedgerIds = new Set(evictResult.removedEntries.map((e) => e.id)); + for (let i = hydratedEntryIds.length - 1; i >= 0; i--) { + if (removedLedgerIds.has(hydratedEntryIds[i])) hydratedEntryIds.splice(i, 1); + } + // Drop evicted entries from the visible replay and adjust counts + const removedEids = new Set( + evictResult.removedEntries.map((e) => e.eid).filter((v): v is number => v !== undefined) + ); + const removedKeys = new Set( + evictResult.removedEntries.map( + (e) => `${e.role} ${compactForHistoryPreview(e.role, e.content)}` + ) + ); + for (let i = preview.length - 1; i >= 0; i--) { + const p = preview[i]; + if ( + (p.eid !== undefined && removedEids.has(p.eid)) || + removedKeys.has(`${p.role} ${p.content}`) + ) { + preview.splice(i, 1); + } + } + let removedMessages = 0; + for (const removed of evictResult.removedEntries) { + if ( + removed.role === 'user' || + removed.role === 'assistant' || + removed.role === 'inbox' || + (removed.role === 'system' && + (removed.source === 'continuation' || + !INTERNAL_SYSTEM_SOURCES.has(removed.source || ''))) + ) { + removedMessages += 1; + } + evictedEntries.push({ + role: removed.role, + content: removed.content, + source: removed.source, + eid: removed.eid, + actor: typeof event.actor === 'string' ? event.actor : undefined, + reason: typeof event.reason === 'string' ? event.reason : undefined, + }); + } + if (evictedEntries.length > EVICTED_DISPLAY_MAX) { + evictedEntries.splice(0, evictedEntries.length - EVICTED_DISPLAY_MAX); + } + messageCount = Math.max(0, messageCount - removedMessages); + loaded = Math.max(0, loaded - evictResult.removedEntries.length); + continue; + } if (type === 'compaction' && typeof event.summary === 'string') { // Compaction marks a new start state: everything replayed before this // point is superseded by the event's summary + kept tail. The tail's @@ -670,7 +763,8 @@ export function hydrateLedgerFromTranscript( : 'system'; const source = typeof keptRecord.source === 'string' ? keptRecord.source : 'compaction-tail'; - const entry = ledger.addEntry(role, keptRecord.content, source); + const keptEid = typeof keptRecord.eid === 'number' ? keptRecord.eid : undefined; + const entry = ledger.addEntry(role, keptRecord.content, source, keptEid); hydratedEntryIds.push(entry.id); loaded += 1; if (role === 'user' || role === 'assistant' || role === 'inbox') { @@ -678,7 +772,9 @@ export function hydrateLedgerFromTranscript( pushPreview( role, keptRecord.content, - typeof event.ts === 'string' ? event.ts : undefined + typeof event.ts === 'string' ? event.ts : undefined, + undefined, + keptEid ); } else if (role === 'system' && !INTERNAL_SYSTEM_SOURCES.has(source)) { // Kept system turns with a meaningful channel label (heartbeat, @@ -687,37 +783,61 @@ export function hydrateLedgerFromTranscript( 'system', keptRecord.content, typeof event.ts === 'string' ? event.ts : undefined, - source + source, + keptEid ); } } continue; } if (type === 'user' && typeof event.content === 'string') { - const entry = ledger.addEntry('user', event.content, 'repl-history'); + const entry = ledger.addEntry('user', event.content, 'repl-history', eid); hydratedEntryIds.push(entry.id); loaded += 1; messageCount += 1; - pushPreview('user', event.content, typeof event.ts === 'string' ? event.ts : undefined); + pushPreview( + 'user', + event.content, + typeof event.ts === 'string' ? event.ts : undefined, + undefined, + eid + ); continue; } if (type === 'assistant') { if (event.cancelled === true || event.content === '(no output)') continue; if (typeof event.content !== 'string') continue; const source = typeof event.backend === 'string' ? event.backend : 'backend-history'; - const entry = ledger.addEntry('assistant', event.content, source); + const entry = ledger.addEntry('assistant', event.content, source, eid); hydratedEntryIds.push(entry.id); loaded += 1; messageCount += 1; - pushPreview('assistant', event.content, typeof event.ts === 'string' ? event.ts : undefined); + pushPreview( + 'assistant', + event.content, + typeof event.ts === 'string' ? event.ts : undefined, + undefined, + eid + ); continue; } if (type === 'inbox' && typeof event.rendered === 'string') { - const entry = ledger.addEntry('inbox', compactForLedger(event.rendered), 'inkmail-history'); + const entry = ledger.addEntry( + 'inbox', + compactForLedger(event.rendered), + 'inkmail-history', + eid + ); hydratedEntryIds.push(entry.id); loaded += 1; messageCount += 1; - pushPreview('inbox', event.rendered, typeof event.ts === 'string' ? event.ts : undefined); + pushPreview( + 'inbox', + event.rendered, + typeof event.ts === 'string' ? event.ts : undefined, + undefined, + eid + ); if (typeof event.messageId === 'string') { seenInboxIds.add(event.messageId); } @@ -726,7 +846,7 @@ export function hydrateLedgerFromTranscript( if (type === 'system_turn' && typeof event.content === 'string') { // Synthetic turn input (heartbeat trigger, continuation prompt, etc.) const label = typeof event.label === 'string' ? event.label : 'system'; - const entry = ledger.addEntry('system', event.content, label); + const entry = ledger.addEntry('system', event.content, label, eid); hydratedEntryIds.push(entry.id); loaded += 1; messageCount += 1; @@ -737,14 +857,15 @@ export function hydrateLedgerFromTranscript( 'system', event.content, typeof event.ts === 'string' ? event.ts : undefined, - label + label, + eid ); } continue; } if (type === 'hook_injection' && typeof event.content === 'string') { const source = typeof event.source === 'string' ? event.source : 'hook-history'; - const entry = ledger.addEntry('system', event.content, source); + const entry = ledger.addEntry('system', event.content, source, eid); hydratedEntryIds.push(entry.id); loaded += 1; if (typeof event.memoryId === 'string') { @@ -758,7 +879,8 @@ export function hydrateLedgerFromTranscript( const entry = ledger.addEntry( 'system', compactForLedger(`⚡ ${actor} ${activityType} — ${event.content}`, 320), - 'pcp-activity-history' + 'pcp-activity-history', + eid ); hydratedEntryIds.push(entry.id); loaded += 1; @@ -776,6 +898,8 @@ export function hydrateLedgerFromTranscript( seenActivityIds: Array.from(seenActivityIds), recoveredMemoryIds, compactionCollapsed, + evictedEntries, + maxEid, }; } @@ -853,8 +977,21 @@ async function tailTranscript(target: string): Promise { }); } -function appendTranscript(path: string, event: Record): void { - appendFileSync(path, JSON.stringify({ ts: new Date().toISOString(), ...event }) + '\n'); +// Per-transcript monotonic event id counters. Every appended event gets an +// `eid` so persistent operations (context_evict) can reference events +// precisely across reattach. Seeded from the file's max eid on hydration. +const transcriptEidCounters = new Map(); + +export function seedTranscriptEidCounter(path: string, maxSeen: number): void { + const current = transcriptEidCounters.get(path) ?? 0; + if (maxSeen > current) transcriptEidCounters.set(path, maxSeen); +} + +function appendTranscript(path: string, event: Record): number { + const eid = (transcriptEidCounters.get(path) ?? 0) + 1; + transcriptEidCounters.set(path, eid); + appendFileSync(path, JSON.stringify({ ts: new Date().toISOString(), eid, ...event }) + '\n'); + return eid; } function compactForLedger(content: string, maxChars = LEDGER_COMPACT_CHARS): string { @@ -2228,6 +2365,10 @@ export async function runChat(options: ChatOptions): Promise { // Session-level tool call log — surfaced in the Ctrl+O context inspector const recentToolCalls: Array<{ tool: string; status: string; at: string }> = []; + // Entries evicted from the window (hydration replay + live evictions) — + // out of context but never out of sight; surfaced in the inspector + const sessionEvictedEntries: EvictedEntryRecord[] = []; + // Register built-in hooks (passive recall + budget monitor). // callRecall wraps pcp.callTool('recall', ...) into the shape hooks expect. const { passiveRecall: passiveRecallHandle } = registerBuiltinHooks(hookRegistry, { @@ -2523,6 +2664,9 @@ export async function runChat(options: ChatOptions): Promise { let historyHydration: HistoryHydrationResult | null = null; if (attachedToExistingSession && existingTranscript) { const hydrated = hydrateLedgerFromTranscript(ledger, existingTranscript); + // Continue the event-id sequence from where the file left off + seedTranscriptEidCounter(existingTranscript, hydrated.maxEid); + sessionEvictedEntries.push(...hydrated.evictedEntries); historyHydration = { loaded: hydrated.loaded, messageCount: hydrated.messageCount, @@ -2876,6 +3020,18 @@ export async function runChat(options: ChatOptions): Promise { removedTokens: trim.removedTokens, totalAfter: trim.totalAfter, }); + // Persist the trim as an eviction so it survives reattach (context_trim + // alone is informational — hydration doesn't replay it) + appendTranscript(runtime.transcriptPath, { + type: 'context_evict', + actor: 'system', + reason: `trim: ${reason}`, + removedTokens: trim.removedTokens, + refs: trim.removedEntries.map((e) => ({ + ...(e.eid !== undefined ? { eid: e.eid } : {}), + hash: entryRefHash(e.role, e.content), + })), + }); return { removed: trim.removedEntries.length, removedTokens: trim.removedTokens }; }; @@ -2951,7 +3107,12 @@ export async function runChat(options: ChatOptions): Promise { const keptEntries = ledger .listEntries() .slice(1) // entry 0 is the summary itself - .map((e) => ({ role: e.role, content: e.content, source: e.source })); + .map((e) => ({ + role: e.role, + content: e.content, + source: e.source, + ...(e.eid !== undefined ? { eid: e.eid } : {}), + })); appendTranscript(runtime.transcriptPath, { type: 'compaction', reason, @@ -3766,11 +3927,41 @@ export async function runChat(options: ChatOptions): Promise { const content = (r?.content as Array<{ text: string }> | undefined)?.[0]?.text; if (content) { const parsed = JSON.parse(content); - printLine( + printEvent( chalk.dim( ` 🗑 evicted ${parsed.evicted} entries (${parsed.tokensFreed} tok freed, ${parsed.totalAfter} tok remaining)` ) ); + // Persist the eviction so it survives reattach — without this, + // hydration replays the raw events and evicted entries resurrect + if (parsed.success && Array.isArray(parsed.evictRefs) && parsed.evicted > 0) { + const refs = parsed.evictRefs as Array>; + appendTranscript(runtime.transcriptPath, { + type: 'context_evict', + actor: 'sb', + reason: compactForLedger(JSON.stringify(result.args ?? {}), 200), + removedTokens: parsed.tokensFreed, + refs: refs.map((ref) => ({ + ...(typeof ref.eid === 'number' ? { eid: ref.eid } : {}), + hash: ref.hash, + })), + }); + for (const ref of refs) { + sessionEvictedEntries.push({ + role: (ref.role as LedgerRole) || 'system', + content: typeof ref.preview === 'string' ? ref.preview : '', + source: typeof ref.source === 'string' ? ref.source : undefined, + eid: typeof ref.eid === 'number' ? ref.eid : undefined, + actor: 'sb', + }); + } + if (sessionEvictedEntries.length > EVICTED_DISPLAY_MAX) { + sessionEvictedEntries.splice( + 0, + sessionEvictedEntries.length - EVICTED_DISPLAY_MAX + ); + } + } } } else if (result.tool === 'list_context') { const r = result.result as Record | undefined; @@ -4350,6 +4541,13 @@ export async function runChat(options: ChatOptions): Promise { bootstrapTokens: runtime.bootstrapContext ? estimateTokens(runtime.bootstrapContext) : 0, }, toolCalls: recentToolCalls, + evicted: sessionEvictedEntries.map((e) => ({ + role: e.role, + source: e.source, + preview: e.content.slice(0, 100), + actor: e.actor, + reason: e.reason, + })), }); }; diff --git a/packages/cli/src/repl/context-ledger.ts b/packages/cli/src/repl/context-ledger.ts index bfc8e25fe4bee72405c128787630d338b1e5c5f6..5d89648668d9031d69e66b63fb6c1087adb80465 100644 GIT binary patch delta 1648 zcma)6O^@S55Jd>~kORK}L?Nvvb~fwTy>Q5oSuI}zAuhA*0U^Y8+~v51)3&EQ<&IV0 zPn0idPlc;DG|gStgnD{5nf&m3Pjot)O^9elUpQrb?o1Piw`>C$O`$ZN@a#IeRG!LC zJHY7yDJe|F_Z?R%m@Ur-6d%;4!Hrc%s%hBjBW#+{Q>(c!RuF1OmFc$};SGEAQh`c^ z*3&Rxt(YwttIgKvR#jC;!KwUPy#4a|3y?HSeYfUgfU#9(`2^Di_i-dJ@#CV#3_3+OFkqD4AT1 zvwP%2?LcHysvIiZxLUV4j)w-h%uJ9#cd$;zl>i0i;DwjZ`xjKPUb7pB!juintStzk zt~s#V7z>#uPleOzgs)<8XH@$9N zee;(;etzwZ`7O_{e|-J76QV535=4RCSuUx)$>Sp|i=6IhLA(8Rb?0$^vl$;FgNFos z`k^O`C@-P#zak*X@fm$CS70j!FQAtt+ac1?K`tm)F8W?urlfJ48_(&SYSi5&p7CZs z4lu@;l8VgfK-$=JkY0wv`IvKf<%blu`B7t)A-ivnbh6S)5Ct+Tk=orwEa z($GsrB7Ks$J#hf(VP+yps!fZ18p;^YAeE3#t`H7WP% ({ + ...(e.eid !== undefined ? { eid: e.eid } : {}), + hash: entryRefHash(e.role, e.content), + role: e.role, + source: e.source, + preview: e.content.slice(0, 80), + tokens: e.approxTokens, + })), }), }, ], diff --git a/packages/cli/src/repl/ink/context-viewer.tsx b/packages/cli/src/repl/ink/context-viewer.tsx index 3ab2af22..9239d6e1 100644 --- a/packages/cli/src/repl/ink/context-viewer.tsx +++ b/packages/cli/src/repl/ink/context-viewer.tsx @@ -16,6 +16,14 @@ export interface ContextSections { }; /** Tool calls executed this session (most recent last) */ toolCalls?: Array<{ tool: string; status: string; at: string }>; + /** Entries evicted from the context window — out of the prompt, not erased */ + evicted?: Array<{ + role: string; + source?: string; + preview: string; + actor?: string; + reason?: string; + }>; } export function formatContextLines(sections: ContextSections): string[] { @@ -49,6 +57,20 @@ export function formatContextLines(sections: ContextSections): string[] { lines.push(''); } + if (sections.evicted && sections.evicted.length > 0) { + lines.push('── Evicted from Context ──'); + lines.push('(out of the prompt window — still in the transcript)'); + lines.push(''); + const recentEvicted = sections.evicted.slice(-25).reverse(); + for (const entry of recentEvicted) { + const attribution = [entry.actor, entry.reason].filter(Boolean).join(' · '); + lines.push( + `✕ [${entry.role}${entry.source ? `/${entry.source}` : ''}] ${entry.preview}${attribution ? ` (${attribution})` : ''}` + ); + } + lines.push(''); + } + if (sections.passiveRecallEntries.length > 0) { lines.push('── Memories in Context ──'); lines.push(''); From 4ed81180229c2e035eb68f1e8f3fd240d4f2e82c Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 11 Jun 2026 00:03:18 -0700 Subject: [PATCH 2/2] fix(cli): clean NUL byte in entryRefHash + eid-preferred preview filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../cli/src/commands/chat-hydration.test.ts | 21 +++++++++++++++++ packages/cli/src/commands/chat.ts | 22 +++++++++++------- packages/cli/src/repl/context-ledger.ts | Bin 11396 -> 11396 bytes 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/chat-hydration.test.ts b/packages/cli/src/commands/chat-hydration.test.ts index 5ea9a1f5..c939d3ea 100644 --- a/packages/cli/src/commands/chat-hydration.test.ts +++ b/packages/cli/src/commands/chat-hydration.test.ts @@ -349,6 +349,27 @@ describe('hydrateLedgerFromTranscript — context_evict events', () => { expect(ledger.listEntries().map((e) => e.content)).toContain('bootstrap identity block'); }); + it('keeps the surviving duplicate preview row on eid-specific eviction (regression)', () => { + // Lumen's repro: two identical-content entries; evicting ONE by eid must + // leave the other's preview row intact (content-key fallback must not + // collateral-evict survivors). + writeTranscript([ + { eid: 1, type: 'user', content: 'duplicate' }, + { eid: 2, type: 'user', content: 'duplicate' }, + { eid: 3, type: 'context_evict', actor: 'sb', refs: [{ eid: 1 }] }, + ]); + + const ledger = new ContextLedger(); + const result = hydrateLedgerFromTranscript(ledger, transcriptPath); + + expect(ledger.listEntries()).toHaveLength(1); + expect(ledger.listEntries()[0].eid).toBe(2); + expect(result.messageCount).toBe(1); + expect(result.tailPreview).toHaveLength(1); + expect(result.tailPreview[0].eid).toBe(2); + expect(result.tailPreview[0].content).toBe('duplicate'); + }); + it('reports maxEid so the append counter continues the sequence', () => { writeTranscript([ { eid: 7, type: 'user', content: 'a' }, diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index 0de338a9..a7b511b5 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -686,21 +686,25 @@ export function hydrateLedgerFromTranscript( for (let i = hydratedEntryIds.length - 1; i >= 0; i--) { if (removedLedgerIds.has(hydratedEntryIds[i])) hydratedEntryIds.splice(i, 1); } - // Drop evicted entries from the visible replay and adjust counts + // Drop evicted entries from the visible replay and adjust counts. + // Eid-preferred matching: rows with eids are filtered ONLY by eid — + // content-key matching is reserved for eid-less (legacy) removals. + // Otherwise evicting one of two identical-content entries by eid + // would also drop the survivor's preview row. const removedEids = new Set( evictResult.removedEntries.map((e) => e.eid).filter((v): v is number => v !== undefined) ); - const removedKeys = new Set( - evictResult.removedEntries.map( - (e) => `${e.role} ${compactForHistoryPreview(e.role, e.content)}` - ) + const removedKeysWithoutEid = new Set( + evictResult.removedEntries + .filter((e) => e.eid === undefined) + .map((e) => `${e.role} ${compactForHistoryPreview(e.role, e.content)}`) ); for (let i = preview.length - 1; i >= 0; i--) { const p = preview[i]; - if ( - (p.eid !== undefined && removedEids.has(p.eid)) || - removedKeys.has(`${p.role} ${p.content}`) - ) { + const matchesByEid = p.eid !== undefined && removedEids.has(p.eid); + const matchesByKey = + p.eid === undefined && removedKeysWithoutEid.has(`${p.role} ${p.content}`); + if (matchesByEid || matchesByKey) { preview.splice(i, 1); } } diff --git a/packages/cli/src/repl/context-ledger.ts b/packages/cli/src/repl/context-ledger.ts index 5d89648668d9031d69e66b63fb6c1087adb80465..1942cefa11ca910492175f712797874eac99f1d5 100644 GIT binary patch delta 14 VcmZpPY>C{koSCs^^9trW8UQVv1@r&_ delta 14 VcmZpPY>C{koSBhf^9trW8UQM^1$Y1e