Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 150 additions & 34 deletions packages/cli/src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ import {
type LedgerRole,
} from '../repl/context-ledger.js';
import { parseSlashCommand } from '../repl/slash.js';
import {
parseEvictSelection,
selectEvictionEntries,
formatEvictCandidate,
} from '../repl/evict-selection.js';
import { ToolMode, ToolPolicyScopeKind, ToolPolicyState } from '../repl/tool-policy.js';
import { formatBackendTokenUsage, type BackendTokenUsage } from '../repl/token-usage.js';
import { discoverSkills, loadSkillInstruction, type SkillInstruction } from '../repl/skills.js';
Expand Down Expand Up @@ -3001,6 +3006,50 @@ export async function runChat(options: ChatOptions): Promise<void> {
return sessionsCache;
};

// ── Persistent eviction (single writer) ──
// Every eviction — SB tool, user /evict, system trim — flows through here
// so the transcript event shape and the live evicted-display list can't
// drift between actors. The context_evict event is what makes the
// eviction survive reattach; sessionEvictedEntries is what Ctrl+O and
// /evicted show right now.
const recordEviction = (
actor: 'sb' | 'user' | 'system',
reason: string,
removedTokens: number,
refs: Array<{
eid?: number;
hash: string;
role: LedgerRole;
source?: string;
preview: string;
}>
): void => {
if (refs.length === 0) return;
appendTranscript(runtime.transcriptPath, {
type: 'context_evict',
actor,
reason,
removedTokens,
refs: refs.map((ref) => ({
...(typeof ref.eid === 'number' ? { eid: ref.eid } : {}),
hash: ref.hash,
})),
});
for (const ref of refs) {
sessionEvictedEntries.push({
role: ref.role,
content: ref.preview,
source: ref.source,
eid: ref.eid,
actor,
reason,
});
}
if (sessionEvictedEntries.length > EVICTED_DISPLAY_MAX) {
sessionEvictedEntries.splice(0, sessionEvictedEntries.length - EVICTED_DISPLAY_MAX);
}
};

const trimContextToPercent = async (
targetPercent: number,
reason: string
Expand All @@ -3026,16 +3075,18 @@ export async function runChat(options: ChatOptions): Promise<void> {
});
// 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) => ({
recordEviction(
'system',
`trim: ${reason}`,
trim.removedTokens,
trim.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, 100),
}))
);

