/** * Pasted text blobs are represented in the editor as `[pasted #N]` markers while the * text payload is held aside in a registry keyed by N. The markers present in * the submitted text decide which pasted blobs are written to disk and replaced with * stubs, so deleting a marker drops its payload and restoring it (undo, history, retry) * brings it back as long as the payload is still in the registry. */ import { createHash } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { getAgentDir } from "../../config.js"; /** Matches `[pasted #N]` markers inserted when a text blob is moved into a file from the editor. */ const PASTED_MARKER_REGEX = /\[pasted #(\d+)\]/g; export type PastedMode = "blob" | "secret"; export interface PastedEntry { id: number; content: string; mode: PastedMode; name?: string; timestamp?: number; } export interface PastedStubInfo { path: string; bytes: number; lines: number; sha256: string; mode: PastedMode; } /** The marker text inserted into the editor for pasted text blob `id`. */ export function formatPastedMarker(id: number): string { return `[pasted #${id}]`; } /** Marker ids that appear in `text`, in order of appearance. */ export function pastedMarkerIds(text: string): number[] { return [...text.matchAll(PASTED_MARKER_REGEX)] .map((match) => Number(match[1])) .filter((id) => Number.isSafeInteger(id)); } /** Replace every pasted-marker spelling whose numeric id appears in `remaps`. */ export function remapPastedMarkers(text: string, remaps: ReadonlyMap): string { return text.replace(PASTED_MARKER_REGEX, (marker, id: string) => { const replacement = remaps.get(Number(id)); return replacement === undefined ? marker : formatPastedMarker(replacement); }); } /** * Pasted entries from `pending` whose marker still appears in `text`, in paste order * (the map's insertion order). Each entry is returned at most once even if its * marker is duplicated in the text. */ export function collectMarkedPastes(pending: ReadonlyMap, text: string): T[] { if (pending.size === 0) { return []; } const present = new Set(pastedMarkerIds(text)); const pasted: T[] = []; for (const [id, entry] of pending) { if (present.has(id)) { pasted.push(entry); } } return pasted; } /** * Evict oldest entries (insertion order) from `pastes` until the total of * `sizeOf` is within `maxBytes`. Ids in `keep` are never evicted, so a pasted * whose marker is still live retains its content even if that holds the total * above the cap. */ export function evictPastesToBudget( pastes: Map, sizeOf: (value: T) => number, maxBytes: number, keep: ReadonlySet, ): void { let total = 0; for (const value of pastes.values()) { total += sizeOf(value); } for (const key of [...pastes.keys()]) { if (total <= maxBytes) { break; } if (keep.has(key)) { continue; } const value = pastes.get(key); if (value !== undefined) { total -= sizeOf(value); pastes.delete(key); } } } /** * Modes a live pasted file cycles through. A legacy terminal cannot deliver `shift+alt+k` * (a shifted letter arrives as its uppercase form, which the key matcher cannot tell * apart from the unshifted key), so `alt+k` has to be able to reach secret mode on its own. */ export function toggledPastedMode(current: PastedMode): PastedMode { return current === "secret" ? "blob" : "secret"; } /** * One-line stand-in for a pasted blob. The reading rules live in the system prompt * (`PASTED_FILE_PROMPT`), not here, so the user's own message stays one line plus a blank. */ export function formatPastedStub(info: PastedStubInfo): string { const label = info.mode === "secret" ? "pasted secret" : "pasted"; return `[${label}: ${info.path} ยท ${info.bytes} B]\n`; } function formatHHMMSS(date: Date): string { const pad = (n: number) => String(n).padStart(2, "0"); return `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; } export interface WritePastedFileResult { path: string; bytes: number; lines: number; sha256: string; stub: string; } /** * Write a pasted blob to `~/.prime/agent/pastes//-.txt` * with directory mode 0700 and file mode 0600 atomically (via `.partial` then rename). */ export function writePastedFile( entry: PastedEntry, options: { sessionId: string; agentDir?: string; now?: Date; }, ): WritePastedFileResult { const baseDir = options.agentDir ?? getAgentDir(); const pastesDir = join(baseDir, "pastes", options.sessionId); mkdirSync(pastesDir, { recursive: true, mode: 0o700 }); chmodSync(pastesDir, 0o700); const timestamp = formatHHMMSS(options.now ?? new Date()); const name = entry.name ?? `paste-${entry.id}`; const fileName = `${timestamp}-${name}.txt`; const filePath = join(pastesDir, fileName); const partialPath = `${filePath}.partial`; const content = entry.content; writeFileSync(partialPath, content, { encoding: "utf-8", mode: 0o600 }); chmodSync(partialPath, 0o600); renameSync(partialPath, filePath); const bytes = Buffer.byteLength(content, "utf-8"); const lines = content.split("\n").length; const sha256 = createHash("sha256").update(content, "utf-8").digest("hex"); const stub = formatPastedStub({ path: filePath, bytes, lines, sha256, mode: entry.mode, }); return { path: filePath, bytes, lines, sha256, stub, }; } /** * Replace all `[pasted #N]` markers present in `text` with their written file stubs. */ export function resolvePastesInText( text: string, pastes: ReadonlyMap, options: { sessionId: string; agentDir?: string; now?: Date; }, ): { text: string; writtenFiles: Array<{ id: number; path: string; mode: PastedMode }> } { const writtenFiles: Array<{ id: number; path: string; mode: PastedMode }> = []; const writtenById = new Map(); const replacedText = text.replace(PASTED_MARKER_REGEX, (marker, idStr) => { const id = Number(idStr); const entry = pastes.get(id); if (!entry) { return marker; } let written = writtenById.get(id); if (!written) { written = writePastedFile(entry, options); writtenById.set(id, written); writtenFiles.push({ id, path: written.path, mode: entry.mode }); } return written.stub; }); return { text: replacedText, writtenFiles }; } /** * Best-effort delete a session's pasted-file directory and return names of files removed. */ export function reapSessionPastes(sessionId: string, agentDir?: string): string[] { const baseDir = agentDir ?? getAgentDir(); const pastesDir = join(baseDir, "pastes", sessionId); if (!existsSync(pastesDir)) { return []; } try { const files = readdirSync(pastesDir); rmSync(pastesDir, { recursive: true, force: true }); return files; } catch { return []; } }