diff --git a/docs/prompt-history.md b/docs/prompt-history.md new file mode 100644 index 000000000..1fa8eba4f --- /dev/null +++ b/docs/prompt-history.md @@ -0,0 +1,61 @@ +# 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. + +## Capture is opt-in + +Recording is **off by default**. Delivered prompts can contain secrets, and the +deletion UI is not shipped yet, so nothing is stored unless you explicitly opt in: + +```bash +GENTLE_PI_HISTORY_CAPTURE=1 pi +``` + +- Enabled by `1`, `true`, or `on` (case-insensitive). Unset, empty, or any other + value means **off** — the same switch is the disable path. +- The check runs per prompt: unsetting the switch (or setting it to `0`) stops + new captures immediately, no pi restart needed. +- With capture off the extension is inert: no registry entry, no files, and + prompts are never written. + +## Where the files live + +Everything sits under `~/.pi/agent/history/`: + +- `registry.json` — advisory map of project hash → cwd, used for display + labels. +- `projects//.jsonl` — one append-only capture file per pi + process. + +`` 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: + +```json +{"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. + +## Who can read them + +The store is plain JSONL on your local disk, not encrypted. Files are created by +the pi process with default umask permissions (typically `0644` files inside +`0755` directories), so any process running as your OS user can read them, and +other local accounts can too wherever they can traverse your home directory. +Treat the store as sensitive: it holds your prompts verbatim. + +## What disabling capture does + +Turning the switch off only stops **new** captures. Nothing is deleted: files +already written — and the registry entry — stay on disk until you remove them or +the deletion UI ships. To erase the store manually while capture is off (or pi +is not running): + +```bash +rm -rf ~/.pi/agent/history # whole store +rm -rf ~/.pi/agent/history/projects/ # one project (see registry.json) +``` diff --git a/extensions/history/atomic-write.ts b/extensions/history/atomic-write.ts new file mode 100644 index 000000000..cc6ae8147 --- /dev/null +++ b/extensions/history/atomic-write.ts @@ -0,0 +1,38 @@ +import path from "node:path"; +// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history +// SPDX-License-Identifier: MIT + +import fs from "node:fs"; + +/** + * Shared atomic JSON writer (design §D3): serialize to a `.tmp` file in the + * SAME directory as the target, then renameSync it into place — a same-dir + * rename is atomic on POSIX/APFS, so readers see the old or the new file, + * never a partial write. Any error returns false and never throws. + * + * No fsync: both consumers treat lost writes as derived cache (a lost index + * rebuilds on the next open; a lost tombstone resurfaces a prompt the user + * can re-delete), so the per-write fsync cost is not justified — the crash + * window is documented, not fixed. The staging name is unique per write + * (`.tmp--`, the same convention as the store.ts writers): the + * state dir is shared across concurrent pi instances, so a fixed + * `${filePath}.tmp` would let two writers clobber the same staging file + * (torn target JSON, spurious rename failures). A failed write unlinks its + * staging file, so orphaned `.tmp` files do not accumulate. + */ +export function writeJsonAtomic(filePath: string, value: unknown): boolean { + const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(tmpPath, JSON.stringify(value), "utf8"); + fs.renameSync(tmpPath, filePath); + return true; + } catch { + try { + fs.unlinkSync(tmpPath); + } catch { + // staging file never created or already renamed + } + return false; + } +} diff --git a/extensions/history/index.ts b/extensions/history/index.ts new file mode 100644 index 000000000..74dab5942 --- /dev/null +++ b/extensions/history/index.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history +// SPDX-License-Identifier: MIT + +// Prompt-history extension entry (slice 1): identity constants, the +// per-instance writer lifecycle, and the before_agent_start capture +// handler. Selector UI, shortcut/command, scope drains, legacy migration +// and seed bootstrap, and GC arrive in later slices. +// +// Capture is OPT-IN while the deletion/privacy behavior is unshipped: +// nothing is recorded unless GENTLE_PI_HISTORY_CAPTURE=1|true|on. With the +// switch off the handler is a no-op — no registry entry, no files, and +// prompts are never written. Unsetting the switch only stops NEW captures; +// files already written stay on disk (docs/prompt-history.md). + +import { randomUUID } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { + appendSessionCapture, + ensureRegistryEntry, + openSessionWriter, + type SessionWriterState, +} from "./store.ts"; + +// v2 multi-concurrency store root (design: tmp/multi-concurrency-design.md). +const PI_HISTORY_ROOT = join(homedir(), ".pi", "agent", "history"); + +export interface HistoryDeps { + env?: NodeJS.ProcessEnv; + root?: string; + cwd?: string; + instanceId?: string; + now?: () => number; +} + +/** + * Strict opt-in: capture stays off unless GENTLE_PI_HISTORY_CAPTURE is + * explicitly 1, true, or on (case-insensitive). The same switch is the + * disable path — unsetting it stops new captures; files already on disk + * are left untouched until the deletion tooling lands. + */ +export function captureEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const value = env.GENTLE_PI_HISTORY_CAPTURE?.trim().toLowerCase(); + return value === "1" || value === "true" || value === "on"; +} + +export default function promptHistoryExtension( + pi: ExtensionAPI, + deps: HistoryDeps = {}, +): void { + const env = deps.env ?? process.env; + const root = deps.root ?? PI_HISTORY_ROOT; + const cwd = deps.cwd ?? process.cwd(); + const instanceId = deps.instanceId ?? randomUUID(); + const now = deps.now ?? Date.now; + 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. + */ + const getWriter = (): SessionWriterState => { + if (!writerState) { + try { + ensureRegistryEntry(root, cwd); + } catch { + // registry is advisory + } + writerState = openSessionWriter(root, cwd, instanceId); + } + return writerState; + }; + + // Persist every delivered user prompt (write-through, append-only JSONL), + // but only for opted-in sessions — see captureEnabled(). The local + // ExtensionAPI stub types handler args as unknown; narrow here. + pi.on("before_agent_start", (...args: unknown[]) => { + if (!captureEnabled(env)) return; + try { + const event = args[0] as { prompt?: string } | undefined; + appendSessionCapture(getWriter(), event?.prompt ?? "", now()); + } catch { + // A capture failure must never break the agent loop or unregister + // the handler - swallow and keep the next prompt capturable. + } + }); +} diff --git a/extensions/history/store.ts b/extensions/history/store.ts new file mode 100644 index 000000000..f3723f6ad --- /dev/null +++ b/extensions/history/store.ts @@ -0,0 +1,245 @@ +// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history +// SPDX-License-Identifier: MIT + +// Consolidated multi-concurrency store (v2), slice 1: project paths and +// identity, the advisory registry, entry primitives, and the per-instance +// session writer. Scope drains/deletes, legacy migration and seed +// bootstrap, 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"; + +// =========================================================================== +// Paths (formerly store-paths.ts) +// =========================================================================== + +/** + * Project identity for the multi-concurrency store (design v2). + * + * The cwd is canonicalized through realpath — the same resolution pi's + * session-manager applies — so symlinked or differently-spelled paths to one + * project merge into a single identity. A failed resolution (deleted cwd) + * falls back to hashing the raw string: identity degrades, never throws. + */ +export function projectHash(cwd: string): string { + let canonical = cwd; + try { + canonical = fs.realpathSync(cwd); + } catch { + // fall back to the raw path + } + return createHash("sha256").update(canonical).digest("hex").slice(0, 16); +} + +/** The project's directory under the store root. */ +export function projectDir(root: string, cwd: string): string { + return path.join(root, "projects", projectHash(cwd)); +} + +/** The capture file owned by one pi instance (per session/process). */ +export function sessionFilePath( + root: string, + cwd: string, + instanceId: string, +): string { + return path.join(projectDir(root, cwd), `${instanceId}.jsonl`); +} + +/** The rebuildable bootstrap output for a project. */ +export function seedFilePath(root: string, cwd: string): string { + return path.join(projectDir(root, cwd), "seed.jsonl"); +} + +/** The one-time legacy/global seed (never GC'd). */ +export function globalSeedPath(root: string): string { + return path.join(root, "history-global.jsonl"); +} + +/** Advisory hash → cwd map for display labels. */ +export function registryPath(root: string): string { + return path.join(root, "registry.json"); +} + +// =========================================================================== +// Registry (formerly registry.ts) +// =========================================================================== + +export interface RegistryEntryResult { + hash: string; + created: boolean; +} + +type RegistryData = Record; + +function readRegistry(root: string): RegistryData { + try { + const raw = fs.readFileSync(registryPath(root), "utf8"); + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + const out: RegistryData = {}; + for (const [key, value] of Object.entries( + parsed as Record, + )) { + if (typeof value === "string") out[key] = value; + } + return out; + } catch { + return {}; + } +} + +function writeRegistryAtomic(root: string, data: RegistryData): void { + const target = registryPath(root); + const tmp = `${target}.tmp-${process.pid}-${Date.now()}`; + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf8"); + fs.renameSync(tmp, target); +} + +/** + * Ensure the advisory registry maps this project's hash to its cwd. + * Idempotent: an existing identical entry writes nothing. A hash mapped to a + * DIFFERENT cwd is a (practically unreachable) collision — the entry is + * re-keyed at 24 hash chars so both identities coexist. + */ +export function ensureRegistryEntry( + root: string, + cwd: string, +): RegistryEntryResult { + const hash = projectHash(cwd); + const data = readRegistry(root); + if (data[hash] === cwd) return { hash, created: false }; + // An earlier collision may have re-keyed THIS cwd to a long key. + // Return the existing mapping unchanged so collision assignments stay + // stable across calls instead of flipping the other occupant's key. + const existingKey = Object.keys(data).find((k) => data[k] === cwd); + if (existingKey !== undefined) return { hash: existingKey, created: false }; + if (data[hash] !== undefined) { + // Collision: re-key the EXISTING occupant at 24 hash chars so both + // identities coexist; the incoming cwd keeps the short hash — the + // key shape projectDir/sessionFilePath/drains derive. + const existing = data[hash]; + data[projectHashLong(existing)] = existing; + data[hash] = cwd; + writeRegistryAtomic(root, data); + return { hash, created: true }; + } + data[hash] = cwd; + writeRegistryAtomic(root, data); + return { hash, created: true }; +} + +function projectHashLong(cwd: string): string { + // Reuse the same canonicalization as projectHash but keep 24 chars. + let canonical = cwd; + try { + canonical = fs.realpathSync(cwd); + } catch { + // fall back to the raw path + } + return createHash("sha256").update(canonical).digest("hex").slice(0, 24); +} + +// =========================================================================== +// Entry primitives (from v1 history-store.ts) +// =========================================================================== + +/** One line of `editor-history.jsonl`. */ +export interface StoreEntry { + /** Schema version; 1 when absent in the source line. */ + v: number; + text: string; + /** Capture epoch-ms; optional, line order is authoritative for recency. */ + ts?: number; +} + +/** + * Parse one JSONL line. Returns null for malformed lines (bad JSON, + * non-string or whitespace-only text) so callers can skip them; a torn + * last line from a crash is handled the same way. + */ +export function parseStoreLine(raw: string): StoreEntry | null { + if (raw.length === 0) return null; + try { + const value: unknown = JSON.parse(raw); + if (!value || typeof value !== "object") return null; + const record = value as { v?: unknown; text?: unknown; ts?: unknown }; + if (typeof record.text !== "string") return null; + if (record.text.trim().length === 0) return null; + const entry: StoreEntry = { v: 1, text: record.text }; + if (typeof record.v === "number" && Number.isFinite(record.v)) { + entry.v = record.v; + } + if (typeof record.ts === "number" && Number.isFinite(record.ts)) { + entry.ts = record.ts; + } + return entry; + } catch { + return null; + } +} + +// =========================================================================== +// Instance writer (formerly multi-store.ts; scope drains/deletes and GC +// arrive in later slices) +// =========================================================================== + +/** Mutable state of ONE pi instance's exclusive capture file. */ +export interface SessionWriterState { + filePath: string; + /** Logical line count of this instance's file. */ + lineCount: number; +} + +/** Command-like prompts (`/name ...`) are UI commands, not prompts. */ +function isLikelyCommand(text: string): boolean { + return /^\/[A-Za-z]/.test(text.trim()); +} + +function serializeEntry(entry: StoreEntry): string { + const out: { v: number; text: string; ts?: number } = { + v: entry.v, + text: entry.text, + }; + if (entry.ts !== undefined) out.ts = entry.ts; + return JSON.stringify(out); +} + +/** + * Open the writer for this pi instance. The file is created LAZILY by the + * first capture — starting pi must not litter empty files. Only this + * instance ever appends here (design v2: zero shared writes). + */ +export function openSessionWriter( + root: string, + cwd: string, + instanceId: string, +): SessionWriterState { + return { + filePath: sessionFilePath(root, cwd, instanceId), + lineCount: 0, + }; +} + +/** + * Append one prompt line to the instance's own file (write-through). + * Skips empty/whitespace-only and command-like prompts. + */ +export function appendSessionCapture( + state: SessionWriterState, + text: string, + ts?: number, +): void { + if (typeof text !== "string" || text.trim().length === 0) return; + if (isLikelyCommand(text)) return; + + const entry: StoreEntry = { v: 1, text }; + if (ts !== undefined) entry.ts = ts; + fs.mkdirSync(path.dirname(state.filePath), { recursive: true }); + fs.appendFileSync(state.filePath, serializeEntry(entry) + "\n", "utf8"); + state.lineCount += 1; +} diff --git a/tests/history-atomic-write.test.ts b/tests/history-atomic-write.test.ts new file mode 100644 index 000000000..5448398e1 --- /dev/null +++ b/tests/history-atomic-write.test.ts @@ -0,0 +1,57 @@ +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 { writeJsonAtomic } from "../extensions/history/atomic-write.ts"; + +// Shared atomic writer (design §D3): tmp+rename in the TARGET's directory. +// Fixtures live under the OS temp dir — never the workspace tmp. Failure +// paths exercise the catch branch: false return, no throw, and the staging +// file unlinked so `.tmp-*` files never accumulate beside the target. + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "atomic-write-")); +} + +function tmpLeftovers(dir: string): string[] { + return fs.readdirSync(dir).filter((f) => f.includes(".tmp-")); +} + +test("success: missing parent dirs are created recursively and the JSON parses back", () => { + const root = makeRoot(); + const target = path.join(root, "deep", "nested", "state.json"); + const value = { key: "value", nested: { n: 1 } }; + assert.equal(writeJsonAtomic(target, value), true); + assert.deepEqual(JSON.parse(fs.readFileSync(target, "utf8")), value); + assert.deepEqual(tmpLeftovers(path.dirname(target)), []); +}); + +test("a parent chain holding a regular file (ENOTDIR) returns false without throwing", () => { + const root = makeRoot(); + const blocker = path.join(root, "blocker"); + fs.writeFileSync(blocker, "regular file", "utf8"); + const target = path.join(blocker, "child", "state.json"); + assert.equal(writeJsonAtomic(target, { a: 1 }), false); + assert.equal(fs.existsSync(target), false); +}); + +test("an existing directory as the target fails the rename: false and no leftover staging file", () => { + const root = makeRoot(); + const target = path.join(root, "state.json"); + fs.mkdirSync(target, { recursive: true }); + assert.equal(writeJsonAtomic(target, { a: 1 }), false); + // The catch branch unlinked its staging file — no `.tmp-*` accumulation. + assert.deepEqual(tmpLeftovers(root), []); + // The directory itself is untouched. + assert.equal(fs.statSync(target).isDirectory(), true); +}); + +test("overwrite of an existing target replaces the content", () => { + const root = makeRoot(); + const target = path.join(root, "state.json"); + assert.equal(writeJsonAtomic(target, { v: 1 }), true); + assert.equal(writeJsonAtomic(target, { v: 2 }), true); + assert.deepEqual(JSON.parse(fs.readFileSync(target, "utf8")), { v: 2 }); + assert.deepEqual(tmpLeftovers(root), []); +}); diff --git a/tests/history-multi-reader.test.ts b/tests/history-multi-reader.test.ts new file mode 100644 index 000000000..c82ebcbc7 --- /dev/null +++ b/tests/history-multi-reader.test.ts @@ -0,0 +1,203 @@ +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 { writeJsonAtomic } from "../extensions/history/atomic-write.ts"; +import { + appendSessionCapture, + ensureRegistryEntry, + openSessionWriter, + parseStoreLine, + projectDir, + projectHash, + registryPath, + sessionFilePath, +} from "../extensions/history/store.ts"; + +// Slice-1 concurrency/recovery coverage. The dev-suite multi-reader drain +// scenarios are re-expressed against the slice-1 surface (per-instance +// writers, parseStoreLine, atomic writes): parallel writers on one project +// dir, torn-line tolerance, and same-target atomic-write collisions. The +// drain/read ordering scenarios themselves arrive with the slice-2 reader. + +const PROJECT_A = "/pi-history-test/project-a"; +const PROJECT_B = "/pi-history-test/project-b"; + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-multi-reader-")); +} + +function storedTexts(file: string): string[] { + return fs + .readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim().length > 0) + .map((l) => (JSON.parse(l) as { text: string }).text); +} + +// --- parallel writers --- + +test("growth from a concurrent instance is visible on the next read", () => { + const root = makeRoot(); + const a = openSessionWriter(root, PROJECT_A, "inst-a"); + appendSessionCapture(a, "first"); + const dirA = projectDir(root, PROJECT_A); + assert.deepEqual(fs.readdirSync(dirA), ["inst-a.jsonl"]); + + // A second pi instance grows the SAME project dir through its OWN file; + // neither writer reads or rewrites the other's bytes. + const b = openSessionWriter(root, PROJECT_A, "inst-b"); + appendSessionCapture(b, "from-other-instance"); + + assert.deepEqual(fs.readdirSync(dirA).sort(), [ + "inst-a.jsonl", + "inst-b.jsonl", + ]); + assert.deepEqual(storedTexts(sessionFilePath(root, PROJECT_A, "inst-a")), [ + "first", + ]); + assert.deepEqual(storedTexts(sessionFilePath(root, PROJECT_A, "inst-b")), [ + "from-other-instance", + ]); + assert.equal(a.lineCount, 1); + assert.equal(b.lineCount, 1); +}); + +test("interleaved captures from multiple writers never clobber each other", () => { + const root = makeRoot(); + const writers = [ + openSessionWriter(root, PROJECT_A, "w0"), + openSessionWriter(root, PROJECT_A, "w1"), + openSessionWriter(root, PROJECT_A, "w2"), + ]; + for (let i = 0; i < 10; i++) { + for (let w = 0; w < writers.length; w++) { + appendSessionCapture(writers[w], `w${w}-line-${i}`); + } + } + const dir = projectDir(root, PROJECT_A); + assert.deepEqual(fs.readdirSync(dir).sort(), [ + "w0.jsonl", + "w1.jsonl", + "w2.jsonl", + ]); + for (let w = 0; w < writers.length; w++) { + assert.equal(writers[w].lineCount, 10); + assert.deepEqual( + storedTexts(writers[w].filePath), + Array.from({ length: 10 }, (_, i) => `w${w}-line-${i}`), + ); + } +}); + +test("a high-volume burst on one writer keeps every line, in order", () => { + const root = makeRoot(); + const writer = openSessionWriter(root, PROJECT_A, "burst"); + const expected: string[] = []; + for (let i = 0; i < 100; i++) { + const text = `burst-${i}`; + expected.push(text); + appendSessionCapture(writer, text, 1000 + i); + } + assert.equal(writer.lineCount, 100); + const lines = fs.readFileSync(writer.filePath, "utf8").trim().split("\n"); + assert.equal(lines.length, 100); + const parsed = lines.map((l) => JSON.parse(l) as { text: string; ts: number }); + assert.deepEqual( + parsed.map((e) => e.text), + expected, + ); + assert.equal(parsed[42].ts, 1042); +}); + +// --- torn-line / crash recovery --- + +test("torn and malformed lines parse to null (crash garbage never resurfaces)", () => { + // A torn final line (process died mid-write) is a truncated JSON doc. + const torn = JSON.stringify({ v: 1, text: "survivor" }).slice(0, 12); + const malformed: string[] = [ + "", + "{torn", + torn, + "not json at all", + JSON.stringify([]), + JSON.stringify("scalar"), + JSON.stringify(null), + JSON.stringify({ v: 1 }), + JSON.stringify({ text: 42 }), + JSON.stringify({ text: " " }), + ]; + for (const line of malformed) { + assert.equal(parseStoreLine(line), null, JSON.stringify(line)); + } + // Valid lines keep parsing: absent v defaults to 1, ts/v flow through. + assert.deepEqual(parseStoreLine(JSON.stringify({ v: 1, text: "survivor" })), { + v: 1, + text: "survivor", + }); + assert.deepEqual(parseStoreLine(JSON.stringify({ text: "y" })), { + v: 1, + text: "y", + }); + assert.deepEqual(parseStoreLine(JSON.stringify({ v: 2, text: "x", ts: 7 })), { + v: 2, + text: "x", + ts: 7, + }); +}); + +test("a torn final line is tolerated: skipped by readers, later appends continue", () => { + const root = makeRoot(); + const writer = openSessionWriter(root, PROJECT_A, "torn"); + appendSessionCapture(writer, "before-crash"); + // Crash mid-write: a partial line lands WITHOUT its trailing newline. + fs.appendFileSync(writer.filePath, `{"v":1,"text":"tor`, "utf8"); + // parseStoreLine skips the torn tail instead of throwing... + assert.equal(parseStoreLine('{"v":1,"text":"tor'), null); + // ...and the instance keeps capturing. The first append after a + // newline-less torn tail merges with the fragment (one accepted lost + // entry — the same crash window the design documents for lost writes); + // the next full line parses cleanly again. + appendSessionCapture(writer, "after-crash"); + appendSessionCapture(writer, "after-crash-2"); + assert.equal(writer.lineCount, 3); + const lines = fs.readFileSync(writer.filePath, "utf8").trim().split("\n"); + assert.equal(lines.length, 3); + assert.equal((JSON.parse(lines[0]) as { text: string }).text, "before-crash"); + assert.equal(parseStoreLine(lines[1]), null); // torn fragment + merged entry + assert.equal( + (JSON.parse(lines[2]) as { text: string }).text, + "after-crash-2", + ); +}); + +// --- atomic-write collisions --- + +test("rapid same-target atomic writes leave one valid document and no staging files", () => { + const root = makeRoot(); + const target = path.join(root, "shared-state.json"); + for (let i = 0; i < 25; i++) { + assert.equal(writeJsonAtomic(target, { writer: i }), true); + } + const final = JSON.parse(fs.readFileSync(target, "utf8")) as { + writer: number; + }; + assert.ok(final.writer >= 0 && final.writer <= 24); + const leftovers = fs.readdirSync(root).filter((f) => f.includes(".tmp-")); + assert.deepEqual(leftovers, []); +}); + +test("interleaved registry updates from two instances keep both entries", () => { + const root = makeRoot(); + for (let i = 0; i < 3; i++) { + ensureRegistryEntry(root, PROJECT_A); + ensureRegistryEntry(root, PROJECT_B); + } + const raw = JSON.parse( + fs.readFileSync(registryPath(root), "utf8"), + ) as Record; + assert.equal(Object.keys(raw).length, 2); + assert.equal(raw[projectHash(PROJECT_A)], PROJECT_A); + assert.equal(raw[projectHash(PROJECT_B)], PROJECT_B); +}); diff --git a/tests/history-registry.test.ts b/tests/history-registry.test.ts new file mode 100644 index 000000000..23235f8cd --- /dev/null +++ b/tests/history-registry.test.ts @@ -0,0 +1,143 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + ensureRegistryEntry, + projectHash, + registryPath, +} from "../extensions/history/store.ts"; + +// Slice-1 port note: the dev suite asserted lookups through the dead +// `lookupCwd` export, which slice 1 drops. Every lookup assertion is +// re-expressed against the persisted registry.json content. + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-registry-")); +} + +function readRegistryFile(root: string): Record { + return JSON.parse( + fs.readFileSync(registryPath(root), "utf8"), + ) as Record; +} + +test("creates the registry with the first entry (idempotent)", () => { + const root = makeRoot(); + const result = ensureRegistryEntry(root, "/pi-history-test/project-a"); + assert.deepEqual(result, { hash: "4be15ec687e9df85", created: true }); + ensureRegistryEntry(root, "/pi-history-test/project-a"); + assert.deepEqual(readRegistryFile(root), { + "4be15ec687e9df85": "/pi-history-test/project-a", + }); +}); + +test("second project appends without touching the first", () => { + const root = makeRoot(); + ensureRegistryEntry(root, "/pi-history-test/project-a"); + const b = ensureRegistryEntry(root, "/pi-history-test/project-b"); + assert.equal(b.created, true); + const raw = readRegistryFile(root); + assert.equal(Object.keys(raw).length, 2); + assert.equal(raw[b.hash], "/pi-history-test/project-b"); +}); + +test("registry.json maps known hashes and omits unknown ones", () => { + const root = makeRoot(); + const { hash } = ensureRegistryEntry(root, "/pi-history-test/project-a"); + const raw = readRegistryFile(root); + assert.equal(raw[hash], "/pi-history-test/project-a"); + assert.equal(raw["0000000000000000"], undefined); + // A fresh root has no registry file until its first entry lands. + assert.equal(fs.existsSync(registryPath(makeRoot())), false); +}); + +test("corrupt registry json is treated as empty and rebuilt on next entry", () => { + const root = makeRoot(); + fs.writeFileSync(registryPath(root), "{not-json", "utf8"); + const result = ensureRegistryEntry(root, "/pi-history-test/project-a"); + assert.equal(result.created, true); + // The corrupt content was discarded (fail-open to empty), so the rebuilt + // registry contains exactly the new entry and nothing else. + assert.deepEqual(readRegistryFile(root), { + "4be15ec687e9df85": "/pi-history-test/project-a", + }); +}); + +test("no leftover tmp files after writes", () => { + const root = makeRoot(); + ensureRegistryEntry(root, "/a"); + ensureRegistryEntry(root, "/b"); + const leftovers = fs.readdirSync(root).filter((f) => f.includes(".tmp-")); + assert.deepEqual(leftovers, []); +}); + +test("hash collision re-keys the existing occupant; the new cwd keeps the short hash", () => { + const root = makeRoot(); + const cwd = "/pi-history-test/project-a"; + const hash = projectHash(cwd); + // Simulate a collision: the short hash is pre-mapped to a different cwd. + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync( + registryPath(root), + JSON.stringify({ [hash]: "/some/other/project" }), + "utf8", + ); + const result = ensureRegistryEntry(root, cwd); + assert.deepEqual(result, { hash, created: true }); + const raw = readRegistryFile(root); + assert.equal(raw[hash], cwd); + const longKeys = Object.keys(raw).filter((k) => k.length === 24); + assert.equal(longKeys.length, 1); + assert.equal(raw[longKeys[0]], "/some/other/project"); +}); + +test("a re-keyed cwd keeps its long key on later calls (stable collision mappings)", () => { + // projectHashLong is private: derive the documented 24-char key here — + // the literals never exist, so canonicalization falls back to the raw + // string on every platform. + const longKey = (cwd: string) => + createHash("sha256").update(cwd).digest("hex").slice(0, 24); + const root = makeRoot(); + const a = "/pi-history-test/registry-collide-a"; + const b = "/pi-history-test/registry-collide-b"; + // Simulate the collision: b's short hash is pre-mapped to a different + // cwd, so entering b re-keys that occupant to a 24-char key. + const shortHash = projectHash(b); + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync( + registryPath(root), + JSON.stringify({ [shortHash]: a }), + "utf8", + ); + ensureRegistryEntry(root, b); // collision: a re-keyed to 24 chars + const before = readRegistryFile(root); + // Re-entering the re-keyed cwd must return its EXISTING long key and + // leave the other occupant's short-hash mapping untouched. + const again = ensureRegistryEntry(root, a); + assert.equal(again.created, false); + assert.equal(again.hash, longKey(a)); + const after = readRegistryFile(root); + assert.deepEqual(after, before); + // And re-entering the short-hash holder keeps the short key. + const holder = ensureRegistryEntry(root, b); + assert.equal(holder.hash, projectHash(b)); + assert.deepEqual(readRegistryFile(root), before); +}); + +test("wrong-shaped registry (array / scalar / null) fails open and is rebuilt on the next entry", () => { + // Valid JSON, wrong shape: the readRegistry shape guard treats each as an + // empty registry, and the next entry rebuilds a valid object-mapped + // registry around itself. + const shapes: unknown[] = [["an", "array"], "scalar-string", null]; + const cwd = "/pi-history-test/project-a"; + for (const shape of shapes) { + const root = makeRoot(); + fs.writeFileSync(registryPath(root), JSON.stringify(shape), "utf8"); + const result = ensureRegistryEntry(root, cwd); + assert.deepEqual(result, { hash: projectHash(cwd), created: true }); + assert.deepEqual(readRegistryFile(root), { [projectHash(cwd)]: cwd }); + } +}); diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts new file mode 100644 index 000000000..4128b7eef --- /dev/null +++ b/tests/history-session-writer.test.ts @@ -0,0 +1,168 @@ +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 { + appendSessionCapture, + openSessionWriter, + projectHash, + sessionFilePath, +} from "../extensions/history/store.ts"; +import promptHistoryExtension, { captureEnabled } from "../extensions/history/index.ts"; + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-writer-")); +} + +const CWD = "/pi-history-test/project-a"; + +function fileTexts(file: string): string[] { + return fs + .readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim().length > 0) + .map((l) => (JSON.parse(l) as { text: string }).text); +} + +function openWriterForTest(root: string, instanceId: string) { + return openSessionWriter(root, CWD, instanceId); +} + +/** Load the extension against a temp root and return the capture handler. */ +function captureHandlerWith(env: NodeJS.ProcessEnv, root: string) { + const registered: Array<[string, unknown]> = []; + const pi = { + on: (event: string, handler: unknown) => { + registered.push([event, handler]); + }, + }; + promptHistoryExtension(pi as never, { + env, + root, + cwd: CWD, + instanceId: "inst-entry", + now: () => 1700000000000, + }); + return registered[0][1] as (event: unknown) => void; +} + +test("no file is created until the first capture", () => { + const root = makeRoot(); + const state = openWriterForTest(root, "sess-1"); + const file = sessionFilePath(root, CWD, "sess-1"); + assert.equal(fs.existsSync(file), false); + assert.equal(state.lineCount, 0); +}); + +test("first capture lazily creates the file and appends one line", () => { + const root = makeRoot(); + const state = openWriterForTest(root, "sess-1"); + appendSessionCapture(state, "hello world", 1234); + const file = sessionFilePath(root, CWD, "sess-1"); + assert.equal(fs.existsSync(file), true); + const lines = fs.readFileSync(file, "utf8").trim().split("\n"); + assert.equal(lines.length, 1); + const parsed = JSON.parse(lines[0]); + assert.equal(parsed.text, "hello world"); + assert.equal(parsed.ts, 1234); + assert.equal(parsed.v, 1); + assert.equal(state.lineCount, 1); +}); + +test("captures append in order; count tracks", () => { + const root = makeRoot(); + const state = openWriterForTest(root, "sess-2"); + appendSessionCapture(state, "one"); + appendSessionCapture(state, "two"); + appendSessionCapture(state, "three"); + assert.deepEqual(fileTexts(sessionFilePath(root, CWD, "sess-2")), [ + "one", + "two", + "three", + ]); + assert.equal(state.lineCount, 3); +}); + +test("command-like and empty captures are skipped", () => { + const root = makeRoot(); + const state = openWriterForTest(root, "sess-3"); + appendSessionCapture(state, "/compact"); + appendSessionCapture(state, " "); + appendSessionCapture(state, ""); + appendSessionCapture(state, "kept"); + assert.deepEqual(fileTexts(sessionFilePath(root, CWD, "sess-3")), ["kept"]); + assert.equal(state.lineCount, 1); +}); + +test("two writers own separate files in the same project dir", () => { + const root = makeRoot(); + const a = openWriterForTest(root, "inst-a"); + const b = openWriterForTest(root, "inst-b"); + appendSessionCapture(a, "from-a"); + appendSessionCapture(b, "from-b"); + const dir = path.join(root, "projects", projectHash(CWD)); + const files = fs.readdirSync(dir).sort(); + assert.deepEqual(files, ["inst-a.jsonl", "inst-b.jsonl"]); +}); + +test("the slice-1 extension entry registers only the capture handler", () => { + // Module load must stay side-effect free (importing index.ts parses the + // whole slice-1 graph without touching the real ~/.pi store root), and + // slice 1 wires exactly one handler: before_agent_start. + const registered: Array<[string, unknown]> = []; + const pi = { + on: (event: string, handler: unknown) => { + registered.push([event, handler]); + }, + }; + promptHistoryExtension(pi as never); + assert.deepEqual( + registered.map(([event]) => event), + ["before_agent_start"], + ); + // The 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"); +}); + +test("captureEnabled is a strict opt-in", () => { + assert.equal(captureEnabled({}), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "0" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "false" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "off" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "yes" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: " 1 " }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "TRUE" }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "On" }), true); +}); + +test("the capture handler is a no-op unless the user opts in", () => { + const root = makeRoot(); + const handler = captureHandlerWith({}, root); + handler({ prompt: "sensitive prompt" }); + handler({ prompt: "another one" }); + // Nothing at all: no capture file, no project dir, no registry entry. + assert.deepEqual(fs.readdirSync(root), []); +}); + +test("an opted-in session captures delivered prompts", () => { + const root = makeRoot(); + const handler = captureHandlerWith({ GENTLE_PI_HISTORY_CAPTURE: "1" }, root); + handler({ prompt: "hello store" }); + assert.deepEqual(fileTexts(sessionFilePath(root, CWD, "inst-entry")), [ + "hello store", + ]); +}); + +test("disabling capture stops new lines and leaves existing files alone", () => { + const root = makeRoot(); + const env: NodeJS.ProcessEnv = { GENTLE_PI_HISTORY_CAPTURE: "true" }; + const handler = captureHandlerWith(env, root); + handler({ prompt: "kept" }); + const file = sessionFilePath(root, CWD, "inst-entry"); + assert.equal(fs.existsSync(file), true); + delete env.GENTLE_PI_HISTORY_CAPTURE; + handler({ prompt: "never written" }); + assert.deepEqual(fileTexts(file), ["kept"]); +}); diff --git a/tests/history-store-paths.test.ts b/tests/history-store-paths.test.ts new file mode 100644 index 000000000..a6e5be45a --- /dev/null +++ b/tests/history-store-paths.test.ts @@ -0,0 +1,79 @@ +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, + projectDir, + projectHash, + registryPath, + seedFilePath, + sessionFilePath, +} from "../extensions/history/store.ts"; + +const ROOT = path.join(os.tmpdir(), "pi-history-test-root"); + +test("projectHash returns 16 lowercase hex chars", () => { + const hash = projectHash("/pi-history-test/project-a"); + assert.match(hash, /^[0-9a-f]{16}$/); +}); + +test("known vector: stable hash for a fixed path", () => { + // The literal exists on no machine, so every platform exercises the + // documented raw-string fallback: sha256(literal), first 16 hex chars. + assert.equal( + projectHash("/pi-history-test/project-a"), + "4be15ec687e9df85", + ); +}); + +test("distinct paths produce distinct hashes", () => { + assert.notEqual( + projectHash("/pi-history-test/project-a"), + projectHash("/pi-history-test/project-b"), + ); +}); + +test("symlinked cwd resolves to the same hash as its target", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paths-sym-")); + const target = path.join(dir, "real-project"); + fs.mkdirSync(target); + const link = path.join(dir, "link-project"); + fs.symlinkSync(target, link); + assert.equal(projectHash(link), projectHash(target)); +}); + +test("trailing slash does not change the identity", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paths-slash-")); + assert.equal(projectHash(dir), projectHash(`${dir}/`)); +}); + +test("nonexistent path falls back to hashing the raw string (no throw)", () => { + const missing = path.join(os.tmpdir(), "paths-missing-does-not-exist"); + const hash = projectHash(missing); + assert.match(hash, /^[0-9a-f]{16}$/); +}); + +test("path derivations compose under the root", () => { + const cwd = "/pi-history-test/project-a"; + const hash = projectHash(cwd); + assert.equal(projectDir(ROOT, cwd), path.join(ROOT, "projects", hash)); + assert.equal( + sessionFilePath(ROOT, cwd, "abc-123"), + path.join(ROOT, "projects", hash, "abc-123.jsonl"), + ); + assert.equal( + seedFilePath(ROOT, cwd), + path.join(ROOT, "projects", hash, "seed.jsonl"), + ); + assert.equal(globalSeedPath(ROOT), path.join(ROOT, "history-global.jsonl")); + assert.equal(registryPath(ROOT), path.join(ROOT, "registry.json")); +}); + +test("two cwds map to sibling project dirs", () => { + const a = projectDir(ROOT, "/pi-history-test/project-a"); + const b = projectDir(ROOT, "/pi-history-test/project-b"); + assert.notEqual(a, b); + assert.equal(path.dirname(a), path.dirname(b)); +});