Merge branch 'spill-markers' into integration/audit-fixes
1- Added spill markers to move large or secret pasted text outside the transcript with file stubs and session reaping.95 unmodified lines96979899100101102103104105109 unmodified linesExpand all95 unmodified lines| `app.input.clear` | `escape` | Clear input || `app.exit` | `ctrl+d` | Exit (when editor empty) || `app.suspend` | `ctrl+z` (none on Windows) | Suspend to background || `app.editor.external` | `ctrl+g` | Open in external editor (`$VISUAL` or `$EDITOR`) || `app.editor.spill` | `alt+k` | Convert most recent paste to spill || `app.editor.spillSecret` | `shift+alt+k` | Convert most recent paste to secret spill || `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows) | Paste image from clipboard || `app.clipboard.copyLoginUrl` | `c`, `alt+c` | Copy the sign-in URL from a login dialog |
### Sessions109 unmodified linesExpand all257 unmodified linesExpand all258259260261262263264265266267268269270271272273154 unmodified linesExpand all257 unmodified linesExpand all| `terminal.clearOnShrink` | boolean | `false` | Clear empty rows when content shrinks (can cause flicker) || `images.autoResize` | boolean | `true` | Resize images to 2000x2000 max || `images.blockImages` | boolean | `false` | Block all images from being sent to LLM |
### Pastes & Spills
| Setting | Type | Default | Description ||---------|------|---------|-------------|| `spills.thresholdBytes` | number | `8192` | Byte threshold to automatically spill pasted text outside the transcript || `spills.thresholdLines` | number | `200` | Line count threshold to automatically spill pasted text outside the transcript || `spills.reaping` | boolean | `true` | Best-effort delete session spill directory on session shutdown and `/new` |
### Shell
| Setting | Type | Default | Description ||---------|------|---------|-------------|154 unmodified linesExpand all123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132import { existsSync, readFileSync, rmSync, statSync } from "node:fs";import { tmpdir } from "node:os";import { join } from "node:path";import { fauxAssistantMessage } from "@earendil-works/pi-ai";import { describe, expect, test } from "vitest";import { collectMarkedSpills, evictSpillsToBudget, formatSpillMarker, formatSpillStub, reapSessionSpills, remapSpillMarkers, resolveSpillsInText, type SpillEntry, writeSpillFile,} from "../src/modes/interactive/spill-markers.js";import { createHarness, getUserTexts } from "./suite/harness.js";
describe("spill markers pure logic", () => { test("collectMarkedSpills resolves only markers still present, in paste order (delete drops payload)", () => { const pending = new Map<number, string>([ [1, "first"], [2, "second"], ]); expect(collectMarkedSpills(pending, "[spill #2] then [spill #1]")).toEqual(["first", "second"]); expect(collectMarkedSpills(pending, "kept [spill #1] only")).toEqual(["first"]); expect(collectMarkedSpills(pending, "deleted marker drops payload")).toEqual([]); });
test("a restored marker still resolves its spill (undo-safe)", () => { const pending = new Map<number, string>([[1, "payload"]]); expect(collectMarkedSpills(pending, "no marker")).toEqual([]); expect(collectMarkedSpills(pending, "back [spill #1]")).toEqual(["payload"]); });
test("remapSpillMarkers updates matching marker ids", () => { expect(remapSpillMarkers("[spill #1] [spill #2]", new Map([[1, 10]]))).toBe("[spill #10] [spill #2]"); });
test("writeSpillFile writes atomic file with mode 0600 and directory 0700", () => { const tempDir = join(tmpdir(), `spill-direct-${Date.now()}`); const entry: SpillEntry = { id: 42, content: "direct payload", mode: "blob" }; const res = writeSpillFile(entry, { sessionId: "s1", agentDir: tempDir }); expect(existsSync(res.path)).toBe(true); expect(readFileSync(res.path, "utf-8")).toBe("direct payload"); expect(statSync(res.path).mode & 0o777).toBe(0o600); rmSync(tempDir, { recursive: true, force: true }); });
test("formatSpillStub formats blob and secret instructions accurately", () => { const info = { path: "/tmp/test.txt", bytes: 1234, lines: 45, sha256: "abc123", mode: "blob" as const, }; const blobStub = formatSpillStub(info); expect(blobStub).toContain("Read only the slices you need (sed/rg); do not dump the whole file into context."); expect(blobStub).toContain("Delete the file once it is no longer needed.");
const secretStub = formatSpillStub({ ...info, mode: "secret" }); expect(secretStub).toContain( "Do NOT read this into context at all - pass the path to the command that needs it.", ); expect(secretStub).toContain("Delete the file once it is no longer needed."); });
const size = (s: string) => s.length;
test.each([ { name: "drops oldest entries until within budget", budget: 8, kept: [] as number[], expected: [2, 3] }, { name: "never evicts kept ids, even past budget", budget: 8, kept: [1], expected: [1, 3] }, { name: "keeps everything when all ids are kept", budget: 1, kept: [1, 2, 3], expected: [1, 2, 3] }, { name: "is a no-op within budget", budget: 100, kept: [], expected: [1, 2, 3] }, ])("evictSpillsToBudget $name", ({ budget, kept, expected }) => { const spills = new Map([ [1, "aaaa"], [2, "bbbb"], [3, "cccc"], ]); evictSpillsToBudget(spills, size, budget, new Set(kept)); expect([...spills.keys()]).toEqual(expected); });});
describe("spill files and session transcript exclusion", () => { test("spill resolution writes 0600 file, emits stub, excludes payload from session jsonl, and reaps", async () => { const harness = await createHarness({ persistSession: true }); try { const sessionId = harness.session.sessionId; const agentDir = harness.tempDir; const payload = "SECRET_TOKEN_XYZ_987654321_DO_NOT_LEAK"; const spills = new Map<number, SpillEntry>([[1, { id: 1, content: payload, mode: "secret" }]]);
const input = `check secret: ${formatSpillMarker(1)}`; const resolved = resolveSpillsInText(input, spills, { sessionId, agentDir });
// (a) file exists at expected path with byte-identical content and mode 0600 const written = resolved.writtenFiles[0]!; expect(written).toBeDefined(); expect(existsSync(written.path)).toBe(true); expect(readFileSync(written.path, "utf-8")).toBe(payload); expect(statSync(written.path).mode & 0o777).toBe(0o600);
// (b) user text session receives contains stub and not payload harness.setResponses([fauxAssistantMessage("acknowledged")]); await harness.session.prompt(resolved.text); const userTexts = getUserTexts(harness); expect(userTexts[0]).toContain(written.path); expect(userTexts[0]).not.toContain(payload);
// (c) persisted session jsonl contains payload string nowhere (the regression test) const sessionJsonl = readFileSync(harness.session.sessionFile!, "utf-8"); expect(sessionJsonl.includes(payload)).toBe(false);
// (d) secret-mode stub says what it must say expect(userTexts[0]).toContain( "Do NOT read this into context at all - pass the path to the command that needs it.", ); expect(userTexts[0]).toContain("Delete the file once it is no longer needed.");
// Reaper: mid-session file is retained; reap removes directory expect(existsSync(written.path)).toBe(true); const reaped = reapSessionSpills(sessionId, agentDir); expect(reaped.length).toBe(1); expect(existsSync(written.path)).toBe(false); } finally { harness.cleanup(); } });});1- Added spill marker atomicity and paste hooks to the interactive editor.49 unmodified lines505152535455565758596061626364656667686911 unmodified lines49 unmodified lines getPasteSnapshot?(): EditorPasteSnapshot;
restorePasteSnapshot?(snapshot: EditorPasteSnapshot): void;
/** Hook called when text is pasted. If it returns true, normal paste insertion is bypassed. */ onPasteText?: (text: string) => boolean | undefined;
/** Callback after a paste has been inserted in the editor. */ onPasteRecorded?: (info: { pasteId?: number; text: string; marker?: string }) => void;
/** Get raw content of a paste by its paste ID. */ getPaste?(id: number): string | undefined;
/** Delete a paste entry by ID so it won't expand. */ deletePaste?(id: number): boolean;
/** Set the autocomplete provider */ setAutocompleteProvider?(provider: AutocompleteProvider): void;
/** Border color function */11 unmodified lines26 unmodified lines2728293031323334353678 unmodified lines115116117118119120121122123124125126127128129130109 unmodified linesExpand all240241242243244245246247248249164 unmodified linesExpand all26 unmodified lines "app.subagents.focus": true; "app.heartbeats.open": true; "app.heartbeats.openSelected": true; "app.editor.external": true; "app.editor.spill": true; "app.editor.spillSecret": true; "app.prompt.stash": true; "app.message.followUp": true; "app.message.navigateOlder": true; "app.message.navigateNewer": true;78 unmodified lines "app.editor.external": { defaultKeys: "ctrl+g", description: "Open external editor", }, "app.editor.spill": { defaultKeys: "alt+k", description: "Convert most recent paste to spill", }, "app.editor.spillSecret": { defaultKeys: "shift+alt+k", description: "Convert most recent paste to secret spill", }, "app.prompt.stash": { defaultKeys: "ctrl+s", description: "Stash or restore draft prompt", },109 unmodified linesExpand all description: "Tree filter: cycle backward", },} as const satisfies KeybindingDefinitions;
export const DEFAULT_APP_KEYBINDINGS = KEYBINDINGS;
const KEYBINDING_NAME_MIGRATIONS = { "app.message.dequeue": "app.message.navigateOlder", cursorUp: "tui.editor.cursorUp", cursorDown: "tui.editor.cursorDown",164 unmodified linesExpand all63 unmodified lines6465666768697071727374757677175 unmodified linesExpand all2532542552562572582592602611161 unmodified linesExpand all14231424142514261427142814291430143114321433143414351436143714381439144014411442103 unmodified linesExpand all63 unmodified lines autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility) blockImages?: boolean; // default: false - when true, prevents all images from being sent to LLM providers}
export interface SpillSettings { thresholdBytes?: number; // default: 8192 (~8 KB) thresholdLines?: number; // default: 200 (~200 lines) reaping?: boolean; // default: true}
export interface ThinkingBudgetsSettings { minimal?: number; low?: number; medium?: number;175 unmodified linesExpand all bundledSkills?: BundledSkillsSettings; // Configure built-in skills shipped with Prime Agent enableBuiltinSkills?: boolean; // default: true - load built-in skills shipped with prime-agent terminal?: TerminalSettings; images?: ImageSettings; spills?: SpillSettings; enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) treeFilterMode?: "default" | "no-tools" | "user-only" | "labeled-only" | "all"; // Default: "user-only" thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels editorPaddingX?: number; // Horizontal padding for input editor (default: 0)1161 unmodified linesExpand all this.markModified("images", "blockImages"); this.save(); }
getSpillThresholdBytes(): number { return this.settings.spills?.thresholdBytes ?? 8192; }
getSpillThresholdLines(): number { return this.settings.spills?.thresholdLines ?? 200; }
getSpillReaping(): boolean { return this.settings.spills?.reaping ?? true; }
getEnabledModels(): string[] | undefined { return this.settings.enabledModels; }
103 unmodified linesExpand all25 unmodified lines262728293031323334353637383039403241424344454647486 unmodified lines5556575859604861626364658 unmodified lines74757677787980818283848586253 unmodified linesExpand all340341342343344345346347348349739 unmodified linesExpand all1089109010911092109310941095109610971098109911001101110211031104162 unmodified linesExpand all1267126812691270127112721273127412751276127712787 unmodified lines1286128712881289129012911292129312941295129612971298129913001301130213031304130513061142 unmodified linesExpand all25 unmodified lines
/** Non-global version for single-segment testing. */const IMAGE_MARKER_SINGLE = /^\[image #(\d+)\]$/;
/** * Regex matching spill markers like `[spill #1]`. Kept in sync with the marker * format produced by the coding agent's spill-markers helper. */const SPILL_MARKER_REGEX = /\[spill #(\d+)\]/g;
/** Non-global version for single-segment testing. */const SPILL_MARKER_SINGLE = /^\[spill #(\d+)\]$/;
/** Check if a segment is an atomic marker (paste or image) merged by segmentWithMarkers. *//** Check if a segment is an atomic marker (paste, image, or spill) merged by segmentWithMarkers. */function isAtomicMarker(segment: string): boolean { return segment.length >= 10 && (PASTE_MARKER_SINGLE.test(segment) || IMAGE_MARKER_SINGLE.test(segment)); return ( segment.length >= 10 && (PASTE_MARKER_SINGLE.test(segment) || IMAGE_MARKER_SINGLE.test(segment) || SPILL_MARKER_SINGLE.test(segment)) );}
/** * A segmenter that wraps Intl.Segmenter and merges graphemes that fall6 unmodified lines */function segmentWithMarkers(text: string, validPasteIds: Set<number>): Iterable<Intl.SegmentData> { const hasPaste = validPasteIds.size > 0 && text.includes("[paste #"); const hasImage = text.includes("[image #"); const hasSpill = text.includes("[spill #");
if (!hasPaste && !hasImage) { if (!hasPaste && !hasImage && !hasSpill) { return baseSegmenter.segment(text); }
const markers: Array<{ start: number; end: number }> = [];8 unmodified lines for (const m of text.matchAll(IMAGE_MARKER_REGEX)) { markers.push({ start: m.index, end: m.index + m[0].length }); } } if (hasSpill) { for (const m of text.matchAll(SPILL_MARKER_REGEX)) { markers.push({ start: m.index, end: m.index + m[0].length }); } } if (markers.length === 0) { return baseSegmenter.segment(text); } markers.sort((a, b) => a.start - b.start);253 unmodified linesExpand all
public onSubmit?: (text: string) => void; public onChange?: (text: string) => void; public disableSubmit: boolean = false; public onPasteText?: (text: string) => boolean | undefined; public onPasteRecorded?: (info: { pasteId?: number; text: string; marker?: string }) => void;
constructor(tui: TUI, theme: EditorTheme, options: EditorOptions = {}) { this.tui = tui; this.theme = theme;739 unmodified linesExpand all this.pastes = new Map(snapshot.pastes); this.pasteCounter = snapshot.pasteCounter; }
getPaste(id: number): string | undefined { return this.pastes.get(id); }
deletePaste(id: number): boolean { return this.pastes.delete(id); }
getLines(): string[] { return [...this.state.lines]; }
162 unmodified linesExpand all filteredText = ` ${filteredText}`; } }
if (this.onPasteText && this.onPasteText(filteredText) === true) { return; }
// Split into lines to check for large paste const pastedLines = filteredText.split("\n");
// Check if this is a large paste (> 10 lines or > 1000 characters)7 unmodified lines pastedLines.length > 10 ? `[paste #${pasteId} +${pastedLines.length} lines]` : `[paste #${pasteId} ${totalChars} chars]`; this.insertTextAtCursorInternal(marker); this.onPasteRecorded?.({ pasteId, text: filteredText, marker }); return; }
if (pastedLines.length === 1) { this.insertTextAtCursorInternal(filteredText); this.onPasteRecorded?.({ text: filteredText }); return; }
// Multi-line paste - use direct state manipulation this.insertTextAtCursorInternal(filteredText); this.onPasteRecorded?.({ text: filteredText }); }
private addNewLine(): void { this.cancelAutocomplete();1142 unmodified linesExpand all