diff --git a/docs/prompt-history.md b/docs/prompt-history.md index 1fa8eba4f..4a4262c40 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -1,9 +1,8 @@ # Prompt history -Slice 1 of the prompt-history extension (#819 split) ships the storage layer only: -a per-instance JSONL capture store, project identity, and the read/write -primitives later slices build on. The selector UI, deletion/scope drains, and GC -arrive in later slices of the chain. +Prompt history stores captured prompts per pi instance and can import older +history and project session transcripts. Deletion and compaction arrive in +later slices of the chain. ## Capture is opt-in @@ -21,6 +20,20 @@ GENTLE_PI_HISTORY_CAPTURE=1 pi - With capture off the extension is inert: no registry entry, no files, and prompts are never written. +## Legacy migration and seeding are opt-in + +Importing past prompts is part of capture: the first delivered prompt in an +opted-in session attempts legacy migration and one-time bootstrap from project +session transcripts. The selector reads the store but does not initiate import. +With capture off, both capture and the selector leave the store untouched. +Failed migration reads can be retried on a later session; untrusted deletion +records defer transcript bootstrap until they can be read safely. + +An import creates **new searchable copies** under `~/.pi/agent/history`. The +source transcripts stay untouched and read-only. Turning capture off again +does not remove copies that were already imported: delete them manually as +described in "What disabling capture does" below. + ## Where the files live Everything sits under `~/.pi/agent/history/`: @@ -29,6 +42,8 @@ Everything sits under `~/.pi/agent/history/`: labels. - `projects//.jsonl` — one append-only capture file per pi process. +- `projects//seed.jsonl` — one-time transcript import for this project. +- `history-global.jsonl` — imported legacy editor-history prompts. `` is the first 16 hex chars of the SHA-256 of the canonicalized project cwd; `` is a per-process UUID. Each line is one delivered prompt: @@ -37,8 +52,9 @@ cwd; `` is a per-process UUID. Each line is one delivered prompt: {"v":1,"text":"the prompt as delivered","ts":1700000000000} ``` -UI command-like prompts (`/name ...`) and empty lines are never stored. Later -slices add the rebuildable `seed.jsonl`, scope drains/deletes, and GC. +UI command-like prompts (`/name ...`) and empty lines are never captured. +Imported copies remain on disk when capture is turned off; the seed is not +regenerated if it already exists, to avoid resurrecting deleted prompts. ## Who can read them diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 50304bcda..f26971e4b 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -38,10 +38,12 @@ import { } from "@earendil-works/pi-tui"; import { appendSessionCapture, + bootstrapProjectSeed, type DrainResult, drainGlobal, drainProject, ensureRegistryEntry, + migrateLegacyStores, openSessionWriter, type SessionWriterState, } from "./store.ts"; @@ -87,7 +89,9 @@ const PREVIEW_WHEEL_Y_LAST = 26; const ENTRY_PREFIX_WIDTH = 2; // v2 multi-concurrency store root (design: tmp/multi-concurrency-design.md). -const PI_HISTORY_ROOT = join(homedir(), ".pi", "agent", "history"); +const AGENT_DIR = join(homedir(), ".pi", "agent"); +const PI_HISTORY_ROOT = join(AGENT_DIR, "history"); +const SESSIONS_ROOT = join(homedir(), ".pi", "agent", "sessions"); export interface HistoryDeps { env?: NodeJS.ProcessEnv; @@ -95,6 +99,8 @@ export interface HistoryDeps { cwd?: string; instanceId?: string; now?: () => number; + agentDir?: string; + sessionsRoot?: string; } /** @@ -969,20 +975,37 @@ export default function promptHistoryExtension( const cwd = deps.cwd ?? process.cwd(); const instanceId = deps.instanceId ?? randomUUID(); const now = deps.now ?? Date.now; + const agentDir = deps.agentDir ?? AGENT_DIR; + const sessionsRoot = deps.sessionsRoot ?? SESSIONS_ROOT; let writerState: SessionWriterState | null = null; /** - * One-time init per extension load: register the project in the advisory - * registry, then open this instance's exclusive capture file. Legacy - * migration and seed bootstrap join this init order in a later slice. + * One-time init per extension load: migrate legacy stores, register the + * project, bootstrap the seed, then open this instance's exclusive file. */ const getWriter = (): SessionWriterState => { if (!writerState) { + try { + migrateLegacyStores(root, agentDir); + } catch { + // migration is best-effort; the gate keeps it one-shot + } try { ensureRegistryEntry(root, cwd); } catch { // registry is advisory } + try { + bootstrapProjectSeed( + root, + cwd, + sessionsRoot, + 500, + root, + ); + } catch { + // bootstrap is a rebuildable cache + } writerState = openSessionWriter(root, cwd, instanceId); } return writerState; diff --git a/extensions/history/load-shared-history.ts b/extensions/history/load-shared-history.ts new file mode 100644 index 000000000..79ef12f7d --- /dev/null +++ b/extensions/history/load-shared-history.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history +// SPDX-License-Identifier: MIT + +import fs from "node:fs"; + +interface SharedHistoryEntry { + text: string; +} + +function isSharedHistoryEntry(value: unknown): value is SharedHistoryEntry { + if (!value || typeof value !== "object") return false; + return typeof (value as { text?: unknown }).text === "string"; +} + +function toSharedHistoryValue(value: unknown): string | null { + if (typeof value === "string") return value.length > 0 ? value : null; + if (isSharedHistoryEntry(value)) + return value.text.length > 0 ? value.text : null; + return null; +} + +function isNonEmptyString(value: string | null): value is string { + return typeof value === "string" && value.length > 0; +} + +export function loadSharedHistory(historyFile: string): string[] { + if (!fs.existsSync(historyFile)) return []; + + try { + const raw = fs.readFileSync(historyFile, "utf8"); + const parsed: unknown = JSON.parse(raw); + + if (!Array.isArray(parsed)) return []; + + return parsed.map(toSharedHistoryValue).filter(isNonEmptyString); + } catch { + return []; + } +} diff --git a/extensions/history/session-scan.ts b/extensions/history/session-scan.ts new file mode 100644 index 000000000..accd302c7 --- /dev/null +++ b/extensions/history/session-scan.ts @@ -0,0 +1,233 @@ +// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history +// SPDX-License-Identifier: MIT + +import fs from "node:fs"; +import path from "node:path"; + +/** + * One user prompt extracted from a pi session transcript file. + * + * `ts` is the resolved ordering timestamp in milliseconds since the epoch. + * It follows the documented fallback chain (message ms-epoch → entry ISO → + * header ISO → file mtime) and exists for ordering only — extraction never + * fails because of it. + */ +export interface ExtractedPrompt { + text: string; + ts: number; +} + +/** + * Result of scanning one session file: the extracted user prompts (in file + * order) plus `skippedLines`, the number of gate-passing lines whose JSON + * could not be parsed. Parse failures are counted, never fatal. + */ +export interface FileScanResult { + prompts: ExtractedPrompt[]; + skippedLines: number; +} + +/** + * Maximum extracted prompt length in UTF-16 code units (`text.length`). A + * prompt strictly greater than this is skipped; at the maximum it still + * extracts. The skip is uniform and silent (design §D1). + */ +export const MAX_PROMPT_CHARS = 16384; + +/** + * Cheap per-line substring prefilter literal. PREFILTER ONLY: a miss means + * the line is never parsed; the parsed extraction rule below is the only + * membership authority. + */ +const USER_ROLE_LITERAL = '"role":"user"'; + +/** Defensive field-access shapes for session JSONL values. */ +interface SessionFields { + type?: unknown; + version?: unknown; + timestamp?: unknown; + message?: unknown; +} + +interface MessageFields { + role?: unknown; + content?: unknown; + timestamp?: unknown; +} + +/** + * Validate the line-1 session header. A file is admitted only when the + * header parses, carries type "session", and a numeric version ≤ 3 + * (format v3; legacy v1/v2 tolerated). Mirrors pi's loadEntriesFromFile + * tolerance: any miss yields an empty scan, never a throw. Returns the + * header timestamp in ms, or NaN when absent or unparseable. + */ +function parseHeader(line: string): number | null { + let header: unknown; + try { + header = JSON.parse(line); + } catch { + return null; + } + if (header == null || typeof header !== "object") return null; + const fields = header as SessionFields; + if (fields.type !== "session") return null; + if (typeof fields.version !== "number" || !(fields.version <= 3)) { + return null; + } + return typeof fields.timestamp === "string" + ? Date.parse(fields.timestamp) + : NaN; +} + +/** + * Extract the prompt text from a user message's content: plain string + * content passes through as-is; block arrays join their text blocks with a + * single space and trim the assembly (upstream extractTextContent rule, + * design §D1) — images and other block types are ignored, and zero text + * blocks yield no prompt. Returns null when no prompt text exists. + */ +function extractText(content: unknown): string | null { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return null; + const parts: string[] = []; + for (const block of content) { + if ( + block != null && + typeof block === "object" && + (block as SessionFields).type === "text" && + typeof (block as { text?: unknown }).text === "string" + ) { + parts.push((block as { text: string }).text); + } + } + if (parts.length === 0) return null; + return parts.join(" ").trim(); +} + +/** + * Resolve a prompt's ordering timestamp: message ms-epoch, then entry ISO, + * then header ISO, then the file mtime. Every hop is NaN-tolerant; ordering + * data is never worth a throw. + */ +function resolveTimestamp( + entry: SessionFields, + message: MessageFields, + headerTs: number, + filePath: string, +): number { + if ( + typeof message.timestamp === "number" && + Number.isFinite(message.timestamp) + ) { + return message.timestamp; + } + if (typeof entry.timestamp === "string") { + const parsed = Date.parse(entry.timestamp); + if (!Number.isNaN(parsed)) return parsed; + } + if (!Number.isNaN(headerTs)) return headerTs; + try { + const mtimeMs = fs.statSync(filePath).mtimeMs; + if (!Number.isNaN(mtimeMs)) return mtimeMs; + } catch { + // The file vanished between read and stat — leave the NaN residue. + } + return NaN; +} + +/** + * Extract every user prompt from one pi session transcript file. + * + * Pipeline (design §C): line-1 header admission gate; per line the cheap + * USER_ROLE_LITERAL substring gate as a PREFILTER ONLY (gate misses are + * never parsed); JSON.parse with a counted skip on throw; then the parsed + * extraction rule (entry type "message" AND user role) as the only + * membership authority. Whitespace-only text is skipped silently, and text + * above MAX_PROMPT_CHARS skips uniformly. UI-free and fs-only: data-path + * errors degrade to empty or partial results and never throw. + */ +export function extractPromptsFromFile(filePath: string): FileScanResult { + let raw: string; + try { + raw = fs.readFileSync(filePath, "utf8"); + } catch { + return { prompts: [], skippedLines: 0 }; + } + + const lines = raw.split("\n"); + const headerTs = parseHeader(lines[0]); + if (headerTs === null) return { prompts: [], skippedLines: 0 }; + + const prompts: ExtractedPrompt[] = []; + let skippedLines = 0; + + for (let index = 1; index < lines.length; index++) { + const line = lines[index]; + // Prefilter: a miss never reaches JSON.parse. + if (!line.includes(USER_ROLE_LITERAL)) continue; + + let entry: unknown; + try { + entry = JSON.parse(line); + } catch { + skippedLines++; + continue; + } + if (entry == null || typeof entry !== "object") continue; + + const record = entry as SessionFields; + if (record.type !== "message") continue; + if (record.message == null || typeof record.message !== "object") continue; + const message = record.message as MessageFields; + if (message.role !== "user") continue; + + const text = extractText(message.content); + if (text === null || text.trim() === "") continue; // silent, not counted + if (text.length > MAX_PROMPT_CHARS) continue; // uniform silent length skip + + prompts.push({ + text, + ts: resolveTimestamp(record, message, headerTs, filePath), + }); + } + + return { prompts, skippedLines }; +} + +/** + * One-level scan rule (C1): list the top-level `*.jsonl` session files of + * every encoded-cwd directory under the pi sessions root. Only DIRECTORIES + * at the root are entered (stray root-level files are skipped) and only + * their top-level jsonl files are candidates — nested subagent payloads + * (`run-N/session.jsonl`) and `subagent-artifacts/` subtrees are directories + * and are never descended. An unreadable root yields an empty list and a + * directory whose readdir fails is skipped — never fatal. Returns sorted + * absolute paths for deterministic scan order. Mirrors pi's non-recursive + * listSessionsFromDir (session-manager.ts:822-826, read-only). + */ +export function listSessionFiles(sessionsRoot: string): string[] { + let rootEntries: fs.Dirent[]; + try { + rootEntries = fs.readdirSync(sessionsRoot, { withFileTypes: true }); + } catch { + return []; + } + const files: string[] = []; + for (const rootEntry of rootEntries) { + if (!rootEntry.isDirectory()) continue; + const dirPath = path.join(sessionsRoot, rootEntry.name); + let children: fs.Dirent[]; + try { + children = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch { + continue; // one unreadable directory skips itself, never fatal + } + for (const child of children) { + if (child.isFile() && child.name.endsWith(".jsonl")) { + files.push(path.join(dirPath, child.name)); + } + } + } + return files.sort(); +} diff --git a/extensions/history/store.ts b/extensions/history/store.ts index b63b07719..fc608af58 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -1,17 +1,23 @@ // SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history // SPDX-License-Identifier: MIT -// Consolidated multi-concurrency store (v2), slices 1+2: project paths and -// identity, the advisory registry, entry primitives, the per-instance -// session writer, and the scope drain/reader/query section (ordering, -// dedup, tombstone filter, project/global drains). Legacy migration and -// seed bootstrap, scope deletes, and GC/compaction arrive in later slices. -// Formerly store-paths.ts + registry.ts + multi-store.ts (+ v1 primitives). +// Consolidated multi-concurrency store (v2), slices 1+2+4: project paths +// and identity, the advisory registry, entry primitives, the per-instance +// session writer, the scope drain/reader/query section (ordering, dedup, +// tombstone filter, project/global drains), legacy migration, and the +// project seed bootstrap. Scope deletes and GC/compaction arrive in later +// slices. Formerly store-paths.ts + registry.ts + multi-store.ts (+ v1 +// primitives). import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { readHiddenPrompts } from "./hide-prompts.ts"; +import { + extractPromptsFromFile, + listSessionFiles, + type ExtractedPrompt, +} from "./session-scan.ts"; // =========================================================================== // Paths (formerly store-paths.ts) @@ -434,3 +440,224 @@ export function drainGlobal( if (fs.existsSync(globalSeed)) sorted.push(globalSeed); // legacy last return drainWithHidden(sorted, limit, stateDir); } + +// --------------------------------------------------------------------------- +// Legacy migration (design v2: one-time, gated) +// --------------------------------------------------------------------------- + +export interface MigrationResult { + migrated: number; + ran: boolean; +} + +function readValidLines(file: string): StoreEntry[] { + // A read failure must abort the entire migration: archiving a source + // whose prompts were not imported would make the loss permanent. + const raw = fs.readFileSync(file, "utf8"); + const entries: StoreEntry[] = []; + for (const lineText of raw.split("\n")) { + const parsed = parseStoreLine(lineText); + if (parsed) entries.push(parsed); + } + return entries; +} + +/** + * One-time migration from the v1 stores into the v2 global seed: + * - `~/.pi/agent/editor-history.jsonl` (v1 single-file store) + * - `~/.pi/agent/editor-history.json` (pre-v1 array, newest-first) + * Content lands in `pi-history/history-global.jsonl` chronologically; only + * after the seed write succeeds is the array source renamed `.imported`. + * Keep the v1 JSONL path live: older processes may still append to it, and + * later opens import new prompts without replacing the complete seed. + */ +export function migrateLegacyStores( + root: string, + agentDir: string, +): MigrationResult { + const seed = globalSeedPath(root); + fs.mkdirSync(root, { recursive: true }); + const lock = `${seed}.migration-lock`; + try { + fs.mkdirSync(lock); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + return { migrated: 0, ran: false }; + } + throw error; + } + try { + return migrateLegacyStoresLocked(seed, agentDir); + } finally { + fs.rmdirSync(lock); + } +} + +function migrateLegacyStoresLocked(seed: string, agentDir: string): MigrationResult { + // Re-read under the exclusive lock: another process may have published a + // complete seed while this one waited. Never replace a published seed. + const existing = fs.existsSync(seed) ? readValidLines(seed) : []; + const known = new Set(existing.map((entry) => promptKey(entry.text))); + const collected: StoreEntry[] = []; + const collect = (entry: StoreEntry) => { + const key = promptKey(entry.text); + if (!known.has(key)) { + known.add(key); + collected.push(entry); + } + }; + + // Pre-v1 array (newest-first) → reverse to chronological. + const legacyArray = path.join(agentDir, "editor-history.json"); + if (fs.existsSync(legacyArray)) { + // Unlike the tolerant UI reader, migration must not archive a source + // whose bytes could not be read or parsed. Read exactly once. + const values: unknown = JSON.parse(fs.readFileSync(legacyArray, "utf8")); + if (!Array.isArray(values)) throw new Error("Invalid legacy history array"); + for (let i = values.length - 1; i >= 0; i--) { + const item: unknown = values[i]; + const text = typeof item === "string" ? item : + item && typeof item === "object" && "text" in item && + typeof item.text === "string" ? item.text : null; + if (text && text.length > 0) collect({ v: 1, text }); + } + } + + // v1 single-file store — already chronological. + const v1File = path.join(agentDir, "editor-history.jsonl"); + for (const source of [`${v1File}.imported`, v1File]) { + // Older migrations may have renamed a file still open for appends. + if (fs.existsSync(source)) { + for (const entry of readValidLines(source)) collect(entry); + } + } + + if (collected.length === 0) return { migrated: 0, ran: false }; + + const tmp = `${seed}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync( + tmp, + [...existing, ...collected].map((e) => JSON.stringify(e)).join("\n") + "\n", + "utf8", + ); + fs.renameSync(tmp, seed); + + // Array sources are immutable; the v1 JSONL path remains live for writers + // opened by older processes, and is checked again on later migrations. + try { + if (fs.existsSync(legacyArray)) fs.renameSync(legacyArray, `${legacyArray}.imported`); + } catch { + // A failed archive is harmless: already seeded entries are deduplicated. + } + return { migrated: collected.length, ran: true }; +} + +// --------------------------------------------------------------------------- +// Project bootstrap (design v2: seed.jsonl) +// --------------------------------------------------------------------------- + +export interface SeedResult { + seeded: number; + ran: boolean; +} + +/** + * Seed `projects//seed.jsonl` from the project's pi transcripts when + * the project dir holds fewer than `target` entries. Existing session files + * are counted; their prompts are NOT re-seeded (dedupe by UI-level key). + * The seed is a rebuildable cache — rewritten only when the dir is empty. + */ +export function bootstrapProjectSeed( + root: string, + cwd: string, + sessionsRoot: string, + target: number, + stateDir?: string, +): SeedResult { + const dir = path.join(root, "projects", projectHash(cwd)); + + // Count existing entries and collect their identities. + const existingKeys = new Set(); + let existingCount = 0; + for (const file of listProjectFiles(dir)) { + let raw = ""; + try { + raw = fs.readFileSync(file, "utf8"); + } catch { + continue; + } + for (const lineText of raw.split("\n")) { + const parsed = parseStoreLine(lineText); + if (parsed) { + existingCount += 1; + existingKeys.add(promptKey(parsed.text)); + } + } + } + if (existingCount >= target) return { seeded: 0, ran: false }; + // The seed is written ONCE: an existing seed is never regenerated, so a + // deleted prompt cannot be resurrected from transcripts on a new session. + if (fs.existsSync(seedFilePath(root, cwd))) { + return { seeded: 0, ran: false }; + } + // Tombstones (user deletions) suppress transcript prompts from seeding. + // Fail closed (spec C4): an untrusted hidden.json leaves the tombstone + // set unknown, and a wrongly seeded prompt would be permanent (the seed + // is written once, never regenerated) — skip the bootstrap instead; a + // later open retries once the file is trusted again or deleted. + let hidden = new Set(); + if (stateDir !== undefined) { + const read = readHiddenPrompts(stateDir); + if (read.status === "untrusted") return { seeded: 0, ran: false }; + hidden = read.keys; + } + + // Scan transcripts: session files of THIS project's dir, newest first. + let files: string[] = []; + try { + const dirName = cwd + .replace(/^[/\\]/, "") + .replace(/[/\\:]/g, "-"); + files = listSessionFiles(sessionsRoot).filter((file) => + file.includes(`${path.sep}--${dirName}--${path.sep}`), + ); + } catch { + return { seeded: 0, ran: false }; + } + files.sort((a, b) => fileMtimeMs(b) - fileMtimeMs(a)); + + const collected: StoreEntry[] = []; + outer: for (const file of files) { + let prompts: ExtractedPrompt[] = []; + try { + prompts = extractPromptsFromFile(file).prompts; + } catch { + continue; + } + for (let i = prompts.length - 1; i >= 0; i--) { + const text = prompts[i].text; + if (/^\/[A-Za-z]/.test(text.trim())) continue; + if (hidden.size > 0 && hidden.has(promptDedupKeyOf(text))) continue; + const key = promptKey(text); + if (existingKeys.has(key)) continue; + existingKeys.add(key); + const entry: StoreEntry = { v: 1, text }; + if (Number.isFinite(prompts[i].ts)) entry.ts = prompts[i].ts; + collected.push(entry); + if (collected.length >= target - existingCount) break outer; + } + } + if (collected.length === 0) return { seeded: 0, ran: false }; + + collected.reverse(); // chronological (oldest first) + const seed = seedFilePath(root, cwd); + fs.mkdirSync(path.dirname(seed), { recursive: true }); + const tmp = `${seed}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync( + tmp, + collected.map((e) => JSON.stringify(e)).join("\n") + "\n", + "utf8", + ); + fs.renameSync(tmp, seed); + return { seeded: collected.length, ran: true }; +} diff --git a/odd/tasks/history-contributor-chain.md b/odd/tasks/history-contributor-chain.md new file mode 100644 index 000000000..4718a6d37 --- /dev/null +++ b/odd/tasks/history-contributor-chain.md @@ -0,0 +1,24 @@ +# Complete contributor history chain + +## Objective / authorization +Adapt and merge Gentle Shell History PRs #1391 (migration/seeding), #1393 (deletion/tombstones), and #1394 (compaction), in order. Exclude unrelated PR #1452. User authorized adaptation and merge. Issue #818 is approved. Earlier #1390/#1392/#1453–#1455 are already merged. + +## Problem and constraints +The open PRs are cumulative branches from older main. #1393/#1394 switch the current `GENTLE_PI_HISTORY_CAPTURE` opt-in to `GENTLE_PI_HISTORY_ENABLE`; #1394's compaction can remove a file with concurrent appends and the seed gate, and runs at shutdown while capture is off. Current main is moving. Preserve existing privacy semantics, contributor attribution, and all unrelated work; do not merge an unsafe head and assume revert restores lost data. + +## Route and delivery +Delegated direct writer for multi-file implementation and preparatory reading. One writer at a time; isolate each PR adaptation in its own worktree. Existing cumulative chain is the delivery strategy, in order #1391 → #1393 → #1394. Each slice is a work unit with tests/docs and Conventional Commit. Forecast: incremental #1391 ~510 lines; #1393 ~700 lines; #1394 ~600 lines, beyond the advisory 400-line PR budget; preserve coherent behavior and report size exception rather than code-golf. Check PR policy, exact head, mergeability, and required CI before each merge. Do not wait for optional CodeRabbit; evaluate critical findings on final heads. Review mode is user-owned. + +## Tasks +- [ ] H1: Reconcile #1391 against current main, fix migration/seed privacy and retry issues with deterministic tests; verify and merge only when eligible. Route: delegated, multiple nontrivial source/test files. Commit and merge identities pending. +- [ ] H2: Reconcile #1393 against post-H1 main, preserve `GENTLE_PI_HISTORY_CAPTURE`, ensure exact tombstones, race-safe deletion, and failure reporting; verify and merge only when eligible. Route: delegated, multiple nontrivial source/test files. Commit and merge identities pending. +- [ ] H3: Reconcile #1394 against post-H2 main, preserve capture compatibility and seed gate, protect active writers during GC, and update truthful docs; verify and merge only when eligible. Route: delegated, multiple nontrivial source/test files. Commit and merge identities pending. + +## Acceptance and checks +Run focused `tests/history-*.test.ts`, project verification/typecheck, `git diff --check`, and required exact-head CI as applicable. Each merged slice preserves current main's opt-in behavior and shows no lost prompts in concurrency/failure tests. No automatic rollback is a substitute for data safety. Record failures/skips/pending honestly. + +## Progress +2026-09-26: Remote heads inspected: #1391 `649711c`, #1393 `e302337`, #1394 `5981153`; origin/main `06c9915` at the H1 snapshot. #1391 cumulative diff since #1455 is 1796 additions/23 deletions across 13 files; later cumulative PRs are larger. Separate #1391 worktree created at `fix/history-pr1391-adaptation`. H1 writer observed RED 2 migration failures, GREEN 14/14 focused tests. With node_modules linked from the main checkout, independent verification passed 168 history tests, typecheck had 188 baseline diagnostics and no regression, and `git diff --check` passed. Integration with main, commit, PR checks and merge remain pending. Engram mirror `odd/history-contributor-chain/tasks` pending: this session is bound to the separate gentle-ai project and Engram rejects a gentle-pi write. + +## Next step +H1 is blocked: native review lineage `review-88445da92b015b5c` escalated with `targeted_validator_rejected` for R3-001/R3-002 after the single bounded correction. Do not publish or merge this candidate as approved. Diagnose the native refusal through supported maintainer inspection or make a separately authorized fresh candidate; keep H2/H3 pending. Last integrated commit `15249c57b`, correction commit `cc6ecca68`; independent recheck: 172 focused history tests pass, typecheck retains 188 baseline diagnostics without regressions, diff check passed. `pnpm test` aborted before tests because pnpm attempted a noninteractive `node_modules` purge; the equivalent direct unit stage ran 3,754 tests (3,707 pass, 4 fail, 43 skip), while direct provider-contract and runtime-harness stages passed. Three failures are presence-poll timeouts in `agents-view-thread-identity.test.ts`; one `gentle-shell.test.ts` border mismatch includes unexpected `INSERT`. Baseline attribution unverified. Native review is still escalated. No PR push or merge occurred. diff --git a/tests/history-legacy-migrate-v2.test.ts b/tests/history-legacy-migrate-v2.test.ts new file mode 100644 index 000000000..6fedf9fe3 --- /dev/null +++ b/tests/history-legacy-migrate-v2.test.ts @@ -0,0 +1,297 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { globalSeedPath, migrateLegacyStores } from "../extensions/history/store.ts"; + +function makeDirs(): { root: string; agentDir: string } { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-mig-")); + const root = path.join(base, "pi-history"); + const agentDir = path.join(base, "agent"); + fs.mkdirSync(agentDir, { recursive: true }); + return { root, agentDir }; +} + +function fileTexts(file: string): string[] { + if (!fs.existsSync(file)) return []; + return fs + .readFileSync(file, "utf8") + .trim() + .split("\n") + .filter((l) => l.length > 0) + .map((l) => (JSON.parse(l) as { text: string }).text); +} + +test("no legacy files: migration is a no-op, nothing created", () => { + const { root, agentDir } = makeDirs(); + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 0, ran: false }); + assert.equal(fs.existsSync(globalSeedPath(root)), false); +}); + +test("v1 jsonl migrates into the global seed chronologically", () => { + const { root, agentDir } = makeDirs(); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync( + v1, + `${[ + JSON.stringify({ v: 1, text: "old" }), + JSON.stringify({ v: 1, text: "new" }), + ].join("\n")}\n`, + "utf8", + ); + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 2, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["old", "new"]); + assert.equal(fs.existsSync(v1), true); + assert.equal(fs.existsSync(`${v1}.imported`), false); +}); + +test("competing migration cannot publish a partial seed over a complete one", () => { + const { root, agentDir } = makeDirs(); + const array = path.join(agentDir, "editor-history.json"); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync(array, JSON.stringify(["array prompt"])); + fs.writeFileSync(v1, `${JSON.stringify({ text: "v1 prompt" })}\n`); + const original = fs.readFileSync; + let competing: ReturnType | undefined; + fs.readFileSync = ((file: fs.PathOrFileDescriptor, ...args: unknown[]) => { + if (file === v1 && !competing) { + // The first migration has already read the array; the competing one + // must not publish a seed while that snapshot is incomplete. + competing = { migrated: -1, ran: true }; + competing = migrateLegacyStores(root, agentDir); + } + return (original as (...args: unknown[]) => unknown)(file, ...args); + }) as typeof fs.readFileSync; + try { + migrateLegacyStores(root, agentDir); + } finally { + fs.readFileSync = original; + } + assert.deepEqual(competing, { migrated: 0, ran: false }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["array prompt", "v1 prompt"]); +}); + +test("an ownerless crash lock fails closed without changing sources or seed", () => { + const { root, agentDir } = makeDirs(); + const array = path.join(agentDir, "editor-history.json"); + const v1 = path.join(agentDir, "editor-history.jsonl"); + const seed = globalSeedPath(root); + const arrayBytes = JSON.stringify(["array prompt"]); + const v1Bytes = `${JSON.stringify({ text: "after crash" })}\n`; + const seedBytes = `${JSON.stringify({ text: "already seeded" })}\n`; + fs.writeFileSync(array, arrayBytes); + fs.writeFileSync(v1, v1Bytes); + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync(seed, seedBytes); + // A process can die immediately after mkdir, before recording ownership. + // An ownerless lock needs manual recovery; do not claim automatic import. + const lock = `${seed}.migration-lock`; + fs.mkdirSync(lock); + assert.deepEqual(migrateLegacyStores(root, agentDir), { migrated: 0, ran: false }); + assert.equal(fs.readFileSync(array, "utf8"), arrayBytes); + assert.equal(fs.readFileSync(v1, "utf8"), v1Bytes); + assert.equal(fs.existsSync(`${array}.imported`), false); + assert.equal(fs.existsSync(`${v1}.imported`), false); + assert.equal(fs.readFileSync(seed, "utf8"), seedBytes); + assert.equal(fs.existsSync(lock), true); +}); + +test("a live legacy writer remains reachable for prompts appended after migration", () => { + const { root, agentDir } = makeDirs(); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync(v1, `${JSON.stringify({ text: "before" })}\n`); + const fd = fs.openSync(v1, "a"); + try { + migrateLegacyStores(root, agentDir); + fs.writeSync(fd, `${JSON.stringify({ text: "after" })}\n`); + assert.deepEqual(migrateLegacyStores(root, agentDir), { migrated: 1, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["before", "after"]); + } finally { + fs.closeSync(fd); + } +}); + +test("prompts appended to an already archived live v1 file are recovered", () => { + const { root, agentDir } = makeDirs(); + const archived = path.join(agentDir, "editor-history.jsonl.imported"); + fs.writeFileSync(archived, `${JSON.stringify({ text: "archived" })}\n`); + migrateLegacyStores(root, agentDir); + fs.appendFileSync(archived, `${JSON.stringify({ text: "late" })}\n`); + assert.deepEqual(migrateLegacyStores(root, agentDir), { migrated: 1, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["archived", "late"]); +}); + +test("legacy array file also migrates (newest-first reversed)", () => { + const { root, agentDir } = makeDirs(); + const legacy = path.join(agentDir, "editor-history.json"); + fs.writeFileSync(legacy, JSON.stringify(["newest", "oldest"]), "utf8"); + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 2, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["oldest", "newest"]); + assert.equal(fs.existsSync(`${legacy}.imported`), true); +}); + +test("both legacy files: v1 jsonl content appends after array content", () => { + const { root, agentDir } = makeDirs(); + const legacy = path.join(agentDir, "editor-history.json"); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync(legacy, JSON.stringify(["from-array"]), "utf8"); + fs.writeFileSync( + v1, + `${JSON.stringify({ v: 1, text: "from-jsonl" })}\n`, + "utf8", + ); + migrateLegacyStores(root, agentDir); + assert.deepEqual(fileTexts(globalSeedPath(root)), [ + "from-array", + "from-jsonl", + ]); + assert.equal(fs.existsSync(`${legacy}.imported`), true); + assert.equal(fs.existsSync(v1), true); +}); + +test("existing global seed is preserved while new v1 prompts are imported", () => { + const { root, agentDir } = makeDirs(); + fs.mkdirSync(path.dirname(globalSeedPath(root)), { recursive: true }); + fs.writeFileSync( + globalSeedPath(root), + `${JSON.stringify({ v: 1, text: "already-here" })}\n`, + "utf8", + ); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync( + v1, + `${JSON.stringify({ v: 1, text: "would-migrate" })}\n`, + "utf8", + ); + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 1, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["already-here", "would-migrate"]); + assert.deepEqual(migrateLegacyStores(root, agentDir), { migrated: 0, ran: false }); + assert.equal(fs.existsSync(v1), true); +}); + +test("unreadable legacy source leaves both sources for a complete retry", () => { + const { root, agentDir } = makeDirs(); + const array = path.join(agentDir, "editor-history.json"); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync(array, JSON.stringify(["array prompt"])); + fs.writeFileSync(v1, `${JSON.stringify({ text: "v1 prompt" })}\n`); + const original = fs.readFileSync; + fs.readFileSync = ((file: fs.PathOrFileDescriptor, ...args: unknown[]) => { + if (file === v1) throw new Error("injected read failure"); + return (original as (...args: unknown[]) => unknown)(file, ...args); + }) as typeof fs.readFileSync; + try { + assert.throws(() => migrateLegacyStores(root, agentDir), /injected read failure/); + assert.equal(fs.existsSync(globalSeedPath(root)), false); + assert.equal(fs.existsSync(array), true); + assert.equal(fs.existsSync(v1), true); + } finally { + fs.readFileSync = original; + } + assert.deepEqual(migrateLegacyStores(root, agentDir), { migrated: 2, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["array prompt", "v1 prompt"]); +}); + +test("malformed legacy array cannot archive a readable v1 source", () => { + const { root, agentDir } = makeDirs(); + const array = path.join(agentDir, "editor-history.json"); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync(array, "{torn"); + fs.writeFileSync(v1, `${JSON.stringify({ text: "survives" })}\n`); + assert.throws(() => migrateLegacyStores(root, agentDir), SyntaxError); + assert.equal(fs.existsSync(globalSeedPath(root)), false); + assert.equal(fs.existsSync(v1), true); + fs.writeFileSync(array, JSON.stringify(["repaired"])); + assert.deepEqual(migrateLegacyStores(root, agentDir), { migrated: 2, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["repaired", "survives"]); +}); + +test("malformed v1 jsonl lines are skipped, not fatal", () => { + const { root, agentDir } = makeDirs(); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync( + v1, + `${["{torn", JSON.stringify({ v: 1, text: "good" })].join("\n")}\n`, + "utf8", + ); + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 1, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["good"]); +}); + +// node:test has no test.skipIf (Bun-ism): root skips via the options +// object — chmod 000 is invisible to the superuser. +const sealedLegacyTest = (name: string, fn: () => void) => + test( + name, + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, + fn, + ); + +// chmod-based failure injection is also invisible to the superuser. +const seedFailureTest = (name: string, fn: () => void) => + test( + name, + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, + fn, + ); + +seedFailureTest( + "a failed seed write leaves legacy sources untouched for retry", + () => { + const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "migrate-fail-")); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync( + v1, + `${JSON.stringify({ v: 1, text: "survives-retry" })}\n`, + "utf8", + ); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "migrate-fail-root-")); + // A read-only store root makes the seed write fail AFTER the sources + // have been read but BEFORE any rename. + fs.chmodSync(root, 0o555); + try { + assert.throws(() => migrateLegacyStores(root, agentDir)); + // The source was NOT renamed: the retry path is intact. + assert.equal(fs.existsSync(v1), true); + assert.equal(fs.existsSync(`${v1}.imported`), false); + assert.equal(fs.existsSync(globalSeedPath(root)), false); + } finally { + fs.chmodSync(root, 0o755); + } + // Retry after the failure clears: full migration, then rename. + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 1, ran: true }); + assert.equal(fs.existsSync(v1), true); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["survives-retry"]); + }, +); + +sealedLegacyTest( + "an unreadable legacy file defers migration without archiving either source", + () => { + const { root, agentDir } = makeDirs(); + const readable = path.join(agentDir, "editor-history.json"); + fs.writeFileSync(readable, JSON.stringify(["from-array"]), "utf8"); + const sealed = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync( + sealed, + `${JSON.stringify({ v: 1, text: "sealed-content" })}\n`, + "utf8", + ); + fs.chmodSync(sealed, 0o000); + try { + assert.throws(() => migrateLegacyStores(root, agentDir)); + assert.equal(fs.existsSync(globalSeedPath(root)), false); + assert.equal(fs.existsSync(readable), true); + assert.equal(fs.existsSync(sealed), true); + } finally { + fs.chmodSync(sealed, 0o644); + } + }, +); diff --git a/tests/history-load-shared-history.test.ts b/tests/history-load-shared-history.test.ts new file mode 100644 index 000000000..235a7b1bf --- /dev/null +++ b/tests/history-load-shared-history.test.ts @@ -0,0 +1,53 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { loadSharedHistory } from "../extensions/history/load-shared-history.ts"; + +function makeTempFile(content: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "history-load-")); + const file = path.join(dir, "editor-history.json"); + fs.writeFileSync(file, content, "utf8"); + return file; +} + +test("returns empty array when file is missing", () => { + assert.deepEqual(loadSharedHistory("/definitely/missing.json"), []); +}); + +test("returns empty array for malformed json", () => { + const file = makeTempFile("{not-json"); + assert.deepEqual(loadSharedHistory(file), []); +}); + +test("supports string entries", () => { + const file = makeTempFile(JSON.stringify(["one", "two"])); + assert.deepEqual(loadSharedHistory(file), ["one", "two"]); +}); + +test("supports object entries with text field", () => { + const file = makeTempFile(JSON.stringify([{ text: "one" }, { text: "two" }])); + assert.deepEqual(loadSharedHistory(file), ["one", "two"]); +}); + +test("ignores malformed object entries", () => { + const file = makeTempFile( + JSON.stringify([{ text: "one" }, { text: 2 }, { nope: "three" }]), + ); + assert.deepEqual(loadSharedHistory(file), ["one"]); +}); + +test("ignores empty string entries", () => { + const file = makeTempFile( + JSON.stringify(["", "one", { text: "" }, { text: "two" }]), + ); + assert.deepEqual(loadSharedHistory(file), ["one", "two"]); +}); + +test("keeps only valid strings from mixed arrays", () => { + const file = makeTempFile( + JSON.stringify(["one", null, false, 42, { text: "two" }, { text: 1 }]), + ); + assert.deepEqual(loadSharedHistory(file), ["one", "two"]); +}); diff --git a/tests/history-off-path.test.ts b/tests/history-off-path.test.ts new file mode 100644 index 000000000..ef0eb0701 --- /dev/null +++ b/tests/history-off-path.test.ts @@ -0,0 +1,94 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import promptHistoryExtension from "../extensions/history/index.ts"; + +// The module-level selector gate reads process.env directly (that path has +// no deps.env injection); keep the suite hermetic regardless of the ambient +// shell so the off-path assertions cannot be flipped by the environment. +delete process.env.GENTLE_PI_HISTORY_CAPTURE; + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-off-")); +} + +const CWD = "/pi-history-test/project-off"; + +interface Harness { + commandHandler: (args: unknown, ctx: unknown) => Promise; +} + +/** + * Load the extension against a temp root and capture the registered + * shortcut + history command handlers from the fake pi. + */ +function loadWithCommand(env: NodeJS.ProcessEnv, root: string): Harness { + const shortcuts: Array<[string, { handler: unknown }]> = []; + const commands: Array<[string, { handler: unknown }]> = []; + const pi = { + on: () => {}, + registerShortcut: (key: string, def: { handler: unknown }) => { + shortcuts.push([key, def]); + }, + registerCommand: (name: string, def: { handler: unknown }) => { + commands.push([name, def]); + }, + }; + promptHistoryExtension(pi as never, { + env, + root, + cwd: CWD, + instanceId: "inst-off", + now: () => 1700000000000, + }); + const command = commands.find(([name]) => name === "history"); + assert.ok(command, "the history command must be registered"); + assert.equal(shortcuts.length, 1, "the shortcut must still be registered"); + return { + commandHandler: command[1].handler as Harness["commandHandler"], + }; +} + +function fakeCtx(notifyCalls: Array<[string, string]>) { + return { + ui: { + notify: (message: string, level: string) => { + notifyCalls.push([message, level]); + }, + }, + }; +} + +// NOTE: there is deliberately no enabled-path smoke test here. The selector +// drain is a module-level path hard-wired to PI_HISTORY_ROOT +// (~/.pi/agent/history) with no injection point, and bun's os.homedir() +// ignores runtime HOME overrides — invoking the command with capture on +// would migrate/seed/write the real user store. The enabled direction stays +// covered by the deps-injected tests in history-session-writer.test.ts. + +test("with capture disabled, extension load writes nothing", async () => { + const root = makeRoot(); + loadWithCommand({}, root); + // Flush the setImmediate warm-up. + await new Promise((resolve) => setImmediate(resolve)); + // Nothing at all: no registry, no seed, no store file. + assert.deepEqual(fs.readdirSync(root), []); +}); + +test("with capture disabled, the history command imports nothing and warns", async () => { + const root = makeRoot(); + const { commandHandler } = loadWithCommand({}, root); + await new Promise((resolve) => setImmediate(resolve)); + const notifyCalls: Array<[string, string]> = []; + await commandHandler([], fakeCtx(notifyCalls)); + assert.equal(notifyCalls.length, 1); + assert.equal(notifyCalls[0][1], "warning"); + assert.ok( + notifyCalls[0][0].includes("GENTLE_PI_HISTORY_CAPTURE"), + `the warning must name the switch, got: ${notifyCalls[0][0]}`, + ); + // The gate must fire before the drain: no migration, no seed, no store. + assert.deepEqual(fs.readdirSync(root), []); +}); diff --git a/tests/history-seed-bootstrap.test.ts b/tests/history-seed-bootstrap.test.ts new file mode 100644 index 000000000..d8126f254 --- /dev/null +++ b/tests/history-seed-bootstrap.test.ts @@ -0,0 +1,170 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + bootstrapProjectSeed, + projectHash, + seedFilePath, +} from "../extensions/history/store.ts"; + +// Fake project cwd (never created on disk): projectHash falls back to +// raw-string hashing for nonexistent paths, and the transcript dirName +// encoding derives from the same string. +const CWD = "/pi-history-test/seed-project"; +const DIR = "--pi-history-test-seed-project--"; + +function makeDirs(): { root: string; sessionsRoot: string } { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-seed-")); + return { + root: path.join(base, "pi-history"), + sessionsRoot: path.join(base, "sessions"), + }; +} + +function writeSession( + sessionsRoot: string, + dirName: string, + fileName: string, + userTexts: string[], + mtimeMs?: number, +): string { + const dir = path.join(sessionsRoot, dirName); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, fileName); + const lines: string[] = [ + JSON.stringify({ + type: "session", + version: 1, + timestamp: "2026-01-01T00:00:00.000Z", + }), + ]; + let ms = 1700000000000; + for (const text of userTexts) { + lines.push( + JSON.stringify({ + type: "message", + timestamp: "2026-01-01T00:00:00.000Z", + message: { role: "user", content: text, timestamp: ms }, + }), + ); + ms += 1; + } + fs.writeFileSync(file, `${lines.join("\n")}\n`, "utf8"); + if (mtimeMs !== undefined) { + fs.utimesSync(file, new Date(mtimeMs), new Date(mtimeMs)); + } + return file; +} + +function seedTexts(root: string): string[] { + const file = seedFilePath(root, CWD); + if (!fs.existsSync(file)) return []; + return fs + .readFileSync(file, "utf8") + .trim() + .split("\n") + .filter((l) => l.length > 0) + .map((l) => (JSON.parse(l) as { text: string }).text); +} + +test("no sessions and no project dir: bootstrap seeds nothing", () => { + const { root, sessionsRoot } = makeDirs(); + const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(result, { seeded: 0, ran: false }); + assert.equal(fs.existsSync(seedFilePath(root, CWD)), false); +}); + +test("empty project dir bootstraps from the project's transcripts", () => { + const { root, sessionsRoot } = makeDirs(); + writeSession(sessionsRoot, DIR, "s1.jsonl", [ + "real prompt", + "/compact", + " ", + "second prompt", + ]); + const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(result, { seeded: 2, ran: true }); + // Chronological order (oldest first) in the seed file. + assert.deepEqual(seedTexts(root), ["real prompt", "second prompt"]); +}); + +test("only the project's own session dir is scanned", () => { + const { root, sessionsRoot } = makeDirs(); + writeSession(sessionsRoot, DIR, "s1.jsonl", ["mine"]); + writeSession(sessionsRoot, "--Other--", "s2.jsonl", ["not mine"]); + bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(seedTexts(root), ["mine"]); +}); + +test("caps at the target keeping the newest", () => { + const { root, sessionsRoot } = makeDirs(); + const texts: string[] = []; + for (let i = 1; i <= 600; i++) texts.push(`p${i}`); + writeSession(sessionsRoot, DIR, "big.jsonl", texts); + const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(result, { seeded: 500, ran: true }); + const all = seedTexts(root); + assert.equal(all.length, 500); + assert.equal(all[0], "p101"); // oldest kept + assert.equal(all[499], "p600"); // newest +}); + +test("project dir already populated above target: no scan, seed untouched", () => { + const { root, sessionsRoot } = makeDirs(); + const dir = path.join(root, "projects", projectHash(CWD)); + fs.mkdirSync(dir, { recursive: true }); + const existing = path.join(dir, "existing.jsonl"); + fs.writeFileSync( + existing, + `${Array.from({ length: 500 }, (_, i) => + JSON.stringify({ v: 1, text: `e${i}` }), + ).join("\n")}\n`, + "utf8", + ); + const marker = writeSession(sessionsRoot, DIR, "s.jsonl", ["marker"]); + fs.utimesSync( + marker, + new Date(Date.now() + 5000), + new Date(Date.now() + 5000), + ); + const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(result, { seeded: 0, ran: false }); + assert.deepEqual(seedTexts(root), []); + assert.equal(fs.readFileSync(existing, "utf8").includes("marker"), false); +}); + +// node:test has no test.skipIf (Bun-ism): root skips via the options +// object — chmod 000 is invisible to the superuser. +const sealedStoreTest = (name: string, fn: () => void) => + test( + name, + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, + fn, + ); +sealedStoreTest( + "an unreadable existing store file is skipped during counting; seeding still runs from transcripts", + () => { + const { root, sessionsRoot } = makeDirs(); + const dir = path.join(root, "projects", projectHash(CWD)); + fs.mkdirSync(dir, { recursive: true }); + const sealed = path.join(dir, "sealed.jsonl"); + fs.writeFileSync( + sealed, + `${JSON.stringify({ v: 1, text: "sealed-entry" })}\n`, + "utf8", + ); + fs.chmodSync(sealed, 0o000); + writeSession(sessionsRoot, DIR, "s1.jsonl", ["from transcript"]); + try { + // The unreadable file contributes zero to existingCount, so the count + // stays under target and the transcript scan still runs. No throw. + const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(result, { seeded: 1, ran: true }); + assert.deepEqual(seedTexts(root), ["from transcript"]); + } finally { + fs.chmodSync(sealed, 0o644); // restore before cleanup + } + }, +); diff --git a/tests/history-seed-regen.test.ts b/tests/history-seed-regen.test.ts new file mode 100644 index 000000000..fe8bc559c --- /dev/null +++ b/tests/history-seed-regen.test.ts @@ -0,0 +1,129 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { bootstrapProjectSeed, seedFilePath } from "../extensions/history/store.ts"; + +// Fake project cwd (never created on disk): projectHash falls back to +// raw-string hashing for nonexistent paths, and the transcript dirName +// encoding derives from the same string. +const CWD = "/pi-history-test/seed-regen-project"; +const DIR = "--pi-history-test-seed-regen-project--"; + +function setup() { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "seed2-")); + return { + root: path.join(base, "h"), + sessionsRoot: path.join(base, "sessions"), + stateDir: path.join(base, "state"), + }; +} + +function writeSession(sessionsRoot: string, texts: string[]): void { + const dir = path.join(sessionsRoot, DIR); + fs.mkdirSync(dir, { recursive: true }); + const lines = [ + JSON.stringify({ + type: "session", + version: 1, + timestamp: "2026-01-01T00:00:00.000Z", + }), + ]; + let ms = 1700000000000; + for (const text of texts) { + lines.push( + JSON.stringify({ + type: "message", + timestamp: "2026-01-01T00:00:00.000Z", + message: { role: "user", content: text, timestamp: ms++ }, + }), + ); + } + fs.writeFileSync(path.join(dir, "s1.jsonl"), `${lines.join("\n")}\n`, "utf8"); +} + +test("an existing seed is never regenerated (deleted prompts stay gone)", () => { + const { root, sessionsRoot } = setup(); + writeSession(sessionsRoot, ["keep", "delete-me"]); + bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + // User deletes "delete-me" from the seed file (scope delete). + const seed = seedFilePath(root, CWD); + const kept = fs + .readFileSync(seed, "utf8") + .split("\n") + .filter((l) => !l.includes("delete-me")) + .join("\n"); + fs.writeFileSync(seed, kept, "utf8"); + // A NEW session bootstraps again -> must not resurrect from transcripts. + const again = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(again, { seeded: 0, ran: false }); + const texts = fs + .readFileSync(seed, "utf8") + .trim() + .split("\n") + .map((l) => (JSON.parse(l) as { text: string }).text); + assert.deepEqual(texts, ["keep"]); +}); + +test("untrusted tombstones defer seed until repaired, then suppress deleted prompts", () => { + const { root, sessionsRoot, stateDir } = setup(); + writeSession(sessionsRoot, ["visible", "hidden-prompt"]); + fs.mkdirSync(stateDir, { recursive: true }); + const hiddenFile = path.join(stateDir, "hidden.json"); + fs.writeFileSync(hiddenFile, "{broken"); + assert.deepEqual(bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir), { + seeded: 0, ran: false, + }); + assert.equal(fs.existsSync(seedFilePath(root, CWD)), false); + fs.writeFileSync(hiddenFile, JSON.stringify(["hidden-prompt"])); + assert.deepEqual(bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir), { + seeded: 1, ran: true, + }); + assert.deepEqual( + fs.readFileSync(seedFilePath(root, CWD), "utf8").trim().split("\n") + .map((line) => (JSON.parse(line) as { text: string }).text), + ["visible"], + ); +}); + +test("a failed seed rename leaves no gate and retries with all prompts", () => { + const { root, sessionsRoot, stateDir } = setup(); + writeSession(sessionsRoot, ["visible", "hidden-prompt"]); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, "hidden.json"), JSON.stringify(["hidden-prompt"])); + const seed = seedFilePath(root, CWD); + const original = fs.renameSync; + fs.renameSync = ((from: fs.PathLike, to: fs.PathLike) => { + if (to === seed) throw new Error("injected seed rename failure"); + return original(from, to); + }) as typeof fs.renameSync; + try { + assert.throws(() => bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir), /injected/); + assert.equal(fs.existsSync(seed), false); + } finally { + fs.renameSync = original; + } + assert.deepEqual(bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir), { + seeded: 1, ran: true, + }); +}); + +test("tombstoned prompts are not seeded from transcripts", () => { + const { root, sessionsRoot, stateDir } = setup(); + writeSession(sessionsRoot, ["visible", "hidden-prompt"]); + // Tombstone "hidden-prompt" (same key shape hide-prompts writes). + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "hidden.json"), + JSON.stringify(["hidden-prompt"]), + "utf8", + ); + bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir); + const texts = fs + .readFileSync(seedFilePath(root, CWD), "utf8") + .trim() + .split("\n") + .map((l) => (JSON.parse(l) as { text: string }).text); + assert.deepEqual(texts, ["visible"]); +}); diff --git a/tests/history-session-scan-directory.test.ts b/tests/history-session-scan-directory.test.ts new file mode 100644 index 000000000..d868b5073 --- /dev/null +++ b/tests/history-session-scan-directory.test.ts @@ -0,0 +1,87 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { listSessionFiles } from "../extensions/history/session-scan.ts"; + +/** + * WU1b-carried T7 (AC-S1-7): the one-level directory exclusion matrix. The + * fixture tree mirrors the pi sessions root — encoded-cwd directories with + * top-level jsonl session files, nested run-N/session.jsonl subagent + * payloads, a subagent-artifacts subtree, and a stray root-level file. + * Fixture files carry garbage content: the scanner must LIST paths only, so + * exact path equality proves nested payloads are never ingested (a + * recursive scanner would emit them). + */ +function makeSessionsRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-dirs-")); +} + +function writeFileAt(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, "garbage-not-json\n", "utf8"); +} + +test("one-level scan rule: only top-level jsonl of cwd dirs; nested payloads, subagent-artifacts, and stray root files never ingested (AC-S1-7)", () => { + const root = makeSessionsRoot(); + + // two cwd directories, each holding top-level jsonl session files + const cwdA = path.join(root, "--home-user-project-a--"); + const cwdB = path.join(root, "--home-user-project-b--"); + writeFileAt(path.join(cwdA, "2026-01-01t10-00-00aaa.jsonl")); + writeFileAt(path.join(cwdA, "2026-01-02t11-00-00bbb.jsonl")); + writeFileAt(path.join(cwdB, "2026-01-03t12-00-00ccc.jsonl")); + + // nested run-N/session.jsonl subagent payloads — never descended + writeFileAt(path.join(cwdA, "run-1", "session.jsonl")); + writeFileAt(path.join(cwdB, "run-2", "session.jsonl")); + + // a subagent-artifacts subtree holding a jsonl file — never descended + writeFileAt(path.join(cwdA, "subagent-artifacts", "artifact.jsonl")); + + // one stray root-level FILE (not a directory) — skipped + writeFileAt(path.join(root, "stray.jsonl")); + + const files = listSessionFiles(root); + + // exactly the three top-level jsonl paths of the cwd directories, + // sorted, absolute + assert.deepEqual(files, [ + path.join(cwdA, "2026-01-01t10-00-00aaa.jsonl"), + path.join(cwdA, "2026-01-02t11-00-00bbb.jsonl"), + path.join(cwdB, "2026-01-03t12-00-00ccc.jsonl"), + ]); + for (const file of files) { + assert.equal(path.isAbsolute(file), true); + } +}); + +// node:test has no test.skipIf (Bun-ism): root skips via the options +// object — chmod 000 is invisible to the superuser. +const sealedDirTest = (name: string, fn: () => void) => + test( + name, + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, + fn, + ); +sealedDirTest( + "an unreadable child dir (chmod 000) is skipped; sibling dirs still list", + () => { + const root = makeSessionsRoot(); + const sealed = path.join(root, "--sealed--"); + const open = path.join(root, "--open--"); + writeFileAt(path.join(sealed, "hidden-session.jsonl")); + writeFileAt(path.join(open, "visible-session.jsonl")); + fs.chmodSync(sealed, 0o000); + try { + // One directory whose readdir fails skips itself — never fatal — and + // the sibling directories still contribute their files. + assert.deepEqual(listSessionFiles(root), [ + path.join(open, "visible-session.jsonl"), + ]); + } finally { + fs.chmodSync(sealed, 0o755); // restore before cleanup + } + }, +); diff --git a/tests/history-session-scan-extract.test.ts b/tests/history-session-scan-extract.test.ts new file mode 100644 index 000000000..7814ef8cc --- /dev/null +++ b/tests/history-session-scan-extract.test.ts @@ -0,0 +1,583 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + extractPromptsFromFile, + listSessionFiles, + MAX_PROMPT_CHARS, +} from "../extensions/history/session-scan.ts"; + +/** + * WU1a fixtures (AC-S1-1..6): synthetic v3 session JSONL written to OS temp + * dirs — the module under test is fs-only and takes the file path as a + * parameter. Object lines serialize compactly (pi's JSONL shape); raw + * strings land verbatim for corrupt-line fixtures. + */ +function writeSessionFile(lines: Array): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-")); + const file = path.join(dir, "session.jsonl"); + const serialized = lines + .map((line) => (typeof line === "string" ? line : JSON.stringify(line))) + .join("\n"); + fs.writeFileSync(file, `${serialized}\n`, "utf8"); + return file; +} + +/** + * WU1b fixtures (AC-S1-8..9): a synthetic pi sessions root whose shape + * mirrors ~/.pi/agent/sessions — encoded-cwd directories holding top-level + * jsonl session files. + */ +function makeSessionsRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-root-")); +} + +function writeFileAt(filePath: string, lines: Array): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const serialized = lines + .map((line) => (typeof line === "string" ? line : JSON.stringify(line))) + .join("\n"); + fs.writeFileSync(filePath, `${serialized}\n`, "utf8"); +} + +function sessionHeader(overrides: Record = {}): object { + return { + type: "session", + version: 3, + timestamp: "2026-01-15T10:00:00.000Z", + id: "session-1", + cwd: "/tmp/project", + ...overrides, + }; +} + +function userTextEntry( + text: string, + options: { messageTimestamp?: number; entryTimestamp?: string } = {}, +): object { + const message: Record = { role: "user", content: text }; + if (options.messageTimestamp !== undefined) { + message.timestamp = options.messageTimestamp; + } + const entry: Record = { + type: "message", + id: "entry-1", + parentId: null, + message, + }; + if (options.entryTimestamp !== undefined) { + entry.timestamp = options.entryTimestamp; + } + return entry; +} + +test("extracts exactly the user text block with the message ms-epoch ts (AC-S1-1)", () => { + const user = { + type: "message", + id: "m1", + parentId: null, + timestamp: "2026-01-15T10:00:01.000Z", + message: { + role: "user", + content: [{ type: "text", text: "hello from the user" }], + timestamp: 1768468801123, + }, + }; + const assistant = { + type: "message", + id: "m2", + parentId: "m1", + timestamp: "2026-01-15T10:00:02.000Z", + message: { + role: "assistant", + content: [{ type: "text", text: "assistant reply" }], + }, + }; + const toolResult = { + type: "message", + id: "m3", + parentId: "m2", + timestamp: "2026-01-15T10:00:03.000Z", + message: { + role: "toolResult", + content: [{ type: "text", text: "tool output" }], + }, + }; + const file = writeSessionFile([sessionHeader(), user, assistant, toolResult]); + const result = extractPromptsFromFile(file); + assert.equal(result.prompts.length, 1); + assert.deepEqual(result.prompts[0], { + text: "hello from the user", + ts: 1768468801123, + }); + assert.equal(result.skippedLines, 0); +}); + +test("excludes every non-prompt entry type and role; the valid user entry still extracts (AC-S1-2)", () => { + // compaction / custom_message / custom carry a nested "role":"user" so the + // substring gate HITS and the parsed type rule must reject them — the gate + // never decides membership. The role exclusions below gate-miss instead. + const exclusions = [ + { + type: "compaction", + message: { role: "user", content: "compacted summary" }, + }, + { type: "branch_summary", summary: "branched from main" }, + { type: "custom", customType: "state_snapshot", data: { role: "user" } }, + { + type: "custom_message", + message: { role: "user", content: "custom message text" }, + }, + { type: "label", name: "checkpoint" }, + { type: "session_info", version: 3 }, + { type: "model_change", message: { role: "assistant", content: "switch" } }, + { type: "thinking_level_change", level: "high" }, + { + type: "message", + message: { + role: "assistant", + content: [{ type: "text", text: "reply" }], + }, + }, + { + type: "message", + message: { + role: "toolResult", + content: [{ type: "text", text: "output" }], + }, + }, + { + type: "message", + message: { + role: "bashExecution", + content: [{ type: "text", text: "ls -la" }], + }, + }, + ]; + const file = writeSessionFile([ + sessionHeader(), + userTextEntry("real prompt"), + ...exclusions, + ]); + const result = extractPromptsFromFile(file); + assert.equal(result.prompts.length, 1); + assert.equal(result.prompts[0].text, "real prompt"); + assert.equal(result.skippedLines, 0); +}); + +test("skips empty, whitespace-only, and images-only user content; neighbors still extract (AC-S1-3)", () => { + const entries = [ + userTextEntry("real text before"), + { + type: "message", + message: { + role: "user", + content: [{ type: "image", source: { type: "base64", data: "img" } }], + }, + }, + { type: "message", message: { role: "user", content: "" } }, + { type: "message", message: { role: "user", content: " \n\t " } }, + { + type: "message", + message: { role: "user", content: [{ type: "text", text: " \t " }] }, + }, + userTextEntry("real text after"), + ]; + const file = writeSessionFile([sessionHeader(), ...entries]); + const result = extractPromptsFromFile(file); + assert.deepEqual( + result.prompts.map((prompt) => prompt.text), + ["real text before", "real text after"], + ); + assert.equal(result.skippedLines, 0); +}); + +test("ts precedence: message ms beats entry ISO; ISO alone; header ts; file mtime; NaN hops tolerated (AC-S1-4)", () => { + // (a) message ms-epoch beats entry ISO + const a = writeSessionFile([ + sessionHeader(), + userTextEntry("a", { + messageTimestamp: 1700000000123, + entryTimestamp: "2023-11-14T22:13:19.000Z", + }), + ]); + assert.equal(extractPromptsFromFile(a).prompts[0].ts, 1700000000123); + + // (b) entry ISO only + const b = writeSessionFile([ + sessionHeader(), + userTextEntry("b", { entryTimestamp: "2024-03-01T09:30:00.000Z" }), + ]); + assert.equal( + extractPromptsFromFile(b).prompts[0].ts, + Date.parse("2024-03-01T09:30:00.000Z"), + ); + + // (c) neither present → header timestamp + const c = writeSessionFile([sessionHeader(), userTextEntry("c")]); + assert.equal( + extractPromptsFromFile(c).prompts[0].ts, + Date.parse("2026-01-15T10:00:00.000Z"), + ); + + // NaN tolerance at the entry-ISO hop: garbage entry timestamp falls through + const garbage = writeSessionFile([ + sessionHeader(), + userTextEntry("g", { entryTimestamp: "not-a-timestamp" }), + ]); + assert.equal( + extractPromptsFromFile(garbage).prompts[0].ts, + Date.parse("2026-01-15T10:00:00.000Z"), + ); + + // final fallback: unparseable header timestamp → the file mtime + const before = Date.now() - 5; + const mtimeFile = writeSessionFile([ + sessionHeader({ timestamp: "garbage" }), + userTextEntry("m"), + ]); + const after = Date.now() + 5000; + const ts = extractPromptsFromFile(mtimeFile).prompts[0].ts; + assert.ok(Number.isFinite(ts)); + assert.ok(ts >= before && ts <= after); +}); + +test("corrupt lines are skipped, counted, and never fatal (AC-S1-5)", () => { + const file = writeSessionFile([ + sessionHeader(), + userTextEntry("one"), + '{"type":"message","message":{"role":"user"', + userTextEntry("two"), + '{broken json with "role":"user" inside}', + userTextEntry("three"), + 'not json "role":"user" at all', + ",{oops", + ]); + const result = extractPromptsFromFile(file); + assert.deepEqual( + result.prompts.map((prompt) => prompt.text), + ["one", "two", "three"], + ); + // The three corrupt gate-hit lines count; the gate-missed corrupt line is + // skipped by the prefilter without ever being parsed or counted. + assert.equal(result.skippedLines, 3); +}); + +test("bad-header aborts yield zero entries without throwing (AC-S1-6)", () => { + const emptyResult = { prompts: [], skippedLines: 0 }; + + // first line missing: an empty file + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-")); + const emptyFile = path.join(emptyDir, "session.jsonl"); + fs.writeFileSync(emptyFile, "", "utf8"); + assert.deepEqual(extractPromptsFromFile(emptyFile), emptyResult); + + // unparseable first line + const unparseable = writeSessionFile([ + "{not json at all", + userTextEntry("ignored"), + ]); + assert.deepEqual(extractPromptsFromFile(unparseable), emptyResult); + + // first line type is not session + const wrongType = writeSessionFile([ + { type: "compaction", version: 3 }, + userTextEntry("ignored"), + ]); + assert.deepEqual(extractPromptsFromFile(wrongType), emptyResult); + + // version >= 4 + const futureVersion = writeSessionFile([ + sessionHeader({ version: 4 }), + userTextEntry("ignored"), + ]); + assert.deepEqual(extractPromptsFromFile(futureVersion), emptyResult); + + // non-numeric version is rejected without coercion + const stringVersion = writeSessionFile([ + sessionHeader({ version: "3" }), + userTextEntry("ignored"), + ]); + assert.deepEqual(extractPromptsFromFile(stringVersion), emptyResult); +}); + +test("boundaries: header-only file, blank padding lines, gate hits that fail the parsed rule (triangulation)", () => { + const emptyResult = { prompts: [], skippedLines: 0 }; + + // header-only file: admitted, zero prompts, zero skips + const headerOnly = writeSessionFile([sessionHeader()]); + assert.deepEqual(extractPromptsFromFile(headerOnly), emptyResult); + + // trailing and interior blank lines never parse (gate economy) + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-")); + const padded = path.join(dir, "session.jsonl"); + fs.writeFileSync( + padded, + JSON.stringify(sessionHeader()) + + "\n\n" + + JSON.stringify(userTextEntry("padded")) + + "\n\n", + "utf8", + ); + const paddedResult = extractPromptsFromFile(padded); + assert.equal(paddedResult.prompts.length, 1); + assert.equal(paddedResult.prompts[0].text, "padded"); + assert.equal(paddedResult.skippedLines, 0); + + // a gate hit whose parsed shape fails the extraction rule is silently + // dropped — the gate alone never decides membership + const gateHit = writeSessionFile([ + sessionHeader(), + { + type: "custom", + payload: { role: "user", content: "nested user literal" }, + }, + ]); + assert.deepEqual(extractPromptsFromFile(gateHit), emptyResult); +}); + +test("gate safety: escaped quotes extract exactly, misses never parse, the gate never decides membership (AC-S1-8)", () => { + const root = makeSessionsRoot(); + const cwdDir = path.join(root, "--tmp-project--"); + + // Escaped quotes beside the role field and inside text values: the raw + // "role":"user" literal survives serialization, the gate hits, and + // JSON.parse decodes the escapes to the exact text. + writeFileAt(path.join(cwdDir, "escaped.jsonl"), [ + sessionHeader(), + { + type: "message", + message: { + content: '"leading quote right before the role field', + role: "user", + }, + }, + { + type: "message", + message: { + role: "user", + content: [{ type: "text", text: 'block with "quoted" words' }], + }, + }, + ]); + + // Sentinel lines WITHOUT the user-role literal: if the gate ever parsed + // them, JSON.parse would throw and skippedLines would count them — a zero + // skip count proves the miss path never parses. + writeFileAt(path.join(cwdDir, "sentinel.jsonl"), [ + sessionHeader(), + "{definitely not json and no role literal", + userTextEntry("real prompt after sentinels"), + "{another broken line, still no literal", + ]); + + // A gate hit that fails the parsed extraction rule is silently dropped: + // the parsed rule, not the substring, decides membership. + writeFileAt(path.join(cwdDir, "gate-only.jsonl"), [ + sessionHeader(), + { + type: "custom", + message: { role: "user", content: "gate hits, rule rejects" }, + }, + ]); + + const prompts: string[] = []; + let skippedLines = 0; + const files = listSessionFiles(root); + assert.equal(files.length, 3); + for (const file of files) { + const result = extractPromptsFromFile(file); + for (const prompt of result.prompts) prompts.push(prompt.text); + skippedLines += result.skippedLines; + } + assert.ok(prompts.includes('"leading quote right before the role field')); + assert.ok(prompts.includes('block with "quoted" words')); + assert.ok(prompts.includes("real prompt after sentinels")); + assert.ok(!prompts.includes("gate hits, rule rejects")); + assert.equal(skippedLines, 0); +}); + +test("v1/v2 legacy tolerance: no id/parentId, weak timestamps resolve through the fallback chain (AC-S1-9)", () => { + const root = makeSessionsRoot(); + const cwdDir = path.join(root, "--legacy-project--"); + + // version-1 header; entries carry no id and no parentId + writeFileAt(path.join(cwdDir, "legacy-v1.jsonl"), [ + { type: "session", version: 1, timestamp: "2025-06-01T08:00:00.000Z" }, + { + type: "message", + timestamp: "2025-06-01T09:00:00.000Z", + message: { role: "user", content: "legacy with entry iso" }, + }, + { + type: "message", + message: { role: "user", content: "legacy bare" }, + }, + ]); + + // neither entry nor header timestamp usable → the file mtime is the tail + const before = Date.now() - 5_000; + writeFileAt(path.join(cwdDir, "legacy-mtime.jsonl"), [ + { type: "session", version: 2, timestamp: "garbage" }, + { type: "message", message: { role: "user", content: "legacy mtime" } }, + ]); + const after = Date.now() + 5_000; + + const byText = new Map(); + for (const file of listSessionFiles(root)) { + for (const prompt of extractPromptsFromFile(file).prompts) { + byText.set(prompt.text, prompt.ts); + } + } + assert.equal(byText.size, 3); + assert.equal( + byText.get("legacy with entry iso"), + Date.parse("2025-06-01T09:00:00.000Z"), + ); + assert.equal( + byText.get("legacy bare"), + Date.parse("2025-06-01T08:00:00.000Z"), + ); + const mtimeTs = byText.get("legacy mtime"); + if (mtimeTs === undefined) { + throw new Error("legacy mtime entry did not extract"); + } + assert.ok(Number.isFinite(mtimeTs)); + assert.ok(mtimeTs >= before && mtimeTs <= after); +}); + +test("multi-block content joins text blocks with a single space, trimmed; string content passes as-is (AC-S1-10)", () => { + const multi = writeSessionFile([ + sessionHeader(), + { + type: "message", + message: { + role: "user", + content: [ + { type: "text", text: "first part" }, + { type: "image", source: { type: "base64", data: "img" } }, + { type: "text", text: "second part" }, + ], + }, + }, + ]); + const multiResult = extractPromptsFromFile(multi); + assert.equal(multiResult.prompts.length, 1); + assert.equal(multiResult.prompts[0].text, "first part second part"); + + // the assembly is trimmed at its ends; the raw join keeps inner spacing + const padded = writeSessionFile([ + sessionHeader(), + { + type: "message", + message: { + role: "user", + content: [ + { type: "text", text: " padded " }, + { type: "text", text: "tail " }, + ], + }, + }, + ]); + const paddedResult = extractPromptsFromFile(padded); + assert.equal(paddedResult.prompts[0].text, "padded tail"); + + // plain-string content extracts as-is (WU1a behavior preserved) + const plain = writeSessionFile([ + sessionHeader(), + userTextEntry("plain string content"), + ]); + assert.equal( + extractPromptsFromFile(plain).prompts[0].text, + "plain string content", + ); +}); + +test("length guard: at MAX_PROMPT_CHARS extracts, strictly above skips uniformly and silently (AC-S1-11)", () => { + const atMax = "a".repeat(MAX_PROMPT_CHARS); + const over = "b".repeat(MAX_PROMPT_CHARS + 1); + const file = writeSessionFile([ + sessionHeader(), + userTextEntry("short entry"), + { type: "message", message: { role: "user", content: atMax } }, + { type: "message", message: { role: "user", content: over } }, + ]); + const result = extractPromptsFromFile(file); + assert.deepEqual( + result.prompts.map((prompt) => prompt.text), + ["short entry", atMax], + ); + // the oversized skip is uniform and silent — not a corruption count + assert.equal(result.skippedLines, 0); +}); + +test("WU1b triangulation: exact length boundary, sorted determinism across runs, empty-root fail-open", () => { + // the boundary is exact: MAX_PROMPT_CHARS extracts, one unit more skips + const exact = "x".repeat(MAX_PROMPT_CHARS); + const boundary = writeSessionFile([ + sessionHeader(), + { type: "message", message: { role: "user", content: exact } }, + { + type: "message", + message: { role: "user", content: "y".repeat(MAX_PROMPT_CHARS + 1) }, + }, + ]); + const boundaryResult = extractPromptsFromFile(boundary); + assert.deepEqual( + boundaryResult.prompts.map((prompt) => prompt.text), + [exact], + ); + assert.equal(boundaryResult.skippedLines, 0); + + // two runs return identical sorted absolute paths (deterministic order) + const root = makeSessionsRoot(); + writeFileAt(path.join(root, "--bbb--", "b.jsonl"), [ + sessionHeader(), + userTextEntry("b"), + ]); + writeFileAt(path.join(root, "--aaa--", "a.jsonl"), [ + sessionHeader(), + userTextEntry("a"), + ]); + const first = listSessionFiles(root); + const second = listSessionFiles(root); + assert.deepEqual(first, second); + assert.deepEqual(first, [ + path.join(root, "--aaa--", "a.jsonl"), + path.join(root, "--bbb--", "b.jsonl"), + ]); + + // a sessions root with no cwd directories yields an empty list, no throw + assert.deepEqual(listSessionFiles(makeSessionsRoot()), []); +}); + +test("a nonexistent path yields the empty result without throwing (triangulation)", () => { + const missing = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-")), + "does-not-exist.jsonl", + ); + assert.deepEqual(extractPromptsFromFile(missing), { + prompts: [], + skippedLines: 0, + }); +}); + +test("a header with NO timestamp field (parseHeader NaN branch) plus timestamp-less messages falls back to the file mtime", () => { + // Distinct from the garbage-header-timestamp case already covered: here + // the header carries no timestamp key at all, so parseHeader returns NaN + // and resolveTimestamp falls all the way through to the file mtime. + const before = Date.now() - 5; + const file = writeSessionFile([ + { type: "session", version: 3 }, + userTextEntry("no ts anywhere"), + ]); + const after = Date.now() + 5000; + const result = extractPromptsFromFile(file); + assert.equal(result.prompts.length, 1); + assert.equal(result.prompts[0].text, "no ts anywhere"); + const ts = result.prompts[0].ts; + assert.ok(Number.isFinite(ts)); + assert.ok(ts >= before && ts <= after); +}); diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index de220b781..a249e8b18 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -7,6 +7,7 @@ import { appendSessionCapture, openSessionWriter, projectHash, + seedFilePath, sessionFilePath, } from "../extensions/history/store.ts"; import promptHistoryExtension, { captureEnabled } from "../extensions/history/index.ts"; @@ -36,7 +37,9 @@ function captureHandlerWith(env: NodeJS.ProcessEnv, root: string) { on: (event: string, handler: unknown) => { registered.push([event, handler]); }, - // Slice-3 stage-1 surface: registration-time no-ops for this harness. + // Slice-3+ wiring surface: the factory also registers the shortcut, + // command, and tool_call dismissal; the capture handler stays the + // first registration, so these no-ops only absorb the extra wiring. registerShortcut: () => {}, registerCommand: () => {}, }; @@ -46,6 +49,8 @@ function captureHandlerWith(env: NodeJS.ProcessEnv, root: string) { cwd: CWD, instanceId: "inst-entry", now: () => 1700000000000, + agentDir: path.join(root, "agent"), + sessionsRoot: path.join(root, "sessions"), }); return registered[0][1] as (event: unknown) => void; } @@ -109,30 +114,38 @@ test("two writers own separate files in the same project dir", () => { assert.deepEqual(files, ["inst-a.jsonl", "inst-b.jsonl"]); }); -test("the extension entry registers the capture handler and the overlay dismiss", () => { +test("the extension entry registers exactly the slice-3 wiring surface", () => { // Module load must stay side-effect free (importing index.ts parses the - // whole slice-1 graph without touching the real ~/.pi store root). The - // pi.on surface is exactly two handlers: before_agent_start (slice-1 - // capture) and tool_call (slice-3 stage-3 overlay dismissal). - // (Shortcut/command registration is slice-3 wiring and is not a pi.on - // event; the fake below stubs it as no-ops.) + // whole graph without touching the real ~/.pi store root). Wiring as of + // slice 3: before_agent_start capture + tool_call overlay dismiss, the + // ctrl+shift+r shortcut, and the history command. session_shutdown is + // slice 6 and must not appear yet. const registered: Array<[string, unknown]> = []; + const shortcuts: Array<[string, unknown]> = []; + const commands: Array<[string, unknown]> = []; const pi = { on: (event: string, handler: unknown) => { registered.push([event, handler]); }, - registerShortcut: () => {}, - registerCommand: () => {}, + registerShortcut: (key: string, def: unknown) => { + shortcuts.push([key, def]); + }, + registerCommand: (name: string, def: unknown) => { + commands.push([name, def]); + }, }; promptHistoryExtension(pi as never); assert.deepEqual( registered.map(([event]) => event), ["before_agent_start", "tool_call"], ); - // The capture handler is callable but is NEVER invoked here: a real - // invocation would run getWriter() against the user's real - // ~/.pi/agent/history. - assert.equal(typeof registered[0][1], "function"); + assert.deepEqual(shortcuts.map(([key]) => key), ["ctrl+shift+r"]); + assert.deepEqual(commands.map(([name]) => name), ["history"]); + // Handlers are callable but are NEVER invoked here: a real invocation + // would run getWriter() against the user's real ~/.pi/agent/history. + for (const [, handler] of registered) { + assert.equal(typeof handler, "function"); + } }); test("captureEnabled is a strict opt-in", () => { @@ -164,6 +177,30 @@ test("an opted-in session captures delivered prompts", () => { ]); }); +test("opted-in capture imports into its own root and defers seed on untrusted tombstones", () => { + const root = makeRoot(); + const sessions = path.join(root, "sessions", "--pi-history-test-project-a--"); + fs.mkdirSync(sessions, { recursive: true }); + fs.writeFileSync(path.join(sessions, "s.jsonl"), [ + JSON.stringify({ type: "session", version: 3 }), + JSON.stringify({ type: "message", message: { role: "user", content: "transcript prompt" } }), + ].join("\n") + "\n"); + const agentDir = path.join(root, "agent"); + fs.mkdirSync(agentDir); + fs.writeFileSync(path.join(agentDir, "editor-history.jsonl"), + JSON.stringify({ v: 1, text: "legacy prompt" }) + "\n"); + fs.writeFileSync(path.join(root, "hidden.json"), "{invalid"); + const handler = captureHandlerWith({ GENTLE_PI_HISTORY_CAPTURE: "1" }, root); + handler({ prompt: "current prompt" }); + assert.deepEqual(fileTexts(path.join(root, "history-global.jsonl")), ["legacy prompt"]); + assert.equal(fs.existsSync(seedFilePath(root, CWD)), false); + assert.deepEqual(fileTexts(sessionFilePath(root, CWD, "inst-entry")), ["current prompt"]); + fs.writeFileSync(path.join(root, "hidden.json"), JSON.stringify(["transcript prompt"])); + // A new instance retries bootstrap after tombstones become trusted. + captureHandlerWith({ GENTLE_PI_HISTORY_CAPTURE: "1" }, root)({ prompt: "next prompt" }); + assert.equal(fs.existsSync(seedFilePath(root, CWD)), false); +}); + test("disabling capture stops new lines and leaves existing files alone", () => { const root = makeRoot(); const env: NodeJS.ProcessEnv = { GENTLE_PI_HISTORY_CAPTURE: "true" };