diff --git a/packages/cli/src/commands/chat-hydration.test.ts b/packages/cli/src/commands/chat-hydration.test.ts index 5e09fc60..c939d3ea 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,140 @@ 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('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' }, + { 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..a7b511b5 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,77 @@ 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. + // 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 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]; + 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); + } + } + 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 +767,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 +776,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 +787,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 +850,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 +861,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 +883,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 +902,8 @@ export function hydrateLedgerFromTranscript( seenActivityIds: Array.from(seenActivityIds), recoveredMemoryIds, compactionCollapsed, + evictedEntries, + maxEid, }; } @@ -853,8 +981,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 +2369,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 +2668,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 +3024,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 +3111,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 +3931,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 +4545,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 bfc8e25f..1942cefa 100644 --- a/packages/cli/src/repl/context-ledger.ts +++ b/packages/cli/src/repl/context-ledger.ts @@ -1,3 +1,5 @@ +import { createHash } from 'crypto'; + export type LedgerRole = 'system' | 'user' | 'assistant' | 'inbox'; export interface LedgerEntry { @@ -7,6 +9,22 @@ export interface LedgerEntry { source?: string; createdAt: string; approxTokens: number; + /** + * Transcript event id this entry was hydrated from (file-relative, + * stamped by appendTranscript). Undefined for live entries that haven't + * been individually tracked — eviction refs fall back to content hash. + */ + eid?: number; +} + +/** + * Content-addressed reference for an entry — used by persistent eviction + * (context_evict transcript events) to identify entries across reattach. + * Stable as long as the role + stored content are reproduced identically + * by hydration (they are — ledger transformations are deterministic). + */ +export function entryRefHash(role: string, content: string): string { + return 'sha1:' + createHash('sha1').update(`${role}|${content}`).digest('hex').slice(0, 16); } export interface LedgerBookmark { @@ -62,7 +80,7 @@ export class ContextLedger { private entrySeq = 1; private bookmarkSeq = 1; - public addEntry(role: LedgerRole, content: string, source?: string): LedgerEntry { + public addEntry(role: LedgerRole, content: string, source?: string, eid?: number): LedgerEntry { const entry: LedgerEntry = { id: this.entrySeq++, role, @@ -70,11 +88,33 @@ export class ContextLedger { source, createdAt: new Date().toISOString(), approxTokens: estimateTokens(content), + ...(eid !== undefined ? { eid } : {}), }; this.entries.push(entry); return entry; } + /** + * Find entry IDs matching persistent eviction refs. Matches by eid when + * the ref carries one (precise), otherwise by content hash (legacy / + * live entries — identical role+content duplicates match together). + */ + public findEntriesByRefs(refs: Array<{ eid?: number; hash?: string }>): number[] { + const eids = new Set(refs.map((r) => r.eid).filter((v): v is number => typeof v === 'number')); + const hashes = new Set( + refs.filter((r) => r.eid === undefined && typeof r.hash === 'string').map((r) => r.hash) + ); + const ids: number[] = []; + for (const entry of this.entries) { + if (entry.eid !== undefined && eids.has(entry.eid)) { + ids.push(entry.id); + } else if (hashes.size > 0 && hashes.has(entryRefHash(entry.role, entry.content))) { + ids.push(entry.id); + } + } + return ids; + } + public listEntries(): LedgerEntry[] { return [...this.entries]; } diff --git a/packages/cli/src/repl/context-tools.ts b/packages/cli/src/repl/context-tools.ts index 403a3738..338bfc49 100644 --- a/packages/cli/src/repl/context-tools.ts +++ b/packages/cli/src/repl/context-tools.ts @@ -10,7 +10,7 @@ * but the CLI intercepts and handles them locally. */ -import type { ContextLedger, LedgerEvictResult } from './context-ledger.js'; +import { entryRefHash, type ContextLedger, type LedgerEvictResult } from './context-ledger.js'; import type { PcpToolCallResult } from '../lib/pcp-client.js'; // ─── Session Status Signal ────────────────────────────────────── @@ -172,6 +172,16 @@ function handleEvictContext( tokens: e.approxTokens, preview: e.content.slice(0, 80), })), + // Persistent eviction refs — the runtime writes these to a + // context_evict transcript event so the eviction survives reattach + evictRefs: result.removedEntries.map((e) => ({ + ...(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('');