return { removed: trim.removedEntries.length, removedTokens: trim.removedTokens };
};
Expand Down Expand Up @@ -3940,31 +3991,20 @@ export async function runChat(options: ChatOptions): Promise<void> {
// 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<Record<string, unknown>>;
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
);
}
recordEviction(
'sb',
compactForLedger(JSON.stringify(result.args ?? {}), 200),
typeof parsed.tokensFreed === 'number' ? parsed.tokensFreed : 0,
refs
.filter((ref) => typeof ref.hash === 'string')
.map((ref) => ({
...(typeof ref.eid === 'number' ? { eid: ref.eid } : {}),
hash: ref.hash as string,
role: (ref.role as LedgerRole) || 'system',
source: typeof ref.source === 'string' ? ref.source : undefined,
preview: typeof ref.preview === 'string' ? ref.preview : '',
}))
);
}
}
} else if (result.tool === 'list_context') {
Expand Down Expand Up @@ -4740,7 +4780,7 @@ export async function runChat(options: ChatOptions): Promise<void> {
'',
chalk.bold('Quick commands'),
chalk.dim(
'/help /mcp /capabilities /skills /profile /policy /away /tool-routing /save-config /ui /trim /quit'
'/help /mcp /capabilities /skills /profile /policy /away /tool-routing /save-config /ui /trim /evict /quit'
),
'',
].join('\n')
Expand Down Expand Up @@ -4791,6 +4831,8 @@ export async function runChat(options: ChatOptions): Promise<void> {
'/bookmarks List bookmarks',
'/eject <bookmark|last> Eject context',
'/trim [targetPct] Trim oldest context',
'/evict [sel] [--dry-run] Evict entries (ids, source:<x>, role:<x>)',
'/evicted Show evicted-from-context entries',
'/context Show recent entries',
'/usage Token estimate',
]);
Expand Down Expand Up @@ -5845,6 +5887,80 @@ export async function runChat(options: ChatOptions): Promise<void> {
}
break;
}
case 'evict': {
const selection = parseEvictSelection(slash.args);
if (selection.error) {
showInPanel([
selection.error,
'Usage: /evict [ids | source:<name> | role:<role>] [--dry-run]',
]);
break;
}
if (selection.list) {
// No selector — show the pick list, never mutate
const entries = ledger.listEntries();
if (entries.length === 0) {
showInPanel(['Context is empty — nothing to evict.']);
break;
}
showInPanel([
`Evictable entries (${entries.length}, ~${ledger.totalTokens().toLocaleString()} tok):`,
...entries.map((e) => formatEvictCandidate(e)),
'',
'Evict with: /evict <ids> | /evict source:<name> | /evict role:<role> [--dry-run]',
]);
break;
}
const matched = selectEvictionEntries(ledger.listEntries(), selection);
if (matched.length === 0) {
showInPanel(['No context entries match that selection.']);
break;
}
const matchedTokens = matched.reduce((sum, e) => sum + e.approxTokens, 0);
if (selection.dryRun) {
showInPanel([
`Would evict ${matched.length} entries (~${matchedTokens.toLocaleString()} tok):`,
...matched.map((e) => formatEvictCandidate(e)),
'',
'Re-run without --dry-run to evict.',
]);
break;
}
const evictResult = ledger.evictEntries(matched.map((e) => e.id));
recordEviction(
'user',
`/evict ${slash.args.filter((a) => !a.startsWith('--')).join(' ')}`,
evictResult.removedTokens,
evictResult.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, 100),
}))
);
printEvent(
chalk.dim(
` 🗑 evicted ${evictResult.removedEntries.length} entries (~${evictResult.removedTokens.toLocaleString()} tok freed, ~${evictResult.totalAfter.toLocaleString()} tok remaining) — /evicted to review`
)
);
break;
}
case 'evicted': {
if (sessionEvictedEntries.length === 0) {
showInPanel(['Nothing evicted from context this session.']);
break;
}
const lines = [
`${sessionEvictedEntries.length} entries evicted — out of the prompt window, still in the transcript:`,
...sessionEvictedEntries.map((e) => {
const attribution = [e.actor, e.reason].filter(Boolean).join(' · ');
return `✕ [${e.role}${e.source ? `/${e.source}` : ''}] ${e.content.slice(0, 100)}${attribution ? ` (${attribution})` : ''}`;
}),
];
showInPanel(lines);
break;
}
case 'context': {
if (inkRepl) {
inkRepl.showContextView(buildContextViewLines());
Expand Down
151 changes: 151 additions & 0 deletions packages/cli/src/repl/evict-selection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { describe, it, expect } from 'vitest';
import {
parseEvictSelection,
selectEvictionEntries,
formatEvictCandidate,
} from './evict-selection.js';
import { ContextLedger } from './context-ledger.js';

describe('parseEvictSelection', () => {
it('returns list mode when no selector given', () => {
expect(parseEvictSelection([])).toEqual({ list: true, dryRun: false });
});

it('list mode with dry-run flag alone', () => {
const sel = parseEvictSelection(['--dry-run']);
expect(sel.list).toBe(true);
expect(sel.dryRun).toBe(true);
});

it('parses bare ids', () => {
const sel = parseEvictSelection(['3', '5']);
expect(sel.ids).toEqual([3, 5]);
expect(sel.list).toBe(false);
});

it('parses comma-separated ids and dedupes', () => {
const sel = parseEvictSelection(['3,5', '5', '7']);
expect(sel.ids).toEqual([3, 5, 7]);
});

it('parses source: filter', () => {
const sel = parseEvictSelection(['source:heartbeat']);
expect(sel.source).toBe('heartbeat');
expect(sel.ids).toBeUndefined();
});

it('parses role: filter', () => {
const sel = parseEvictSelection(['role:inbox']);
expect(sel.role).toBe('inbox');
});

it('rejects invalid role', () => {
const sel = parseEvictSelection(['role:bogus']);
expect(sel.error).toMatch(/role: must be one of/);
});

it('rejects empty source value', () => {
const sel = parseEvictSelection(['source:']);
expect(sel.error).toMatch(/source: requires a value/);
});

it('rejects mixing ids with filters', () => {
const sel = parseEvictSelection(['3', 'source:heartbeat']);
expect(sel.error).toMatch(/not both/);
});

it('rejects mixing source and role', () => {
const sel = parseEvictSelection(['source:heartbeat', 'role:inbox']);
expect(sel.error).toMatch(/not both/);
});

it('rejects garbage selectors', () => {
const sel = parseEvictSelection(['heartbeat']);
expect(sel.error).toMatch(/Unrecognized selector/);
});

it('rejects zero and negative ids', () => {
expect(parseEvictSelection(['0']).error).toBeTruthy();
expect(parseEvictSelection(['-3']).error).toBeTruthy();
});

it('rejects malformed numeric tokens that parseInt would truncate', () => {
// Each of these parses to 1 under Number.parseInt — accepting them
// would silently evict entry #1 instead of erroring
expect(parseEvictSelection(['1abc']).error).toMatch(/Unrecognized selector/);
expect(parseEvictSelection(['1.5']).error).toMatch(/Unrecognized selector/);
expect(parseEvictSelection(['1e3']).error).toMatch(/Unrecognized selector/);
expect(parseEvictSelection(['3,abc']).error).toMatch(/Unrecognized selector/);
expect(parseEvictSelection(['+3']).error).toMatch(/Unrecognized selector/);
expect(parseEvictSelection(['3 ', '0x2']).error).toBeTruthy();
});

it('accepts dry-run alongside a filter', () => {
const sel = parseEvictSelection(['source:heartbeat', '--dry-run']);
expect(sel.source).toBe('heartbeat');
expect(sel.dryRun).toBe(true);
expect(sel.error).toBeUndefined();
});
});

describe('selectEvictionEntries', () => {
const buildLedger = () => {
const ledger = new ContextLedger();
ledger.addEntry('system', 'heartbeat reminder one', 'heartbeat');
ledger.addEntry('user', 'hello there');
ledger.addEntry('assistant', 'hi! how can I help?');
ledger.addEntry('system', 'heartbeat reminder two', 'heartbeat');
ledger.addEntry('inbox', 'message from lumen', 'inbox-poll');
return ledger;
};

it('selects by ids in ledger order', () => {
const entries = buildLedger().listEntries();
const sel = parseEvictSelection(['4,1']);
const matched = selectEvictionEntries(entries, sel);
expect(matched.map((e) => e.id)).toEqual([1, 4]);
});

it('selects by source', () => {
const entries = buildLedger().listEntries();
const matched = selectEvictionEntries(entries, parseEvictSelection(['source:heartbeat']));
expect(matched).toHaveLength(2);
expect(matched.every((e) => e.source === 'heartbeat')).toBe(true);
});

it('selects by role', () => {
const entries = buildLedger().listEntries();
const matched = selectEvictionEntries(entries, parseEvictSelection(['role:inbox']));
expect(matched.map((e) => e.role)).toEqual(['inbox']);
});

it('returns nothing for list mode and errors', () => {
const entries = buildLedger().listEntries();
expect(selectEvictionEntries(entries, parseEvictSelection([]))).toEqual([]);
expect(selectEvictionEntries(entries, parseEvictSelection(['role:bogus']))).toEqual([]);
});

it('ignores unknown ids silently', () => {
const entries = buildLedger().listEntries();
const matched = selectEvictionEntries(entries, parseEvictSelection(['99']));
expect(matched).toEqual([]);
});
});

describe('formatEvictCandidate', () => {
it('renders id, role/source, tokens, preview', () => {
const ledger = new ContextLedger();
const entry = ledger.addEntry('system', 'heartbeat reminder with extra space', 'heartbeat');
const line = formatEvictCandidate(entry);
expect(line).toContain(`#${entry.id}`);
expect(line).toContain('[system/heartbeat]');
expect(line).toContain('heartbeat reminder with extra space');
});

it('truncates long previews', () => {
const ledger = new ContextLedger();
const entry = ledger.addEntry('user', 'x'.repeat(500));
const line = formatEvictCandidate(entry, 40);
expect(line.length).toBeLessThan(120);
});
});
Loading
Loading