From b56410e896e01ec2a4cad0d0d4e961b76370985c Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 11 Jun 2026 00:50:43 -0700 Subject: [PATCH 1/2] feat(cli): /evict and /evicted commands + context viewer section jumps (by Wren) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-facing layer on top of persistent eviction (#403): - /evict — user-driven context eviction with the same selector grammar as the SB's evict_context tool: entry ids, source:, role:. No selector lists evictable entries (never mutates); --dry-run previews a selection. Persists via context_evict with actor: 'user'. - /evicted — show everything evicted this session, ✕-labeled with actor · reason attribution (out of the prompt window, still in the transcript). - recordEviction: single writer for all three eviction actors (SB tool, user /evict, system trim) so the transcript event shape and the live evicted-display list can't drift. Fixes two inconsistencies: live trims now appear in Ctrl+O's Evicted section without a reattach, and SB evictions now carry their reason like hydrated records do. - Context Inspector section jumps: e/t/m/b keys jump to Evicted, Tool Calls, Memories, Bootstrap. Footer shows available jumps only. Tests: evict-selection parse/match suite, context-viewer jump mapping, hydration regression suite still green. Co-Authored-By: Wren --- packages/cli/src/commands/chat.ts | 184 ++++++++++++++---- packages/cli/src/repl/evict-selection.test.ts | 140 +++++++++++++ packages/cli/src/repl/evict-selection.ts | 119 +++++++++++ .../cli/src/repl/ink/context-viewer.test.ts | 54 +++++ packages/cli/src/repl/ink/context-viewer.tsx | 40 +++- packages/cli/src/repl/slash-commands.ts | 2 + 6 files changed, 503 insertions(+), 36 deletions(-) create mode 100644 packages/cli/src/repl/evict-selection.test.ts create mode 100644 packages/cli/src/repl/evict-selection.ts create mode 100644 packages/cli/src/repl/ink/context-viewer.test.ts diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index a7b511b5..1d9803c7 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -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'; @@ -3001,6 +3006,50 @@ export async function runChat(options: ChatOptions): Promise { 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 @@ -3026,16 +3075,18 @@ export async function runChat(options: ChatOptions): Promise { }); // 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 }; }; @@ -3940,31 +3991,20 @@ export async function runChat(options: ChatOptions): Promise { // 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 - ); - } + 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') { @@ -4740,7 +4780,7 @@ export async function runChat(options: ChatOptions): Promise { '', 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') @@ -4791,6 +4831,8 @@ export async function runChat(options: ChatOptions): Promise { '/bookmarks List bookmarks', '/eject Eject context', '/trim [targetPct] Trim oldest context', + '/evict [sel] [--dry-run] Evict entries (ids, source:, role:)', + '/evicted Show evicted-from-context entries', '/context Show recent entries', '/usage Token estimate', ]); @@ -5845,6 +5887,80 @@ export async function runChat(options: ChatOptions): Promise { } break; } + case 'evict': { + const selection = parseEvictSelection(slash.args); + if (selection.error) { + showInPanel([ + selection.error, + 'Usage: /evict [ids | source: | 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 | /evict source: | /evict 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()); diff --git a/packages/cli/src/repl/evict-selection.test.ts b/packages/cli/src/repl/evict-selection.test.ts new file mode 100644 index 00000000..0e3dd6f6 --- /dev/null +++ b/packages/cli/src/repl/evict-selection.test.ts @@ -0,0 +1,140 @@ +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('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); + }); +}); diff --git a/packages/cli/src/repl/evict-selection.ts b/packages/cli/src/repl/evict-selection.ts new file mode 100644 index 00000000..095904c1 --- /dev/null +++ b/packages/cli/src/repl/evict-selection.ts @@ -0,0 +1,119 @@ +/** + * /evict selection parsing & matching + * + * Pure helpers behind the user-facing /evict command. The grammar mirrors + * the SB's evict_context tool filters so both actors speak the same + * language: + * + * /evict list evictable entries (no mutation) + * /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, no mutation + */ + +import type { LedgerEntry, LedgerRole } from './context-ledger.js'; + +const LEDGER_ROLES: ReadonlySet = new Set(['system', 'user', 'assistant', 'inbox']); +const DRY_RUN_FLAGS: ReadonlySet = new Set(['--dry-run', '--dry', '-n']); + +export interface EvictSelection { + /** True when no selector was given — caller should list evictable entries */ + list: boolean; + dryRun: boolean; + ids?: number[]; + source?: string; + role?: LedgerRole; + error?: string; +} + +export function parseEvictSelection(args: string[]): EvictSelection { + const selection: EvictSelection = { list: false, dryRun: false }; + const ids: number[] = []; + + for (const arg of args) { + if (DRY_RUN_FLAGS.has(arg.toLowerCase())) { + selection.dryRun = true; + continue; + } + if (arg.toLowerCase().startsWith('source:')) { + const source = arg.slice('source:'.length); + if (!source) { + selection.error = 'source: requires a value (e.g., source:heartbeat)'; + return selection; + } + if (selection.source) { + selection.error = 'Only one source: filter allowed'; + return selection; + } + selection.source = source; + continue; + } + if (arg.toLowerCase().startsWith('role:')) { + const role = arg.slice('role:'.length).toLowerCase(); + if (!LEDGER_ROLES.has(role)) { + selection.error = `role: must be one of ${[...LEDGER_ROLES].join(', ')}`; + return selection; + } + if (selection.role) { + selection.error = 'Only one role: filter allowed'; + return selection; + } + selection.role = role as LedgerRole; + continue; + } + // Bare ids, possibly comma-separated: "3,5" or "7" + const parts = arg.split(',').filter(Boolean); + const parsed = parts.map((p) => Number.parseInt(p, 10)); + if (parts.length === 0 || parsed.some((n) => !Number.isFinite(n) || n <= 0)) { + selection.error = `Unrecognized selector: ${arg} (expected entry ids, source:, role:, or --dry-run)`; + return selection; + } + ids.push(...parsed); + } + + if (ids.length > 0 && (selection.source || selection.role)) { + selection.error = 'Combine ids OR a source:/role: filter, not both'; + return selection; + } + if (selection.source && selection.role) { + selection.error = 'Combine source: OR role:, not both'; + return selection; + } + + if (ids.length > 0) { + selection.ids = [...new Set(ids)]; + } else if (!selection.source && !selection.role) { + selection.list = true; + } + return selection; +} + +/** + * Resolve a parsed selection against the current ledger entries. + * Returns the entries that would be evicted, in ledger order. + */ +export function selectEvictionEntries( + entries: LedgerEntry[], + selection: EvictSelection +): LedgerEntry[] { + if (selection.error || selection.list) return []; + if (selection.ids) { + const wanted = new Set(selection.ids); + return entries.filter((e) => wanted.has(e.id)); + } + if (selection.source) { + return entries.filter((e) => e.source === selection.source); + } + if (selection.role) { + return entries.filter((e) => e.role === selection.role); + } + return []; +} + +/** One-line preview of a ledger entry for /evict listings */ +export function formatEvictCandidate(entry: LedgerEntry, previewChars = 70): string { + const src = entry.source ? `/${entry.source}` : ''; + const preview = entry.content.slice(0, previewChars).replace(/\s+/g, ' '); + return `#${entry.id} [${entry.role}${src}] ~${entry.approxTokens} tok · ${preview}`; +} diff --git a/packages/cli/src/repl/ink/context-viewer.test.ts b/packages/cli/src/repl/ink/context-viewer.test.ts new file mode 100644 index 00000000..081b5bc9 --- /dev/null +++ b/packages/cli/src/repl/ink/context-viewer.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { formatContextLines, computeSectionJumps, SECTION_JUMP_KEYS } from './context-viewer.js'; + +const baseSections = { + passiveRecallEntries: [{ content: '[passive-recall] remembered fact', source: 'passive-recall' }], + passiveRecallStats: { totalInjected: 1, uniqueMemories: 1, currentTurn: 3 }, + ledgerStats: { totalEntries: 10, tokenEstimate: 1200, bootstrapTokens: 800 }, +}; + +describe('computeSectionJumps', () => { + it('maps keys to section header lines when sections are present', () => { + const lines = formatContextLines({ + ...baseSections, + bootstrapSummary: 'identity: wren', + toolCalls: [{ tool: 'recall', status: 'executed', at: new Date().toISOString() }], + evicted: [{ role: 'system', source: 'heartbeat', preview: 'old reminder', actor: 'sb' }], + }); + const jumps = computeSectionJumps(lines); + for (const { key, header } of SECTION_JUMP_KEYS) { + expect(jumps.has(key)).toBe(true); + expect(lines[jumps.get(key)!]).toBe(header); + } + }); + + it('omits keys for absent sections', () => { + const lines = formatContextLines({ + ...baseSections, + passiveRecallEntries: [], + }); + const jumps = computeSectionJumps(lines); + expect(jumps.has('e')).toBe(false); // no evicted section + expect(jumps.has('t')).toBe(false); // no tool calls section + expect(jumps.has('m')).toBe(false); // no memories (empty recall) + expect(jumps.has('b')).toBe(false); // no bootstrap summary + }); + + it('evicted section shows attribution and out-of-window note', () => { + const lines = formatContextLines({ + ...baseSections, + evicted: [ + { + role: 'system', + source: 'heartbeat', + preview: 'old reminder', + actor: 'user', + reason: '/evict source:heartbeat', + }, + ], + }); + const joined = lines.join('\n'); + expect(joined).toContain('out of the prompt window — still in the transcript'); + expect(joined).toContain('✕ [system/heartbeat] old reminder (user · /evict source:heartbeat)'); + }); +}); diff --git a/packages/cli/src/repl/ink/context-viewer.tsx b/packages/cli/src/repl/ink/context-viewer.tsx index 9239d6e1..a36531bb 100644 --- a/packages/cli/src/repl/ink/context-viewer.tsx +++ b/packages/cli/src/repl/ink/context-viewer.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import { Box, Text, useInput, useStdout } from 'ink'; export interface ContextSections { @@ -96,6 +96,26 @@ export function formatContextLines(sections: ContextSections): string[] { return lines; } +/** + * Single-key section jumps for the viewer. Maps a key to the line index of + * its section header, when the section is present in the rendered lines. + */ +export const SECTION_JUMP_KEYS: ReadonlyArray<{ key: string; header: string; label: string }> = [ + { key: 'e', header: '── Evicted from Context ──', label: 'evicted' }, + { key: 't', header: '── Recent Tool Calls ──', label: 'tools' }, + { key: 'm', header: '── Memories in Context ──', label: 'memories' }, + { key: 'b', header: '── Bootstrap Context ──', label: 'bootstrap' }, +]; + +export function computeSectionJumps(lines: string[]): Map { + const jumps = new Map(); + for (const { key, header } of SECTION_JUMP_KEYS) { + const index = lines.indexOf(header); + if (index >= 0) jumps.set(key, index); + } + return jumps; +} + interface ContextViewerProps { lines: string[]; isActive: boolean; @@ -111,6 +131,7 @@ export function ContextViewer({ const viewportHeight = Math.max(5, (stdout?.rows || 24) - 4); const [scrollOffset, setScrollOffset] = useState(0); const maxScroll = Math.max(0, lines.length - viewportHeight); + const sectionJumps = useMemo(() => computeSectionJumps(lines), [lines]); const scrollUp = useCallback( (amount = 1) => setScrollOffset((prev) => Math.max(0, prev - amount)), @@ -161,6 +182,14 @@ export function ContextViewer({ scrollDown(Math.floor(viewportHeight / 2)); return; } + // Section jumps (e: evicted, t: tools, m: memories, b: bootstrap) + if (!key.ctrl && !key.meta) { + const target = sectionJumps.get(input.toLowerCase()); + if (target !== undefined) { + setScrollOffset(Math.min(maxScroll, target)); + return; + } + } }, { isActive } ); @@ -197,7 +226,14 @@ export function ContextViewer({ })} - q/esc: close · ↑↓/j/k: scroll · ctrl+u/d: page + + q/esc: close · ↑↓/j/k: scroll · ctrl+u/d: page + {sectionJumps.size > 0 + ? ` · ${SECTION_JUMP_KEYS.filter((s) => sectionJumps.has(s.key)) + .map((s) => `${s.key}: ${s.label}`) + .join(' · ')}` + : ''} + {position} {scrollPct}% diff --git a/packages/cli/src/repl/slash-commands.ts b/packages/cli/src/repl/slash-commands.ts index 4207d729..7f8b276c 100644 --- a/packages/cli/src/repl/slash-commands.ts +++ b/packages/cli/src/repl/slash-commands.ts @@ -49,6 +49,8 @@ export const SLASH_COMMANDS: SlashCommand[] = [ { name: 'bookmarks', description: 'List bookmarks' }, { name: 'eject', description: 'Eject context to bookmark' }, { name: 'trim', description: 'Trim oldest context entries' }, + { name: 'evict', description: 'Evict context entries (ids, source:, role:)' }, + { name: 'evicted', description: 'Show evicted-from-context entries' }, { name: 'context', description: 'Open context inspector (bootstrap, memories, ledger)' }, { name: 'usage', description: 'Show context token estimate' }, ]; From 9dafe3a5819ac00d5ee7b592d4803a1238527f51 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 11 Jun 2026 00:58:13 -0700 Subject: [PATCH 2/2] fix(cli): require full positive integers for /evict id selectors (by Wren) Lumen review catch on #404: Number.parseInt accepts '1abc', '1.5', and '1e3' as 1, so a typo'd selector would silently evict entry #1. Each comma-separated token must now match /^\d+$/ before parsing. Regression tests cover the truncation cases. Co-Authored-By: Wren --- packages/cli/src/repl/evict-selection.test.ts | 11 +++++++++++ packages/cli/src/repl/evict-selection.ts | 12 +++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/repl/evict-selection.test.ts b/packages/cli/src/repl/evict-selection.test.ts index 0e3dd6f6..4094483c 100644 --- a/packages/cli/src/repl/evict-selection.test.ts +++ b/packages/cli/src/repl/evict-selection.test.ts @@ -69,6 +69,17 @@ describe('parseEvictSelection', () => { 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'); diff --git a/packages/cli/src/repl/evict-selection.ts b/packages/cli/src/repl/evict-selection.ts index 095904c1..2b6330dc 100644 --- a/packages/cli/src/repl/evict-selection.ts +++ b/packages/cli/src/repl/evict-selection.ts @@ -62,13 +62,19 @@ export function parseEvictSelection(args: string[]): EvictSelection { selection.role = role as LedgerRole; continue; } - // Bare ids, possibly comma-separated: "3,5" or "7" + // Bare ids, possibly comma-separated: "3,5" or "7". Each token must be a + // full positive integer — parseInt would accept "1abc"/"1.5"/"1e3" as 1 + // and silently evict the wrong entry. const parts = arg.split(',').filter(Boolean); - const parsed = parts.map((p) => Number.parseInt(p, 10)); - if (parts.length === 0 || parsed.some((n) => !Number.isFinite(n) || n <= 0)) { + if (parts.length === 0 || parts.some((p) => !/^\d+$/.test(p))) { selection.error = `Unrecognized selector: ${arg} (expected entry ids, source:, role:, or --dry-run)`; return selection; } + const parsed = parts.map((p) => Number.parseInt(p, 10)); + if (parsed.some((n) => n <= 0)) { + selection.error = `Entry ids must be positive integers: ${arg}`; + return selection; + } ids.push(...parsed); }