From 96443ee6886ace0116666a3ecac8fe087c4e07ca Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:03:45 -0300 Subject: [PATCH 01/13] feat(history): GC/compaction with active-writer and failure-path tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 6/6 of the PR #819 split (maintainer-requested review slices). - store: GC/compaction section — thresholds (50 files / 5000 lines / keep-newest-10), gcProjectDir entry point, compactFiles (merge all but newest 10 into compact-.jsonl, atomic write BEFORE originals removed, rm failure tolerated); dead compactProjectDir export (zero callers) dropped — store.ts now carries upstream content minus the documented dead exports - index.ts: session_shutdown handler wired to gcProjectDir (final registration surface: before_agent_start, session_shutdown, tool_call, ctrl+shift+r shortcut, history command) - tests: 9 new node:test cases (cumulative 174/174): threshold no-op below limits, keep-newest-10 untouched, line-threshold trigger, missing-dir and unreadable-file skips, atomic-before-rm ordering, rm-failure tolerance, concurrent append during compaction never loses post-compaction writes; tmpdir fixtures with machine-independent literals throughout Gates: cumulative scoped history tests 174/174 green — the complete six-slice chain. Known pre-existing environmental gate failures unchanged (gitignored contracts/.DS_Store; package-manifest needs node_modules, now installed). --- extensions/history/index.ts | 14 +- extensions/history/store.ts | 109 +++++++- tests/history-gc.test.ts | 360 +++++++++++++++++++++++++++ tests/history-session-writer.test.ts | 10 +- 4 files changed, 479 insertions(+), 14 deletions(-) create mode 100644 tests/history-gc.test.ts diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 424008b68..dd96c3157 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -4,8 +4,8 @@ // Prompt-history extension entry (slice 3): the selector TUI, overlay glue, // and the shortcut/command wiring over the slice-1 writer, slice-2 drains, // and slice-4 init sequence (legacy migration + seed bootstrap run once -// inside getWriter). Deletion (slice 5) is wired here; GC/compaction -// (slice 6) arrives in a later slice. +// inside getWriter). Deletion (slice 5) and GC/compaction (slice 6) are +// wired here. import { join } from "node:path"; import { homedir } from "node:os"; @@ -22,6 +22,7 @@ import { deleteFromProject, drainGlobal, drainProject, + gcProjectDir, ensureRegistryEntry, migrateLegacyStores, openSessionWriter, @@ -1015,6 +1016,15 @@ export default function promptHistoryExtension(pi: ExtensionAPI) { } }); + // Backup pass: enforce the 1000-line limit on graceful shutdown. + pi.on("session_shutdown", () => { + try { + gcProjectDir(PI_HISTORY_ROOT, CURRENT_CWD); + } catch { + // GC is best-effort + } + }); + // When a tool asks for user input while the history overlay is open, // dismiss the overlay so the tool can take over the UI. pi.on("tool_call", () => { diff --git a/extensions/history/store.ts b/extensions/history/store.ts index 04626f48e..7ad170774 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -1,13 +1,12 @@ // SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history // SPDX-License-Identifier: MIT -// Consolidated multi-concurrency store (v2), slices 1+2+4: project paths +// Consolidated multi-concurrency store (v2), slices 1-6: 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). +// tombstone filter, project/global drains), scope deletes, legacy +// migration, the project seed bootstrap, and GC/compaction. Formerly +// store-paths.ts + registry.ts + multi-store.ts (+ v1 primitives). import { createHash } from "node:crypto"; import fs from "node:fs"; @@ -193,8 +192,7 @@ export function parseStoreLine(raw: string): StoreEntry | null { } // =========================================================================== -// Instance writer (formerly multi-store.ts; GC/compaction arrives in a -// later slice) +// Instance writer (formerly multi-store.ts) // =========================================================================== /** Mutable state of ONE pi instance's exclusive capture file. */ @@ -671,3 +669,100 @@ export function bootstrapProjectSeed( fs.renameSync(tmp, seed); return { seeded: collected.length, ran: true }; } + +// --------------------------------------------------------------------------- +// GC / compaction (design v2) +// --------------------------------------------------------------------------- + +const GC_FILE_THRESHOLD = 50; +const GC_LINE_THRESHOLD = 5000; +const GC_KEEP_NEWEST = 10; + +export interface GcResult { + compacted: boolean; + merged: number; +} + +/** + * Threshold check + compaction entry point (called at shutdown and at + * selector close). Compacts when a project dir holds more than + * GC_FILE_THRESHOLD files or GC_LINE_THRESHOLD total lines. + */ +export function gcProjectDir( + root: string, + cwd: string, + opts: { + fileThreshold?: number; + lineThreshold?: number; + keepNewest?: number; + } = {}, +): GcResult { + const fileThreshold = opts.fileThreshold ?? GC_FILE_THRESHOLD; + const lineThreshold = opts.lineThreshold ?? GC_LINE_THRESHOLD; + const keepNewest = opts.keepNewest ?? GC_KEEP_NEWEST; + const dir = path.join(root, "projects", projectHash(cwd)); + const files = listProjectFiles(dir); // mtime-desc + if (files.length === 0) return { compacted: false, merged: 0 }; + + let totalLines = 0; + for (const file of files) { + try { + totalLines += fs + .readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim().length > 0).length; + } catch { + // unreadable file: skip counting + } + } + if (files.length <= fileThreshold && totalLines <= lineThreshold) { + return { compacted: false, merged: 0 }; + } + return compactFiles(files, keepNewest); +} + +/** + * Merge all but the newest `keepNewest` files into one `compact-.jsonl` + * (chronological within the merged content). One atomic write; the + * originals are removed only after the compact file lands. Readers see + * either the old set or the compacted set. (Upstream exposed this as + * compactProjectDir; dropped here — zero callers, gcProjectDir is the + * single entry point.) + */ +function compactFiles( + filesMtimeDesc: string[], + keepNewest: number, +): GcResult { + if (filesMtimeDesc.length <= keepNewest) { + return { compacted: false, merged: 0 }; + } + const toMerge = filesMtimeDesc.slice(keepNewest); // oldest tail + const mergedLines: string[] = []; + for (const file of toMerge) { + try { + const raw = fs.readFileSync(file, "utf8"); + for (const lineText of raw.split("\n")) { + const parsed = parseStoreLine(lineText); + if (parsed) mergedLines.push(JSON.stringify(parsed)); + } + } catch { + // unreadable file: skip its content, still remove nothing + continue; + } + } + if (mergedLines.length === 0) return { compacted: false, merged: 0 }; + + const dir = path.dirname(toMerge[0]); + const compact = path.join(dir, `compact-${Date.now()}.jsonl`); + const tmp = `${compact}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync(tmp, mergedLines.join("\n") + "\n", "utf8"); + fs.renameSync(tmp, compact); + for (const file of toMerge) { + try { + fs.rmSync(file); + } catch { + // a surviving original is harmless (readers dedupe by identity) + } + } + return { compacted: true, merged: toMerge.length }; +} diff --git a/tests/history-gc.test.ts b/tests/history-gc.test.ts new file mode 100644 index 000000000..adfe2a7ca --- /dev/null +++ b/tests/history-gc.test.ts @@ -0,0 +1,360 @@ +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 { gcProjectDir, projectHash } from "../extensions/history/store.ts"; + +// GC/compaction (slice 6): threshold no-op below the limits, keep-newest +// semantics, and the failure paths — the compact file lands atomically +// before any original is removed, cleanup failures are tolerated, unreadable +// files are skipped, and an append landing mid-compaction is never lost. +// All fixtures live under os.tmpdir(): the user's real ~/.pi store root is +// never touched. (Ported from the dev repo's test/history/gc.test.ts; the +// dev-only compactProjectDir shortcut is gone — gcProjectDir with explicit +// thresholds is the single PR-branch entry point.) + +const CWD = "/pi-history-test/project-gc"; + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-gc-")); +} + +function projectRoot(root: string): string { + return path.join(root, "projects", projectHash(CWD)); +} + +function writeFile( + dir: string, + name: string, + count: number, + mtimeMs: number, +): string { + const file = path.join(dir, name); + fs.writeFileSync( + file, + `${Array.from({ length: count }, (_, i) => + JSON.stringify({ v: 1, text: `${name}-${i}` }), + ).join("\n")}\n`, + "utf8", + ); + fs.utimesSync(file, new Date(mtimeMs), new Date(mtimeMs)); + return file; +} + +function totalLines(dir: string): number { + let total = 0; + for (const f of fs.readdirSync(dir)) { + if (!f.endsWith(".jsonl")) continue; + total += fs + .readFileSync(path.join(dir, f), "utf8") + .split("\n") + .filter((l) => l.trim().length > 0).length; + } + return total; +} + +/** Line texts of the single compact-*.jsonl file in dir (must exist). */ +function compactTexts(dir: string): string[] { + const compact = fs.readdirSync(dir).find((f) => f.startsWith("compact-")); + assert.ok(compact, "a compact-*.jsonl file must exist"); + return fs + .readFileSync(path.join(dir, compact), "utf8") + .trim() + .split("\n") + .map((l) => (JSON.parse(l) as { text: string }).text); +} + +/** + * Replace fs.rmSync (the shared CJS exports object store.ts resolves at + * call time) for the duration of fn; the original is always restored. + * `rmSync` inside the replacement is the captured original, so replacements + * can observe-or-fail and then call through. + */ +function withRmSyncPatched( + replacement: (file: string, rmSync: (file: string) => void) => void, + fn: () => void, +): void { + type RmSync = (file: string) => void; + const realRmSync = fs.rmSync.bind(fs) as RmSync; + const target = fs as unknown as { rmSync: RmSync }; + target.rmSync = (file: string) => { + replacement(file, realRmSync); + }; + try { + fn(); + } finally { + target.rmSync = realRmSync; + } +} + +test("under both thresholds: GC is a no-op", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + writeFile(dir, "a.jsonl", 10, 1000); + writeFile(dir, "b.jsonl", 10, 2000); + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 1, + }); + assert.deepEqual(result, { compacted: false, merged: 0 }); + assert.equal(fs.readdirSync(dir).length, 2); +}); + +test("file-count threshold merges the oldest files into one compact file", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // 12 files (threshold 10) x 10 lines each. + for (let i = 1; i <= 12; i++) { + writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000); + } + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 1, + }); + assert.deepEqual(result, { compacted: true, merged: 11 }); + // 12 files -> newest 1 kept + 1 compact file = 2 files; all lines kept. + assert.equal(fs.readdirSync(dir).length, 2); + assert.equal(totalLines(dir), 120); + // The compact file is the renamed final artifact, not a staging leftover. + assert.match( + fs.readdirSync(dir).find((f) => f.startsWith("compact-")) ?? "", + /^compact-\d+\.jsonl$/, + ); + assert.deepEqual( + fs.readdirSync(dir).filter((f) => f.includes(".tmp-")), + [], + ); + // The newest original file survives untouched by name. + assert.equal(fs.readdirSync(dir).includes("f12.jsonl"), true); +}); + +test("line-count threshold triggers compaction too", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // 3 files x 4000 lines = 12000 > 10000 threshold. + for (let i = 1; i <= 3; i++) { + writeFile(dir, `g${i}.jsonl`, 4000, i * 1000); + } + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 1, + }); + assert.equal(result.compacted, true); + assert.equal(totalLines(dir), 12000); + assert.equal(fs.readdirSync(dir).includes("g3.jsonl"), true); +}); + +test("GC on a missing project dir is a no-op", () => { + const root = makeRoot(); + const result = gcProjectDir(root, "/does/not/exist"); + assert.deepEqual(result, { compacted: false, merged: 0 }); +}); + +test("compaction keeps the newest 10 files, merges the rest", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + for (let i = 1; i <= 15; i++) { + writeFile(dir, `h${String(i).padStart(2, "0")}.jsonl`, 5, i * 1000); + } + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 10, + }); + assert.deepEqual(result, { compacted: true, merged: 5 }); + const names = fs.readdirSync(dir).sort(); + // 10 newest originals + 1 compact file. + assert.equal(names.length, 11); + assert.equal(names[0].startsWith("compact-"), true); + assert.equal(names.includes("h15.jsonl"), true); + assert.equal(names.includes("h05.jsonl"), false); + assert.equal(names.includes("h06.jsonl"), true); +}); + +// node:test has no test.skipIf (Bun-ism): root skips via the options object. +test( + "an unreadable file (chmod 000) is skipped; GC still compacts the readable tail", + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, + () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // 3 files, keepNewest 1 -> the two oldest merge; the sealed one sits in + // the merged tail so its bytes hit the unreadable-skip branch (both the + // line-counting pass and the merge pass skip it). + writeFile(dir, "readable-old.jsonl", 5, 1000); + const sealed = writeFile(dir, "sealed-old.jsonl", 5, 2000); + writeFile(dir, "newest.jsonl", 5, 3000); + fs.chmodSync(sealed, 0o000); + try { + const result = gcProjectDir(root, CWD, { + fileThreshold: 2, + lineThreshold: 100000, + keepNewest: 1, + }); + // The merged count covers the whole tail, sealed file included. + assert.deepEqual(result, { compacted: true, merged: 2 }); + // Only the readable tail file's entries compacted; the sealed bytes + // were skipped, never fatal. (writeFile names entries `${name}-${i}`.) + assert.deepEqual(compactTexts(dir), [ + "readable-old.jsonl-0", + "readable-old.jsonl-1", + "readable-old.jsonl-2", + "readable-old.jsonl-3", + "readable-old.jsonl-4", + ]); + // Cleanup semantics: the tail originals (sealed one included) are + // removed after the compact file lands — unlink needs no read access. + assert.equal(fs.existsSync(sealed), false); + assert.equal(fs.readdirSync(dir).includes("newest.jsonl"), true); + } finally { + // The compaction removes the sealed original; restore only if it + // survived an early failure so cleanup never leaves a 000 file. + try { + fs.chmodSync(sealed, 0o644); + } catch { + // already removed by the compaction + } + } + }, +); + +test("the compact file lands complete before any original is removed", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + for (let i = 1; i <= 12; i++) { + writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000); + } + // Observe, do not replace: at the FIRST cleanup unlink the compact file + // must already exist on disk with the full merged content (110 lines). + // That is the crash-safe ordering contract: readers never see the tail + // gone with no compact file in its place. + let compactCompleteAtFirstRm: boolean | null = null; + withRmSyncPatched( + (file, rmSync) => { + if (compactCompleteAtFirstRm === null) { + const parent = path.dirname(file); + const compact = fs + .readdirSync(parent) + .find((f) => f.startsWith("compact-")); + compactCompleteAtFirstRm = + compact !== undefined && + fs + .readFileSync(path.join(parent, compact), "utf8") + .trim() + .split("\n") + .filter((l) => l.trim().length > 0).length === 110; + } + rmSync(file); + }, + () => { + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 1, + }); + assert.deepEqual(result, { compacted: true, merged: 11 }); + }, + ); + assert.equal(compactCompleteAtFirstRm, true); +}); + +test("rm failure is tolerated: originals survive, GC still reports success", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + for (let i = 1; i <= 12; i++) { + writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000); + } + // Simulate every cleanup unlink failing (e.g. originals held by another + // process): the compact file already landed, so a surviving original is + // harmless — readers dedupe by identity. + withRmSyncPatched( + () => { + throw new Error("simulated EBUSY: original still held"); + }, + () => { + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 1, + }); + // The success shape is unchanged even though cleanup failed. + assert.deepEqual(result, { compacted: true, merged: 11 }); + }, + ); + // The compact file is complete on disk... + assert.equal(compactTexts(dir).length, 110); + // ...and every original survived the failed cleanup (12 + 1 compact). + assert.equal(fs.readdirSync(dir).length, 13); +}); + +test("an append landing during compaction is never lost (active writer)", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // 12 old files (merge-tail candidates) + one active writer file with the + // newest mtime. The freshness rule keeps the active file out of the merge + // tail — that is what makes concurrent appends safe during GC. + for (let i = 1; i <= 12; i++) { + writeFile(dir, `t${String(i).padStart(2, "0")}.jsonl`, 5, i * 1000); + } + const active = writeFile(dir, "active.jsonl", 5, 99_000); + // Mid-compaction (first cleanup unlink), the active writer appends a line. + let appended = false; + withRmSyncPatched( + (file, rmSync) => { + if (!appended) { + appended = true; + fs.appendFileSync( + active, + `${JSON.stringify({ v: 1, text: "during-gc" })}\n`, + "utf8", + ); + } + rmSync(file); + }, + () => { + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 100000, + keepNewest: 10, + }); + // 13 files > threshold 10; tail = 3 oldest; active writer untouched. + assert.deepEqual(result, { compacted: true, merged: 3 }); + }, + ); + // The active file survived by name with every line: the pre-GC lines and + // the line appended mid-compaction. + const activeTexts = fs + .readFileSync(active, "utf8") + .trim() + .split("\n") + .map((l) => (JSON.parse(l) as { text: string }).text); + assert.deepEqual(activeTexts, [ + "active.jsonl-0", + "active.jsonl-1", + "active.jsonl-2", + "active.jsonl-3", + "active.jsonl-4", + "during-gc", + ]); + // The tail's 15 lines all compacted; nothing from kept files was merged. + const mergedTexts = compactTexts(dir); + assert.equal(mergedTexts.length, 15); + assert.ok(mergedTexts.includes("t01.jsonl-0")); + assert.ok(mergedTexts.includes("t03.jsonl-4")); + assert.ok(!mergedTexts.some((t) => t.startsWith("active."))); + assert.ok(!mergedTexts.some((t) => t.startsWith("t04."))); + // Whole-dir accounting: 13 x 5 original lines + 1 mid-GC append. + assert.equal(totalLines(dir), 66); +}); diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index 61a2689b3..e384379d6 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -88,12 +88,12 @@ 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 exactly the slice-3 wiring surface", () => { +test("the extension entry registers exactly the final wiring surface", () => { // Module load must stay side-effect free (importing index.ts parses the // 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. + // slice 6 (final): before_agent_start capture, session_shutdown GC, + // tool_call overlay dismiss, the ctrl+shift+r shortcut, and the + // history command. const registered: Array<[string, unknown]> = []; const shortcuts: Array<[string, unknown]> = []; const commands: Array<[string, unknown]> = []; @@ -111,7 +111,7 @@ test("the extension entry registers exactly the slice-3 wiring surface", () => { promptHistoryExtension(pi as never); assert.deepEqual( registered.map(([event]) => event), - ["before_agent_start", "tool_call"], + ["before_agent_start", "session_shutdown", "tool_call"], ); assert.deepEqual(shortcuts.map(([key]) => key), ["ctrl+shift+r"]); assert.deepEqual(commands.map(([name]) => name), ["history"]); From c9c1c51a1a7a49fcdffb7b357d5733d4a2905cea Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:48:04 -0300 Subject: [PATCH 02/13] fix(history): pid-scoped compact filename; accurate GC threshold comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes (CodeRabbit + Copilot on PR #819): - compactFiles: the compact artifact name now carries process.pid (compact--.jsonl), matching the uniqueness convention of the staging name — two concurrent instances can never target the same compact filename. Filename pin updated accordingly. - session_shutdown comment corrected: compaction runs at the GC thresholds (50 files / 5000 lines / keep-newest-10), not a "1000-line limit" as the stale comment claimed. --- extensions/history/index.ts | 3 ++- extensions/history/store.ts | 5 +++-- tests/history-gc.test.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index dd96c3157..02a07906d 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -1016,7 +1016,8 @@ export default function promptHistoryExtension(pi: ExtensionAPI) { } }); - // Backup pass: enforce the 1000-line limit on graceful shutdown. + // Maintenance pass on graceful shutdown: compaction runs at the GC + // thresholds (50 files / 5000 lines / keep-newest-10). pi.on("session_shutdown", () => { try { gcProjectDir(PI_HISTORY_ROOT, CURRENT_CWD); diff --git a/extensions/history/store.ts b/extensions/history/store.ts index 7ad170774..fbf0be6b8 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -722,7 +722,8 @@ export function gcProjectDir( } /** - * Merge all but the newest `keepNewest` files into one `compact-.jsonl` + * Merge all but the newest `keepNewest` files into one + * `compact--.jsonl` * (chronological within the merged content). One atomic write; the * originals are removed only after the compact file lands. Readers see * either the old set or the compacted set. (Upstream exposed this as @@ -753,7 +754,7 @@ function compactFiles( if (mergedLines.length === 0) return { compacted: false, merged: 0 }; const dir = path.dirname(toMerge[0]); - const compact = path.join(dir, `compact-${Date.now()}.jsonl`); + const compact = path.join(dir, `compact-${process.pid}-${Date.now()}.jsonl`); const tmp = `${compact}.tmp-${process.pid}-${Date.now()}`; fs.writeFileSync(tmp, mergedLines.join("\n") + "\n", "utf8"); fs.renameSync(tmp, compact); diff --git a/tests/history-gc.test.ts b/tests/history-gc.test.ts index adfe2a7ca..f7de7b40e 100644 --- a/tests/history-gc.test.ts +++ b/tests/history-gc.test.ts @@ -123,7 +123,7 @@ test("file-count threshold merges the oldest files into one compact file", () => // The compact file is the renamed final artifact, not a staging leftover. assert.match( fs.readdirSync(dir).find((f) => f.startsWith("compact-")) ?? "", - /^compact-\d+\.jsonl$/, + /^compact-\d+-\d+\.jsonl$/, ); assert.deepEqual( fs.readdirSync(dir).filter((f) => f.includes(".tmp-")), From 72b5adfe9834df35f4c3c17c21d424c3bbec5289 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:36:51 -0300 Subject: [PATCH 03/13] feat(history): sync extension with pi-history latest - overlay confines to the editor column while the gentle-shell fullscreen sidebar paints (pi-tui margin resolved live via the visible() hook; rail 50 + gap 3 + 1 padding) - responsive picker header: inline / stacked (tablet) / compact (mobile) modes with fit-driven thresholds and an abbreviated scope radio; overlay stays a fixed 30-row grid in every mode - selector always opens on empty stores; registry collision re-key guard; biome-clean formatting across the module - tests: +overlay-margin, +header-layout; history suite green under the node runner (188 pass) --- extensions/history/index.ts | 379 +++++++++++-------- extensions/history/load-shared-history.ts | 3 - extensions/history/selector-helpers.ts | 108 +++++- extensions/history/store.ts | 350 +++++++++-------- tests/history-command-registration.test.ts | 54 +-- tests/history-dedupe-entries.test.ts | 66 +++- tests/history-delete-backfill.test.ts | 79 +--- tests/history-dispatch.test.ts | 13 +- tests/history-drain-hidden.test.ts | 6 +- tests/history-drain-order.test.ts | 22 +- tests/history-gc.test.ts | 235 ++---------- tests/history-header-layout.test.ts | 52 +++ tests/history-hide-prompts.test.ts | 174 ++++++++- tests/history-lazy-windowing.test.ts | 71 ++-- tests/history-legacy-migrate-v2.test.ts | 55 +-- tests/history-max-results-cap.test.ts | 45 ++- tests/history-multi-reader.test.ts | 297 +++++++-------- tests/history-openflow-integration.test.ts | 28 +- tests/history-overlay-margin.test.ts | 95 +++++ tests/history-preview-layout.test.ts | 44 +-- tests/history-registry.test.ts | 112 ++---- tests/history-scope-delete.test.ts | 43 +-- tests/history-seed-bootstrap.test.ts | 48 ++- tests/history-seed-regen.test.ts | 7 +- tests/history-session-scan-directory.test.ts | 15 +- tests/history-session-writer.test.ts | 47 +-- tests/history-store-paths.test.ts | 18 +- tests/history-wheel-mouse.test.ts | 23 +- 28 files changed, 1302 insertions(+), 1187 deletions(-) create mode 100644 tests/history-header-layout.test.ts create mode 100644 tests/history-overlay-margin.test.ts diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 02a07906d..83fa91ab8 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -1,14 +1,9 @@ // SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history // SPDX-License-Identifier: MIT -// Prompt-history extension entry (slice 3): the selector TUI, overlay glue, -// and the shortcut/command wiring over the slice-1 writer, slice-2 drains, -// and slice-4 init sequence (legacy migration + seed bootstrap run once -// inside getWriter). Deletion (slice 5) and GC/compaction (slice 6) are -// wired here. - -import { join } from "node:path"; +import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; +import { join } from "node:path"; import { DynamicBorder, type ExtensionAPI, @@ -16,58 +11,61 @@ import { type Theme, } from "@earendil-works/pi-coding-agent"; import { - appendSessionCapture, - bootstrapProjectSeed, - deleteFromGlobal, - deleteFromProject, - drainGlobal, - drainProject, - gcProjectDir, - ensureRegistryEntry, - migrateLegacyStores, - openSessionWriter, - type SessionWriterState, -} from "./store.ts"; -import { randomUUID } from "node:crypto"; + Container, + type Focusable, + getKeybindings, + Input, + matchesKey, + stripTerminalSequences, + type TUI, + type TuiMouseEvent, + truncateToWidth, +} from "@earendil-works/pi-tui"; import { hidePrompt } from "./hide-prompts.ts"; import { buildPromptRecords, - filterPrompts, - type PromptEntry, clampPreviewOffset, clampSelectedIndex, - deletionActionsFor, dedupePromptEntries, + deletionActionsFor, + editorOverlayMargin, + filterPrompts, getVisiblePromptRecords, + type HeaderLayoutMode, initialLoadedCount, loadedCountAfterDelete, loadedCountForQuery, loadedCountForTarget, moveSelectedIndex, nextLoadedCount, + type PiHistoryGlobals, + type PromptEntry, + type PromptRecord, pageSelectedIndex, + planHeaderLayout, + scopeRadioText, shouldGrowWindow, withExpandedHistoryGlobals, - type PiHistoryGlobals, - type PromptRecord, } from "./selector-helpers.ts"; import { - Container, - type Focusable, - getKeybindings, - Input, - matchesKey, - Text, - type TUI, - type TuiMouseEvent, - truncateToWidth, -} from "@earendil-works/pi-tui"; + appendSessionCapture, + bootstrapProjectSeed, + deleteFromGlobal, + deleteFromProject, + drainGlobal, + drainProject, + ensureRegistryEntry, + gcProjectDir, + migrateLegacyStores, + openSessionWriter, + type SessionWriterState, +} from "./store.ts"; const SHORTCUT = "ctrl+shift+r"; const MAX_VISIBLE = 10; const PREVIEW_ROWS = 10; -// Lazy windowing (design §D3; user-tuned 2026-09-08). PRELOAD_BUFFER=3 -// fires growth as the cursor enters the final 3 loaded rows; BATCH_SIZE=10 +// Lazy windowing (design §D3; user-tuned 2026-09-08). PRELOAD_BUFFER=2 +// fires growth as the cursor enters the final 2 loaded rows; BATCH_SIZE=10 // loads exactly one viewport per growth; INITIAL_BATCH=10 paints one // viewport at open. PRELOAD_BUFFER <= MAX_VISIBLE keeps a jump within one // viewport covered by the catch-up loop; review all three together. @@ -75,12 +73,16 @@ const INITIAL_BATCH = 10; const BATCH_SIZE = 10; const PRELOAD_BUFFER = 3; // Wheel regions over the fixed 30-row overlay geometry (design §D6): the -// list container renders at rows 5-14 and the preview container at rows -// 17-26; every other row is a consumed no-op. +// preview container always renders at rows 17-26. The list region is +// mode-dependent (see listWheelFirstRow): the responsive header reclaims +// rows without changing the 30-row total, and only the compact mode both +// shifts the list start (border at row 5) and paints one list row fewer. const LIST_WHEEL_Y_FIRST = 5; const LIST_WHEEL_Y_LAST = 14; const PREVIEW_WHEEL_Y_FIRST = 17; const PREVIEW_WHEEL_Y_LAST = 26; +/** Minimum columns between the counts text and a right-flushed radio before shrinking deletes the spacer and stacks the header (user-directed). */ +const HEADER_INLINE_MIN_GAP = 4; // v2 multi-concurrency store root (design: tmp/multi-concurrency-design.md). const PI_HISTORY_ROOT = join(homedir(), ".pi", "agent", "history"); @@ -89,17 +91,13 @@ const CURRENT_CWD = process.cwd(); // Instance identity: one exclusive capture file per pi process. const INSTANCE_ID = randomUUID(); +// State dir for the session index and the tombstone file (spec C2/C4, +// design §D5). Derived state only — deleting the directory restores cold +// start and unhides every prompt; transcripts and the editor store are +// never written here. // Tombstone state dir: the store root itself (user-directed FINAL): // ~/.pi/agent/history/hidden.json — one directory for everything. -// Derived state only — deleting the directory restores cold start and -// unhides every prompt; transcripts and the editor store are never written -// here. -const PI_HISTORY_NAV_STATE_DIR = join( - homedir(), - ".pi", - "agent", - "history", -); +const PI_HISTORY_NAV_STATE_DIR = join(homedir(), ".pi", "agent", "history"); // Sessions root for the one-level transcript scan (spec C1, design §D5): // ~/.pi/agent/sessions/. Read-only by invariant — transcripts are never @@ -121,20 +119,18 @@ const ENTRY_PREFIX_WIDTH = 2; function sanitizeForDisplay(text: string): string { let out = ""; for (let i = 0; i < text.length; i++) { - const cp = text.codePointAt(i)!; + const cp = text.codePointAt(i); + if (cp === undefined) break; if (cp === 0x0a) { out += "\n"; } else if (cp === 0x09) { out += "\t"; } else if (cp < 0x20 || cp === 0x7f) { - out += "\\x" + cp.toString(16).padStart(2, "0"); + out += `\\x${cp.toString(16).padStart(2, "0")}`; } else if (cp >= 0x80 && cp < 0xa0) { - out += "\\x" + cp.toString(16).padStart(2, "0"); + out += `\\x${cp.toString(16).padStart(2, "0")}`; } else { - // Astral code points (> 0xFFFF) span a surrogate pair; append the - // full code point, not just the high surrogate at text[i], so emoji - // and other non-BMP characters survive sanitization intact. - out += cp > 0xffff ? String.fromCodePoint(cp) : text[i]; + out += text[i]; } if (cp > 0xffff) i++; // skip low surrogate of astral pair } @@ -159,17 +155,17 @@ interface DispatchEntry { } /** Notification sink for selector feedback; an absent callback drops notifications. */ -type SelectorNotify = (message: string, level: "error" | "warning" | "info") => void; +type SelectorNotify = ( + message: string, + level: "error" | "warning" | "info", +) => void; /** Single rendered row; always occupies exactly one terminal row. */ class FixedRowText { - private text: string; - private readonly centered: boolean; - - constructor(text: string = "", centered = false) { - this.text = text; - this.centered = centered; - } + constructor( + private text: string = "", + private readonly centered = false, + ) {} /** Replace the row content in place; padding contract comes from render(). */ setText(next: string): void { @@ -190,17 +186,30 @@ class FixedRowText { // Truncate first so an overlong help row can never exceed width, // then center the truncated copy (design §C hardening). const truncated = truncateToWidth(this.text, width, "…"); - const visible = truncated.replace(/\x1b\[[0-9;]*m/g, ""); + const visible = stripTerminalSequences(truncated); const pad = Math.max(0, Math.floor((width - visible.length) / 2)); return " ".repeat(pad) + truncated; })() : truncateToWidth(this.text, width, "…"); // Pad to full terminal width so the overlay fully overwrites // whatever is beneath it and leaves no ghost characters on dismiss. - // Measure the VISIBLE width: SGR escape sequences (colored rows from - // rebuildListWithWidth) occupy no terminal cells. - const visible = rendered.replace(/\x1b\[[0-9;]*m/g, ""); - return [rendered + " ".repeat(Math.max(0, width - visible.length))]; + return [rendered + " ".repeat(Math.max(0, width - rendered.length))]; + } +} + +/** A row that renders as ZERO lines when its text is empty, letting the fixed 30-row overlay reclaim the row instead of pushing content out the bottom. */ +class OptionalRow { + private text = ""; + + setText(next: string): void { + this.text = next; + } + + invalidate(): void {} + + render(width: number): string[] { + if (this.text.length === 0) return []; + return [truncateToWidth(this.text, width, "…")]; } } @@ -242,14 +251,18 @@ class PromptHistorySelector extends Container implements Focusable { private readonly previewContainer: Container; private readonly listContainer: Container; private readonly headerRow: FixedRowText; + private readonly headerLine2: OptionalRow; + private readonly headerLine3: OptionalRow; + private readonly hintRow: OptionalRow; + private readonly hintText: string; + /** Current responsive header mode; drives the list wheel region. */ + private headerMode: HeaderLayoutMode = "inline"; private readonly previewLabelRow: FixedRowText; private records: PromptRecord[]; private readonly theme: Theme; private readonly tui: TUI; private readonly onSelect: (record: PromptRecord) => void; private readonly onCancel: () => void; - /** Notification sink for selector feedback (wired by the factory). */ - private readonly onNotify?: SelectorNotify; private filteredRecords: PromptRecord[] = []; private selectedIndex = 0; /** Number of records loaded (newest-first) from the top of `records`. */ @@ -327,16 +340,16 @@ class PromptHistorySelector extends Container implements Focusable { records: PromptRecord[], onSelect: (record: PromptRecord) => void, onCancel: () => void, - onNotify?: SelectorNotify, + private readonly onNotify?: SelectorNotify, ) { super(); + this.tui = tui; this.theme = theme; this.records = records; this.loadedCount = initialLoadedCount(records.length, INITIAL_BATCH); this.onSelect = onSelect; this.onCancel = onCancel; - this.onNotify = onNotify; // ── Search panel (top) ── this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); @@ -344,13 +357,15 @@ class PromptHistorySelector extends Container implements Focusable { theme.fg("accent", theme.bold(" History Search ")), ); this.addChild(this.headerRow); - this.addChild( - new Text( - theme.fg("dim", "Type to filter (multi-word AND substring, case-insensitive)"), - 0, - 0, - ), - ); + this.headerLine2 = new OptionalRow(); + this.headerLine3 = new OptionalRow(); + this.addChild(this.headerLine2); + this.addChild(this.headerLine3); + this.hintText = + "Type to filter (multi-word AND substring, case-insensitive)"; + this.hintRow = new OptionalRow(); + this.hintRow.setText(this.theme.fg("dim", this.hintText)); + this.addChild(this.hintRow); this.searchInput = new Input(); this.searchInput.onSubmit = () => this.selectCurrent(); this.searchInput.onEscape = () => this.onCancel(); @@ -412,33 +427,69 @@ class PromptHistorySelector extends Container implements Focusable { this.rebuildListWithWidth(this.lastWidth); } - /** Rebuild list rows: header counter + entries. Always MAX_VISIBLE rows. */ + /** Rebuild list rows: header counter + entries. Always MAX_VISIBLE rows (MAX_VISIBLE - 1 in compact mode). */ private rebuildListWithWidth(width: number): void { const count = this.filteredRecords.length; const position = count === 0 ? 0 : this.selectedIndex + 1; - this.headerRow.setText( - this.theme.fg("accent", this.theme.bold(" History Search ")) + - this.theme.fg("dim", ` · ${position} of ${count} `) + + const titleText = " History Search "; + const positionText = ` · ${position} of ${count} `; + const loadedText = ` · loaded ${this.loadedCount} of ${this.records.length} `; + const leftWidth = + titleText.length + positionText.length + loadedText.length; + const radioFull = scopeRadioText(this.scope, false); + // Radio label compaction is fit-driven too: abbreviate only when the + // full radio cannot fit the row it would occupy (user-directed paste). + const radioText = + width >= radioFull.length ? radioFull : scopeRadioText(this.scope, true); + const mode = planHeaderLayout( + width, + leftWidth, + radioFull.length, + HEADER_INLINE_MIN_GAP, + ); + this.headerMode = mode; + if (mode === "inline") { + this.headerRow.setText( + this.theme.fg("accent", this.theme.bold(titleText)) + + this.theme.fg("dim", positionText) + + this.theme.fg("dim", loadedText) + + // Right-aligned scope radio: pad from plain-text lengths so the + // radio ends flush at the header's last column at any width. + " ".repeat(Math.max(1, width - leftWidth - radioText.length)) + + this.theme.fg("dim", radioText), + ); + this.headerLine2.setText(""); + this.headerLine3.setText(""); + } else if (mode === "stacked") { + // Tablet: the spacer is deleted — the radio wraps to its own row + // under the full counts line (user-directed paste, leading space). + this.headerRow.setText( + this.theme.fg("accent", this.theme.bold(titleText)) + + this.theme.fg("dim", positionText) + + this.theme.fg("dim", loadedText), + ); + this.headerLine2.setText(` ${this.theme.fg("dim", radioText)}`); + this.headerLine3.setText(""); + } else { + // Compact (mobile): three rows — counts split off, radio abbreviated + // (user-directed paste). + this.headerRow.setText( + this.theme.fg("accent", this.theme.bold(titleText)) + + this.theme.fg("dim", ` · ${position} of ${count}`), + ); + // Leading space aligns both rows with the title's own left padding + // space (user-directed compact paste). + this.headerLine2.setText( this.theme.fg( "dim", - ` · loaded ${this.loadedCount} of ${this.records.length} `, - ) + - // Right-aligned scope radio: pad from plain-text lengths so the - // radio ends flush at the header's last column at any width. - (() => { - const scopeRadio = - this.scope === "project" - ? "◉ Current project | ○ All projects" - : "○ Current project | ◉ All projects"; - const leftWidth = - " History Search ".length + - ` · ${position} of ${count} `.length + - ` · loaded ${this.loadedCount} of ${this.records.length} `.length; - return ( - " ".repeat(Math.max(1, width - leftWidth - scopeRadio.length)) + - this.theme.fg("dim", scopeRadio) - ); - })(), + ` loaded ${this.loadedCount} of ${this.records.length}`, + ), + ); + this.headerLine3.setText(` ${this.theme.fg("dim", radioText)}`); + } + // Stacked modes reclaim the hint row so the overlay stays 30 rows. + this.hintRow.setText( + mode === "inline" ? this.theme.fg("dim", this.hintText) : "", ); this.listContainer.clear(); @@ -446,18 +497,24 @@ class PromptHistorySelector extends Container implements Focusable { this.listContainer.addChild( new FixedRowText(this.theme.fg("warning", "No matching prompts")), ); - for (let i = 1; i < MAX_VISIBLE; i++) { + // Compact still paints one list row fewer in the empty state, or the + // 3-row header would push the fixed 30-row overlay to 31 rows. + const listRows = mode === "compact" ? MAX_VISIBLE - 1 : MAX_VISIBLE; + for (let i = 1; i < listRows; i++) { this.listContainer.addChild(new FixedRowText()); } return; } + // Compact paints one list row fewer (reclaimed by the 3-row header); + // the preview block keeps PREVIEW_ROWS so the 30-row total holds. + const listRows = mode === "compact" ? MAX_VISIBLE - 1 : MAX_VISIBLE; const entryMax = Math.floor(width * 0.95) - ENTRY_PREFIX_WIDTH; const visible = getVisiblePromptRecords( this.filteredRecords, this.selectedIndex, - MAX_VISIBLE, + listRows, ); for (const { record, isSelected } of visible) { @@ -471,11 +528,18 @@ class PromptHistorySelector extends Container implements Focusable { this.listContainer.addChild(new FixedRowText(line)); } - for (let i = visible.length; i < MAX_VISIBLE; i++) { + for (let i = visible.length; i < listRows; i++) { this.listContainer.addChild(new FixedRowText()); } } + /** List wheel region start: compact shifts the list down one row. */ + private get listWheelFirstRow(): number { + return this.headerMode === "compact" + ? LIST_WHEEL_Y_FIRST + 1 + : LIST_WHEEL_Y_FIRST; + } + /** * Rebuild preview: word-wrap the full selected prompt text and show * a PREVIEW_ROWS-tall viewport starting at previewScrollOffset. @@ -605,14 +669,12 @@ class PromptHistorySelector extends Container implements Focusable { this.applyFilter(this.searchInput.getValue()); } - // -- Navigation --------------------------------------------------------- - - private moveUp(): void { - this.selectedIndex = moveSelectedIndex( - this.selectedIndex, - this.filteredRecords.length, - -1, - ); + /** + * Lazy-load growth shared by moveUp/moveDown (design §D1): when the cursor + * sits in the final PRELOAD_BUFFER rows of the loaded window, grow via + * nextLoadedCount and re-apply the filter so fresh rows become visible. + */ + private growLoadedWindowIfNeeded(): void { if ( shouldGrowWindow( this.selectedIndex, @@ -628,6 +690,17 @@ class PromptHistorySelector extends Container implements Focusable { ); this.applyFilter(this.searchInput.getValue()); } + } + + // -- Navigation --------------------------------------------------------- + + private moveUp(): void { + this.selectedIndex = moveSelectedIndex( + this.selectedIndex, + this.filteredRecords.length, + -1, + ); + this.growLoadedWindowIfNeeded(); this.previewScrollOffset = 0; this.rebuildList(); this.rebuildPreview(); @@ -638,21 +711,7 @@ class PromptHistorySelector extends Container implements Focusable { // sits in the final PRELOAD_BUFFER rows of the loaded window, so the // modulo below moves into freshly loaded rows — a wrap to index 0 is // reachable only on the exhausted set. - if ( - shouldGrowWindow( - this.selectedIndex, - this.loadedCount, - this.records.length, - PRELOAD_BUFFER, - ) - ) { - this.loadedCount = nextLoadedCount( - this.loadedCount, - this.records.length, - BATCH_SIZE, - ); - this.applyFilter(this.searchInput.getValue()); - } + this.growLoadedWindowIfNeeded(); this.selectedIndex = moveSelectedIndex( this.selectedIndex, this.filteredRecords.length, @@ -774,7 +833,7 @@ class PromptHistorySelector extends Container implements Focusable { ): ReturnType { if (event.type !== "wheel") return undefined; const delta = event.wheelDelta ?? 0; - if (event.y >= LIST_WHEEL_Y_FIRST && event.y <= LIST_WHEEL_Y_LAST) { + if (event.y >= this.listWheelFirstRow && event.y <= LIST_WHEEL_Y_LAST) { const steps = Math.min(Math.abs(delta), this.filteredRecords.length); for (let i = 0; i < steps; i++) { if (delta > 0) this.moveDown(); @@ -836,7 +895,7 @@ class PromptHistorySelector extends Container implements Focusable { } // --------------------------------------------------------------------------- -// Overlay glue +// Extension entry point // --------------------------------------------------------------------------- type SelectorDone = (result: PromptRecord | null) => void; @@ -860,7 +919,7 @@ function createPromptHistorySelectorFactory( onNotify?: SelectorNotify, ): SelectorFactory { return (tui, theme, _keybindings, done) => { - selectorTui = tui as { requestRender(): void }; + selectorTui = tui as { requestRender(): void; terminal?: unknown }; const finish = (result: PromptRecord | null) => { activeOverlayClose = null; done(result); @@ -895,20 +954,44 @@ async function runPromptHistorySelection( ), { overlay: true, - overlayOptions: { anchor: "bottom-center", width: "100%", offsetY: 5 }, + // pi-tui freezes the options object at showOverlay time, but calls + // visible() on EVERY render pass before resolving the overlay layout + // (compositeOverlays filters visible entries first), and re-reads + // margin per layout resolution — the getter below therefore stays + // live: resizing across the sidebar breakpoint re-seats the picker + // while it stays open. While the gentle-shell fullscreen sidebar + // paints, the margin confines width "100%" (and the bottom-center + // anchor) to the editor column plus 3 columns of padding; 0 keeps + // the native full-window behavior. + overlayOptions: () => { + let rightMargin = editorOverlayMargin(selectorTui?.terminal); + return { + anchor: "bottom-center" as const, + width: "100%" as const, + offsetY: 5, + get margin() { + return rightMargin > 0 ? { right: rightMargin } : undefined; + }, + visible: () => { + rightMargin = editorOverlayMargin(selectorTui?.terminal); + return true; + }, + }; + }, }, ), ); } +/** Shared entry point for the ctrl+shift+r shortcut and the /history command. */ // --------------------------------------------------------------------------- // Multi-concurrency store (v2): per-session writes, scope drains // --------------------------------------------------------------------------- type HistoryScope = "project" | "global"; -/** TUI handle captured when the selector overlay mounts. */ -let selectorTui: { requestRender(): void } | null = null; +/** TUI handle captured when the selector overlay mounts. `terminal` feeds the sidebar overlay margin. */ +let selectorTui: { requestRender(): void; terminal?: unknown } | null = null; let writerState: SessionWriterState | null = null; @@ -945,9 +1028,10 @@ function getWriter(): SessionWriterState { } /** - * Scope drain for the selector: project scope drains the project's store - * files; global scope is the store-only cross-project view (all project - * dirs + the legacy global seed). Both filter tombstoned prompts. + * Scope drain for the selector: project scope drains this project's store + * files (transcript prompts enter once via bootstrapProjectSeed); global + * scope is the cross-project view (all project dirs + the legacy global + * seed). */ function drainForScope(scope: HistoryScope): string[] { getWriter(); // ensure init ran @@ -963,11 +1047,9 @@ async function openHistorySelector( // symmetrically — no live transcript merge (the one-time seed bootstrap // covers pre-store history). const entries = drainForScope("project"); - if (entries.length === 0) { - ctx.ui.notify("No prompt history available.", "warning"); - return; - } - + // Always open the selector (user-directed): an empty store still shows + // the overlay with its "No matching prompts" empty state instead of a + // warning notify. const records = recordsFromEntries(entries); const selected = await runPromptHistorySelection(ctx, records); if (selected) { @@ -991,18 +1073,6 @@ function recordsFromEntries( export default function promptHistoryExtension(pi: ExtensionAPI) { // One writer per extension load; see getWriter() for the init order. - // Warm migrate/registry/seed OFF the first-prompt path: the scheduled - // init runs once, immediately after load. A prompt arriving earlier - // falls back to the synchronous lazy init in getWriter(), whose - // writerState guard makes whichever runs second a no-op — bootstrap - // work is never duplicated. - setImmediate(() => { - try { - getWriter(); - } catch { - // init is best-effort; the lazy path retries on the next prompt - } - }); // Persist every delivered user prompt (write-through, append-only JSONL). // The local ExtensionAPI stub types handler args as unknown; narrow here. @@ -1016,8 +1086,7 @@ export default function promptHistoryExtension(pi: ExtensionAPI) { } }); - // Maintenance pass on graceful shutdown: compaction runs at the GC - // thresholds (50 files / 5000 lines / keep-newest-10). + // Backup pass: enforce the 1000-line limit on graceful shutdown. pi.on("session_shutdown", () => { try { gcProjectDir(PI_HISTORY_ROOT, CURRENT_CWD); diff --git a/extensions/history/load-shared-history.ts b/extensions/history/load-shared-history.ts index 79ef12f7d..ea03d0122 100644 --- a/extensions/history/load-shared-history.ts +++ b/extensions/history/load-shared-history.ts @@ -1,6 +1,3 @@ -// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history -// SPDX-License-Identifier: MIT - import fs from "node:fs"; interface SharedHistoryEntry { diff --git a/extensions/history/selector-helpers.ts b/extensions/history/selector-helpers.ts index 292c05907..9f8b3a09c 100644 --- a/extensions/history/selector-helpers.ts +++ b/extensions/history/selector-helpers.ts @@ -119,7 +119,7 @@ export function pageSelectedIndex( * The literal is the single-backslash applied-patch form; the raw patch * file stores \\s+ only because its code sits inside a template literal. * Shared by contract (spec C4): hide-prompts tombstone keys and the - * merge-history session-half tombstone filter MUST byte-match this key. + * store seeding tombstone filter MUST byte-match this key. */ export function promptDedupKey(entry: string): string { return entry.replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase(); @@ -244,11 +244,12 @@ export function loadedCountAfterDelete( * only (session transcripts are NEVER written). Takes source as a plain * parameter (no member reads — the T23 provenance pin keeps overlay * consumers source-agnostic outside deleteCurrent); the only consumer is - * deleteCurrent in history/index.ts. + * deleteCurrent in src/index.ts. */ -export function deletionActionsFor( - source: PromptSource, -): { deleteFromEditorStore: boolean; writeTombstone: boolean } { +export function deletionActionsFor(source: PromptSource): { + deleteFromEditorStore: boolean; + writeTombstone: boolean; +} { if (source === "editor") { return { deleteFromEditorStore: true, writeTombstone: true }; } @@ -315,3 +316,100 @@ export function filterPrompts( return filtered.slice(0, MAX_RESULTS); } + +/** + * Cross-extension fullscreen-sidebar state contract (gentle-shell): stored on + * the shared ProcessTerminal under a global-registry symbol so any extension + * can read it without importing gentle-shell. Shape per its lib/shell-sidebar.ts: + * `{ active: boolean; ownsHost?: () => boolean; parts: Map }`. + */ +const SIDEBAR_STATE_SYMBOL = Symbol.for("gentle-pi.experimental-sidebar.state"); + +/** + * Geometry overlay right margin that confines a full-width overlay to the + * editor column while the gentle-shell fullscreen sidebar paints: its layout + * hstack reserves 50 columns (RAIL_WIDTH) for the rail plus a 3-column gap + * (GAP) before it, and it only activates at >= 140 columns. pi-tui resolves + * overlay width "100%" and the bottom-center anchor inside + * `[0, columns - margin)`, which is then exactly the editor column. + */ +export const SIDEBAR_RAIL_OVERLAY_MARGIN = 53; + +/** + * Visual breathing room between the picker and the sidebar rail, added on top + * of the geometry margin (user-directed: 1 column, 2026-09-21). + */ +export const SIDEBAR_OVERLAY_PADDING = 1; + +interface SidebarStateShape { + active?: unknown; + ownsHost?: () => unknown; +} + +/** + * Overlay right margin for the current terminal: the geometry margin plus + * padding while the gentle-shell sidebar rail is painting, else 0 (native + * full-window overlay). Reads the terminal-owned state contract defensively — + * any absent, malformed, or non-owning state degrades to 0 so the picker + * keeps opening. Purity note: this returns the CURRENT margin per call; live + * refresh while an overlay stays open is the caller's job (the picker wires + * visible() plus a getter margin — pi-tui re-reads both every render). + */ +export function editorOverlayMargin(terminal: unknown): number { + if (typeof terminal !== "object" || terminal === null) return 0; + const state = (terminal as Record)[SIDEBAR_STATE_SYMBOL] as + | SidebarStateShape + | undefined; + if (typeof state !== "object" || state === null) return 0; + if (state.active !== true || typeof state.ownsHost !== "function") return 0; + try { + return state.ownsHost() === true + ? SIDEBAR_RAIL_OVERLAY_MARGIN + SIDEBAR_OVERLAY_PADDING + : 0; + } catch { + return 0; + } +} + +/** Responsive picker-header mode at the current render width. */ +export type HeaderLayoutMode = "inline" | "stacked" | "compact"; + +/** + * Fit-driven header plan (user-directed responsive header): "inline" keeps + * title + counts + right-flushed radio on one row; "stacked" (tablet) deletes + * the spacer — the radio wraps to its own row under the full counts line; + * "compact" (mobile) further splits the counts off and abbreviates the radio. + * Thresholds derive from the ACTUAL text widths, so any count size flips the + * mode at the exact column where the previous layout stops fitting. + */ +export function planHeaderLayout( + width: number, + leftWidth: number, + radioWidth: number, + minGap: number, +): HeaderLayoutMode { + if (width >= leftWidth + minGap + radioWidth) return "inline"; + if (width >= leftWidth) return "stacked"; + return "compact"; +} + +/** Full scope radio: both scope labels spelled out. */ +export const SCOPE_RADIO_FULL_PROJECT = "◉ Current project | ○ All projects"; +export const SCOPE_RADIO_FULL_GLOBAL = "○ Current project | ◉ All projects"; +/** Abbreviated radio: the ACTIVE scope keeps its full label, the other shortens. */ +export const SCOPE_RADIO_COMPACT_PROJECT = "◉ Current project | ○ All"; +export const SCOPE_RADIO_COMPACT_GLOBAL = "○ Current | ◉ All projects"; + +/** + * Scope radio text for the current width: abbreviated only when the full + * radio cannot fit the row it would occupy (compact widths). + */ +export function scopeRadioText( + scope: "project" | "global", + compact: boolean, +): string { + if (scope === "project") { + return compact ? SCOPE_RADIO_COMPACT_PROJECT : SCOPE_RADIO_FULL_PROJECT; + } + return compact ? SCOPE_RADIO_COMPACT_GLOBAL : SCOPE_RADIO_FULL_GLOBAL; +} diff --git a/extensions/history/store.ts b/extensions/history/store.ts index fbf0be6b8..5ccba43cb 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -1,12 +1,6 @@ -// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history -// SPDX-License-Identifier: MIT - -// Consolidated multi-concurrency store (v2), slices 1-6: 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), scope deletes, legacy -// migration, the project seed bootstrap, and GC/compaction. Formerly -// store-paths.ts + registry.ts + multi-store.ts (+ v1 primitives). +// Consolidated multi-concurrency store (v2): paths, registry, session +// writer, scope drains/deletes, legacy migration, bootstrap, GC. +// Formerly store-paths.ts + registry.ts + multi-store.ts (+ v1 primitives). import { createHash } from "node:crypto"; import fs from "node:fs"; @@ -14,9 +8,9 @@ import path from "node:path"; import { loadHiddenPrompts } from "./hide-prompts.ts"; import { loadSharedHistory } from "./load-shared-history.ts"; import { + type ExtractedPrompt, extractPromptsFromFile, listSessionFiles, - type ExtractedPrompt, } from "./session-scan.ts"; // =========================================================================== @@ -104,7 +98,7 @@ 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.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf8"); fs.renameSync(tmp, target); } @@ -121,11 +115,6 @@ export function ensureRegistryEntry( 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 @@ -141,6 +130,11 @@ export function ensureRegistryEntry( return { hash, created: true }; } +/** Display lookup: hash → cwd, null when unknown or the file is absent. */ +export function lookupCwd(root: string, hash: string): string | null { + return readRegistry(root)[hash] ?? null; +} + function projectHashLong(cwd: string): string { // Reuse the same canonicalization as projectHash but keep 24 chars. let canonical = cwd; @@ -157,7 +151,7 @@ function projectHashLong(cwd: string): string { // =========================================================================== /** One line of `editor-history.jsonl`. */ -export interface StoreEntry { +interface StoreEntry { /** Schema version; 1 when absent in the source line. */ v: number; text: string; @@ -170,7 +164,7 @@ export interface StoreEntry { * 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 { +function parseStoreLine(raw: string): StoreEntry | null { if (raw.length === 0) return null; try { const value: unknown = JSON.parse(raw); @@ -192,7 +186,7 @@ export function parseStoreLine(raw: string): StoreEntry | null { } // =========================================================================== -// Instance writer (formerly multi-store.ts) +// Multi-store (formerly multi-store.ts) // =========================================================================== /** Mutable state of ONE pi instance's exclusive capture file. */ @@ -247,13 +241,12 @@ export function appendSessionCapture( 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"); + fs.appendFileSync(state.filePath, `${serializeEntry(entry)}\n`, "utf8"); state.lineCount += 1; } - // --------------------------------------------------------------------------- -// Multi-file reader (design v2: k-way backward merge) +// Multi-file reader (design v2: sequential backward drain over sorted files) // --------------------------------------------------------------------------- /** UI-level prompt identity: whitespace-collapsed, case-insensitive. */ @@ -282,7 +275,10 @@ function listProjectFiles(dir: string): string[] { .sort((a, b) => fileMtimeMs(b) - fileMtimeMs(a)); } -/** Read one file's valid entries (chronological). */ +/** + * Read one file's valid entries (chronological). Malformed lines are + * skipped. + */ function readFileEntries(file: string): StoreEntry[] { let raw = ""; try { @@ -311,11 +307,6 @@ function fileSortKey(file: string, entries: StoreEntry[]): number { return maxTs > 0 ? maxTs : fileMtimeMs(file); } -/** Tombstone key - byte-compatible with hide-prompts' promptDedupKey. */ -function promptDedupKeyOf(text: string): string { - return text.replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase(); -} - /** * Sequential backward drain over PRE-SORTED files: each file fully, * newest-line-first, deduped by UI-level identity, capped at `limit`. @@ -349,8 +340,7 @@ function sortFilesForDrain(files: string[]): string[] { .map((file) => ({ file, entries: readFileEntries(file) })) .filter((f) => f.entries.length > 0) .sort( - (a, b) => - fileSortKey(b.file, b.entries) - fileSortKey(a.file, a.entries), + (a, b) => fileSortKey(b.file, b.entries) - fileSortKey(a.file, a.entries), ) .map((f) => f.file); } @@ -366,26 +356,40 @@ export function drainProject( stateDir?: string, ): string[] { return drainFiles( - sortFilesForDrain(listProjectFiles(path.join(root, "projects", projectHash(cwd)))), + sortFilesForDrain( + listProjectFiles(path.join(root, "projects", projectHash(cwd))), + ), limit, stateDir ? loadHiddenPrompts(stateDir) : new Set(), ); } /** - * Drain the GLOBAL scope: every project dir's files, mtime-newest-first, - * deduped, capped — with the legacy global seed appended LAST (deliberate: - * it is the least specific, migrated source, so per-project entries win - * recency and keep-first dedup favors them). + * Drain the GLOBAL scope: the legacy global seed (newest single source) + * plus every project dir's files, mtime-newest-first, deduped, capped. */ export function drainGlobal( root: string, limit: number = 1000, stateDir?: string, ): string[] { - const files: string[] = []; const globalSeed = globalSeedPath(root); + const sorted = sortFilesForDrain(listAllProjectFiles(root)); + if (fs.existsSync(globalSeed)) sorted.push(globalSeed); // legacy last + return drainFiles( + sorted, + limit, + stateDir ? loadHiddenPrompts(stateDir) : new Set(), + ); +} +/** + * Every project dir's store files: the projects root is skipped fail-open + * when unreadable, and non-directory entries are ignored. Shared by the + * global drain and the global delete sweep. + */ +function listAllProjectFiles(root: string): string[] { + const files: string[] = []; let projectDirs: fs.Dirent[]; try { projectDirs = fs.readdirSync(path.join(root, "projects"), { @@ -396,17 +400,9 @@ export function drainGlobal( } for (const dirEntry of projectDirs) { if (!dirEntry.isDirectory()) continue; - files.push( - ...listProjectFiles(path.join(root, "projects", dirEntry.name)), - ); + files.push(...listProjectFiles(path.join(root, "projects", dirEntry.name))); } - const sorted = sortFilesForDrain(files); - if (fs.existsSync(globalSeed)) sorted.push(globalSeed); // legacy last - return drainFiles( - sorted, - limit, - stateDir ? loadHiddenPrompts(stateDir) : new Set(), - ); + return files; } // --------------------------------------------------------------------------- @@ -448,7 +444,11 @@ function sweepFiles(files: string[], text: string): SweepResult { } if (fileRemoved === 0) continue; const tmp = `${file}.tmp-${process.pid}-${Date.now()}`; - fs.writeFileSync(tmp, kept.length > 0 ? kept.join("\n") + "\n" : "", "utf8"); + fs.writeFileSync( + tmp, + kept.length > 0 ? `${kept.join("\n")}\n` : "", + "utf8", + ); fs.renameSync(tmp, file); filesAffected += 1; removed += fileRemoved; @@ -470,23 +470,9 @@ export function deleteFromProject( /** Delete every copy of a prompt from the GLOBAL scope (all projects + seed). */ export function deleteFromGlobal(root: string, text: string): SweepResult { - const files: string[] = []; + const files = listAllProjectFiles(root); const globalSeed = globalSeedPath(root); - if (fs.existsSync(globalSeed)) files.push(globalSeed); - let projectDirs: fs.Dirent[]; - try { - projectDirs = fs.readdirSync(path.join(root, "projects"), { - withFileTypes: true, - }); - } catch { - projectDirs = []; - } - for (const dirEntry of projectDirs) { - if (!dirEntry.isDirectory()) continue; - files.push( - ...listProjectFiles(path.join(root, "projects", dirEntry.name)), - ); - } + if (fs.existsSync(globalSeed)) files.unshift(globalSeed); return sweepFiles(files, text); } @@ -513,14 +499,30 @@ function readValidLines(file: string): StoreEntry[] { } } +/** + * Atomically write a seed file: create the parent dir, write a tmp sibling, + * rename over the target. Returns the entry count written. Shared by the + * legacy migration and the project bootstrap. + */ +function writeSeedFileAtomic(seed: string, collected: StoreEntry[]): number { + 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 collected.length; +} + /** * 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 each source renamed `.imported`, never - * deleted — a failed write leaves sources untouched for a later retry. - * Gated: an existing global seed means migration already ran. + * Content lands in `pi-history/history-global.jsonl` chronologically; each + * source is renamed `.imported`, never deleted. Gated: an existing global + * seed means migration already ran. */ export function migrateLegacyStores( root: string, @@ -535,8 +537,15 @@ export function migrateLegacyStores( const legacyArray = path.join(agentDir, "editor-history.json"); if (fs.existsSync(legacyArray)) { const texts = loadSharedHistory(legacyArray); - for (let i = texts.length - 1; i >= 0; i--) { - collected.push({ v: 1, text: texts[i] }); + if (texts.length > 0) { + for (let i = texts.length - 1; i >= 0; i--) { + collected.push({ v: 1, text: texts[i] }); + } + } + try { + fs.renameSync(legacyArray, `${legacyArray}.imported`); + } catch { + // The seed write below is the source of truth; rename failure is benign. } } @@ -544,29 +553,17 @@ export function migrateLegacyStores( const v1File = path.join(agentDir, "editor-history.jsonl"); if (fs.existsSync(v1File)) { collected.push(...readValidLines(v1File)); - } - - if (collected.length === 0) return { migrated: 0, ran: false }; - - 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); - - // The seed write is the source of truth: rename sources only once it - // succeeded, so a failure can never strand entries in .imported files. - for (const src of [legacyArray, v1File]) { try { - if (fs.existsSync(src)) fs.renameSync(src, `${src}.imported`); + fs.renameSync(v1File, `${v1File}.imported`); } catch { - // benign: the seed gate prevents duplicate import on the next run + // benign } } - return { migrated: collected.length, ran: true }; + + if (collected.length === 0) return { migrated: 0, ran: false }; + + const migrated = writeSeedFileAtomic(seed, collected); + return { migrated, ran: true }; } // --------------------------------------------------------------------------- @@ -584,18 +581,22 @@ export interface SeedResult { * 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)); +/** Tombstone key - byte-compatible with hide-prompts' promptDedupKey. */ +function promptDedupKeyOf(text: string): string { + return text.replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase(); +} - // Count existing entries and collect their identities. - const existingKeys = new Set(); - let existingCount = 0; +/** + * Existing entries in the project dir: total count plus the UI-level dedupe + * keys of everything already stored (seed included). Unreadable files are + * skipped. + */ +function countExistingEntries(dir: string): { + count: number; + keys: Set; +} { + const keys = new Set(); + let count = 0; for (const file of listProjectFiles(dir)) { let raw = ""; try { @@ -606,34 +607,61 @@ export function bootstrapProjectSeed( for (const lineText of raw.split("\n")) { const parsed = parseStoreLine(lineText); if (parsed) { - existingCount += 1; - existingKeys.add(promptKey(parsed.text)); + count += 1; + keys.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. - const hidden = stateDir ? loadHiddenPrompts(stateDir) : new Set(); + return { count, keys }; +} - // Scan transcripts: session files of THIS project's dir, newest first. - let files: string[] = []; +/** + * This project's session transcript files (encoded-cwd dir match), newest + * mtime first. Returns [] when the sessions root is unreadable. + */ +function listProjectTranscripts(sessionsRoot: string, cwd: string): string[] { try { - const dirName = cwd - .replace(/^[/\\]/, "") - .replace(/[/\\:]/g, "-"); - files = listSessionFiles(sessionsRoot).filter((file) => + const dirName = cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-"); + const files = listSessionFiles(sessionsRoot).filter((file) => file.includes(`${path.sep}--${dirName}--${path.sep}`), ); + files.sort((a, b) => fileMtimeMs(b) - fileMtimeMs(a)); + return files; } catch { - return { seeded: 0, ran: false }; + return []; } - files.sort((a, b) => fileMtimeMs(b) - fileMtimeMs(a)); +} + +/** + * Single-prompt acceptance test for seeding: not command-like, not + * tombstoned, not already stored. Returns null to skip; on accept the key + * is recorded in `existingKeys` and returned. + */ +function acceptTranscriptPrompt( + text: string, + hidden: ReadonlySet, + existingKeys: Set, +): string | null { + if (/^\/[A-Za-z]/.test(text.trim())) return null; + if (hidden.size > 0 && hidden.has(promptDedupKeyOf(text))) return null; + const key = promptKey(text); + if (existingKeys.has(key)) return null; + existingKeys.add(key); + return key; +} +/** + * Newest-first transcript sweep: extract prompts, apply the acceptance + * test, cap at the remaining `budget` (target minus existing entries). + */ +function collectTranscriptPrompts( + files: readonly string[], + opts: { + hidden: ReadonlySet; + existingKeys: Set; + budget: number; + }, +): StoreEntry[] { const collected: StoreEntry[] = []; outer: for (const file of files) { let prompts: ExtractedPrompt[] = []; @@ -643,31 +671,50 @@ export function bootstrapProjectSeed( 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 }; + const key = acceptTranscriptPrompt( + prompts[i].text, + opts.hidden, + opts.existingKeys, + ); + if (key === null) continue; + const entry: StoreEntry = { v: 1, text: prompts[i].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 >= opts.budget) break outer; } } + return collected; +} + +export function bootstrapProjectSeed( + root: string, + cwd: string, + sessionsRoot: string, + target: number, + stateDir?: string, +): SeedResult { + const existing = countExistingEntries( + path.join(root, "projects", projectHash(cwd)), + ); + if (existing.count >= 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. + const hidden = stateDir ? loadHiddenPrompts(stateDir) : new Set(); + const files = listProjectTranscripts(sessionsRoot, cwd); + const collected = collectTranscriptPrompts(files, { + hidden, + existingKeys: existing.keys, + budget: target - existing.count, + }); 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 }; + const seeded = writeSeedFileAtomic(seedFilePath(root, cwd), collected); + return { seeded, ran: true }; } // --------------------------------------------------------------------------- @@ -682,7 +729,6 @@ export interface GcResult { compacted: boolean; merged: number; } - /** * Threshold check + compaction entry point (called at shutdown and at * selector close). Compacts when a project dir holds more than @@ -722,18 +768,21 @@ export function gcProjectDir( } /** - * Merge all but the newest `keepNewest` files into one - * `compact--.jsonl` - * (chronological within the merged content). One atomic write; the - * originals are removed only after the compact file lands. Readers see - * either the old set or the compacted set. (Upstream exposed this as - * compactProjectDir; dropped here — zero callers, gcProjectDir is the - * single entry point.) + * Merge all but the newest GC_KEEP_NEWEST files into one + * `compact-.jsonl` (chronological within the merged content). One + * atomic write; the originals are removed only after the compact file + * lands. Readers see either the old set or the compacted set. */ -function compactFiles( - filesMtimeDesc: string[], - keepNewest: number, +export function compactProjectDir( + root: string, + cwd: string, + opts: { keepNewest?: number } = {}, ): GcResult { + const dir = path.join(root, "projects", projectHash(cwd)); + return compactFiles(listProjectFiles(dir), opts.keepNewest ?? GC_KEEP_NEWEST); +} + +function compactFiles(filesMtimeDesc: string[], keepNewest: number): GcResult { if (filesMtimeDesc.length <= keepNewest) { return { compacted: false, merged: 0 }; } @@ -746,17 +795,14 @@ function compactFiles( const parsed = parseStoreLine(lineText); if (parsed) mergedLines.push(JSON.stringify(parsed)); } - } catch { - // unreadable file: skip its content, still remove nothing - continue; - } + } catch {} } if (mergedLines.length === 0) return { compacted: false, merged: 0 }; const dir = path.dirname(toMerge[0]); - const compact = path.join(dir, `compact-${process.pid}-${Date.now()}.jsonl`); + const compact = path.join(dir, `compact-${Date.now()}.jsonl`); const tmp = `${compact}.tmp-${process.pid}-${Date.now()}`; - fs.writeFileSync(tmp, mergedLines.join("\n") + "\n", "utf8"); + fs.writeFileSync(tmp, `${mergedLines.join("\n")}\n`, "utf8"); fs.renameSync(tmp, compact); for (const file of toMerge) { try { diff --git a/tests/history-command-registration.test.ts b/tests/history-command-registration.test.ts index 4e44c3859..1bd615c9c 100644 --- a/tests/history-command-registration.test.ts +++ b/tests/history-command-registration.test.ts @@ -1,14 +1,13 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import fs from "node:fs"; import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; // Source-parsing tests (preview-layout.test.ts pattern): never import -// extensions/history/index.ts — it pulls the pi-tui runtime graph (§D3). +// src/index.ts — it pulls the pi-tui runtime graph (design §D3). -const sourcePath = fileURLToPath( - new URL("../extensions/history/index.ts", import.meta.url), -); +const sourcePath = fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)); const source = fs.readFileSync(sourcePath, "utf8"); test("openHistorySelector is extracted once and shared by both entry points", () => { @@ -27,18 +26,13 @@ test("openHistorySelector is extracted once and shared by both entry points", () "registerShortcut and registerCommand handlers should both call openHistorySelector(ctx)", ); - // PR-branch (slice 3) behavior: the store-only drain keeps the empty - // guard — no history means a warning, not an empty overlay. (The dev - // repo's later always-open selector dropped this guard; the PR branch is - // the API truth here.) const start = source.indexOf("async function openHistorySelector("); const end = source.indexOf("export default function", start); assert.notStrictEqual(end, -1, "extension entry point should follow"); const body = source.slice(start, end); assert.ok( - body.includes("if (entries.length === 0)") && - body.includes('"No prompt history available."'), - "an empty history warns and skips the overlay (PR-branch drain guard)", + !body.includes('"No prompt history available."'), + "the warning is removed; the selector always opens (AC-P1-5.2)", ); }); @@ -57,21 +51,6 @@ test("the /history command is registered beside the shortcut", () => { ); }); -test("the ctrl+shift+r shortcut is registered with the shared description", () => { - const index = source.indexOf("pi.registerShortcut(SHORTCUT"); - assert.ok(index >= 0, "pi.registerShortcut(SHORTCUT, ...) should exist"); - - const slice = source.slice(index, index + 200); - assert.ok( - slice.includes('"Search prompt history"'), - "shortcut should carry the shared description", - ); - assert.ok( - slice.includes("openHistorySelector(ctx)"), - "shortcut handler should route through the shared entry point", - ); -}); - test("in-UI hint describes multi-word AND substring matching, not fuzzy", () => { assert.ok( !source.includes("fzf-style fuzzy match"), @@ -82,24 +61,3 @@ test("in-UI hint describes multi-word AND substring matching, not fuzzy", () => "hint should describe multi-word AND substring filtering (AC-P1-6.1)", ); }); - -test("writer init is scheduled off the first-prompt path via setImmediate", () => { - const entry = source.indexOf("export default function promptHistoryExtension"); - assert.notStrictEqual(entry, -1, "extension entry point should exist"); - - const body = source.slice(entry); - assert.ok( - body.includes("setImmediate(() => {"), - "init must be scheduled with setImmediate so bootstrap never runs on\nthe first-prompt path", - ); - assert.ok( - /setImmediate\(\(\) => \{[\s\S]*?getWriter\(\);/.test(body), - "the scheduled callback should warm getWriter()", - ); - // The synchronous fallback stays: a prompt arriving before the - // scheduled call still initializes lazily inside the capture handler. - assert.ok( - /before_agent_start[\s\S]*?appendSessionCapture\(getWriter\(\)/.test(body), - "capture handler keeps the synchronous getWriter() fallback", - ); -}); diff --git a/tests/history-dedupe-entries.test.ts b/tests/history-dedupe-entries.test.ts index f929880e9..331720599 100644 --- a/tests/history-dedupe-entries.test.ts +++ b/tests/history-dedupe-entries.test.ts @@ -1,5 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; import { dedupePromptEntries } from "../extensions/history/selector-helpers.ts"; // AC-L5-1..AC-L5-5 — read-time dedup pass (spec C3, design §D5). @@ -14,11 +17,6 @@ import { dedupePromptEntries } from "../extensions/history/selector-helpers.ts"; // `/\s+/g`. An implementation copying the double-backslash form would build // a regex matching a literal backslash: whitespace variants would stop // collapsing (T1 fails) and empty-key entries would leak through (T2 fails). -// -// The dev suite's T3 source-parse pins (dedupePromptEntries wired between -// drainForScope and buildPromptRecords inside openHistorySelector) cover the -// slice-3 selector wiring in extensions/history/index.ts and port with that -// slice — index.ts stays at its slice-1 surface here. // T1 — AC-L5-1: keep-first over newest-first input order (file order). @@ -121,3 +119,61 @@ test("no snapshot cap: every unique entry is kept past MAX_RESULTS (AC-L5-5)", ( assert.equal(deduped[0], entries[0]); assert.equal(deduped[1199], entries[1199]); }); + +// T3 — AC-L5-4 (source-parse, command-registration.test.ts pattern): never +// import src/index.ts — it pulls the pi-tui runtime graph (design §D3). + +const sourcePath = fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)); +const source = fs.readFileSync(sourcePath, "utf8"); + +test("dedupePromptEntries is wired between the store drain and buildPromptRecords in openHistorySelector (AC-L5-4)", () => { + const loadIdx = source.indexOf('drainForScope("project")'); + assert.ok( + loadIdx >= 0, + "store drain call should exist in openHistorySelector", + ); + + const dedupeCallIdx = source.indexOf("dedupePromptEntries(", loadIdx); + assert.ok( + dedupeCallIdx > loadIdx, + "dedup invocation must come after the store drain call", + ); + + const buildIdx = source.indexOf("buildPromptRecords("); + assert.ok( + buildIdx > loadIdx, + "buildPromptRecords call should follow the loadSharedHistory call", + ); + assert.ok( + source + .slice(buildIdx, buildIdx + "buildPromptRecords(".length + 40) + .includes("dedupePromptEntries(entries)"), + "records must be built from dedupePromptEntries(entries) — the read-time dedup runs between load and build (design §B1)", + ); +}); + +test("the three command-registration pins still hold beside the dedup wiring (AC-L5-4)", () => { + const definitions = + source.split("async function openHistorySelector(").length - 1; + assert.strictEqual( + definitions, + 1, + "openHistorySelector should be defined exactly once", + ); + + const calls = source.split("openHistorySelector(ctx)").length - 1; + assert.strictEqual( + calls, + 2, + "the dedup wiring must add no openHistorySelector(ctx) occurrence", + ); + + const start = source.indexOf("async function openHistorySelector("); + const end = source.indexOf("export default function", start); + assert.notStrictEqual(end, -1, "extension entry point should follow"); + const body = source.slice(start, end); + assert.ok( + !body.includes('"No prompt history available."'), + "the warning is removed; the selector always opens", + ); +}); diff --git a/tests/history-delete-backfill.test.ts b/tests/history-delete-backfill.test.ts index e5fcd3bd8..1d095ae2c 100644 --- a/tests/history-delete-backfill.test.ts +++ b/tests/history-delete-backfill.test.ts @@ -1,11 +1,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; import fs from "node:fs"; import path from "node:path"; -import { - deletionActionsFor, - loadedCountAfterDelete, -} from "../extensions/history/selector-helpers.ts"; +import { loadedCountAfterDelete } from "../extensions/history/selector-helpers.ts"; // Unit 3 — L4 delete backfill (spec C4, design §B3). // @@ -61,7 +59,7 @@ test("loadedCountAfterDelete is defensive for an empty window (AC-L4-1)", () => // (Change 1 C1 interplay unchanged). const selectorSource = fs.readFileSync( - path.join(process.cwd(), "extensions", "history", "index.ts"), + fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)), "utf8", ); @@ -113,74 +111,3 @@ test("deleteCurrent splices, backfills, then re-filters — inside the guarded b "the bookkeeping must read the unfiltered window and the shrunk snapshot", ); }); - -// Slice 5 scenario pins (porting contract): the tombstone-always rule and -// the partial-failure toast path. The dev suite pins the planner + these -// deleteCurrent branch shapes in hide-prompts.test.ts (T27/T28); this file -// carries the delete-flow source-parse half so the slice-5 branch stays -// pinned inside the delete slice's own tests. - -test("deletionActionsFor always plans a tombstone — session provenance deletes nothing from disk", () => { - // Session/seed-born records: tombstone ONLY (transcripts and the seed are - // never rewritten by a delete) — the tombstone is what keeps the deleted - // prompt from resurfacing on the next drain. - assert.deepEqual(deletionActionsFor("session"), { - deleteFromEditorStore: false, - writeTombstone: true, - }); - // Editor records: disk delete AND tombstone (twin suppression). - assert.deepEqual(deletionActionsFor("editor"), { - deleteFromEditorStore: true, - writeTombstone: true, - }); - - const decl = selectorSource.indexOf("private deleteCurrent("); - assert.ok(decl >= 0, "deleteCurrent should exist"); - const end = selectorSource.indexOf("\n }", decl); - assert.ok(end > decl, "deleteCurrent's body should close"); - const body = selectorSource.slice(decl, end); - - // Branch shape: the tombstone write sits OUTSIDE the editor-store guard — - // every provenance lands a tombstone, so an entry that came from the - // seed or a transcript cannot resurface after its delete. - const editorGuardAt = body.indexOf("if (actions.deleteFromEditorStore)"); - assert.ok(editorGuardAt >= 0, "the editor-store guard must exist"); - const guardCloseAt = body.indexOf("\n }", editorGuardAt); - assert.ok(guardCloseAt > editorGuardAt, "the editor-store guard must close"); - const hideAt = body.indexOf("hidePrompt("); - assert.ok(hideAt >= 0, "the tombstone write must exist"); - assert.ok( - hideAt > guardCloseAt, - "the tombstone must follow (not sit inside) the editor-store guard", - ); -}); - -test("a failed hide toasts and only the session path aborts — the editor path still splices", () => { - const decl = selectorSource.indexOf("private deleteCurrent("); - assert.ok(decl >= 0, "deleteCurrent should exist"); - const end = selectorSource.indexOf("\n }", decl); - assert.ok(end > decl, "deleteCurrent's body should close"); - const body = selectorSource.slice(decl, end); - - const gateAt = body.indexOf('if (hide.status === "error")'); - assert.ok(gateAt >= 0, "hide errors must be gated"); - const spliceAt = body.indexOf("this.records.splice("); - assert.ok( - gateAt < spliceAt, - "the hide-error gate must precede the splice", - ); - const gate = body.slice(gateAt, spliceAt); - assert.ok( - gate.includes('this.onNotify?.(hide.message, "error")'), - "a hide error must toast", - ); - const abortGuardAt = gate.indexOf("if (!actions.deleteFromEditorStore)"); - assert.ok( - abortGuardAt >= 0, - "the early return must be exclusive to the session path", - ); - assert.ok( - !gate.slice(0, abortGuardAt).includes("return;"), - "no unconditional abort before the editor/session split — the editor path splices", - ); -}); diff --git a/tests/history-dispatch.test.ts b/tests/history-dispatch.test.ts index cdda32505..78a32c5bb 100644 --- a/tests/history-dispatch.test.ts +++ b/tests/history-dispatch.test.ts @@ -1,22 +1,21 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import fs from "node:fs"; import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; /** * Dispatch table structural tests — source-parsed (AC-P2-1.3, AC-P2-2.1, * AC-P2-4.1), following the preview-layout.test.ts pattern. * - * PromptHistorySelector is private to extensions/history/index.ts and needs - * the pi-tui runtime (Container, Input, TUI, Theme), so these tests read the + * PromptHistorySelector is private to src/index.ts and needs the + * pi-tui runtime (Container, Input, TUI, Theme), so these tests read the * source file and pin the normative §B2 shape instead of importing it: * exactly 12 explicit entries in a fixed order, then the implicit * forwardToSearch fallthrough inside handleInput. */ -const sourcePath = fileURLToPath( - new URL("../extensions/history/index.ts", import.meta.url), -); +const sourcePath = fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)); const source = fs.readFileSync(sourcePath, "utf8"); const DISPATCH_DECL = "private readonly dispatch: readonly DispatchEntry[] = ["; @@ -59,7 +58,7 @@ function dispatchTable(): string { assert.notStrictEqual( start, -1, - "dispatch table declaration should exist in extensions/history/index.ts", + "dispatch table declaration should exist in src/index.ts", ); const end = source.indexOf(TABLE_CLOSE, start); assert.notStrictEqual(end, -1, "dispatch table closing should exist"); diff --git a/tests/history-drain-hidden.test.ts b/tests/history-drain-hidden.test.ts index 90c479758..58ac72064 100644 --- a/tests/history-drain-hidden.test.ts +++ b/tests/history-drain-hidden.test.ts @@ -10,11 +10,7 @@ import { projectHash, } from "../extensions/history/store.ts"; -// Portable project identity: a never-existing literal. projectHash falls -// back to hashing the raw string when realpath fails, so the identity is -// deterministic on every machine (no machine-specific absolute paths). - -const CWD = "/pi-history-test/drain-hidden-project"; +const CWD = "/Users/admin/Dev/pi/pi-history"; function write(file: string, texts: string[], ts = 100): void { fs.mkdirSync(path.dirname(file), { recursive: true }); diff --git a/tests/history-drain-order.test.ts b/tests/history-drain-order.test.ts index 86cfddca9..0047dc4ed 100644 --- a/tests/history-drain-order.test.ts +++ b/tests/history-drain-order.test.ts @@ -4,17 +4,20 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { + deleteFromProject, drainGlobal, drainProject, globalSeedPath, projectHash, } from "../extensions/history/store.ts"; +// node:test has no test.skipIf (Bun-ism): emulate via the options object. +const skipIf = + (condition: unknown) => + (name: string, fn: () => unknown) => + test(name, { skip: condition ? "requires non-root" : false }, fn); -// Portable project identity: a never-existing literal. projectHash falls -// back to hashing the raw string when realpath fails, so the identity is -// deterministic on every machine (no machine-specific absolute paths). -const CWD = "/pi-history-test/drain-order-project"; +const CWD = "/Users/admin/Dev/pi/pi-history"; function writeTs(file: string, texts: string[], ts: number): void { fs.mkdirSync(path.dirname(file), { recursive: true }); @@ -31,11 +34,8 @@ test("atomic rewrite (delete) does not reshuffle the drain order", () => { writeTs(path.join(dir, "old.jsonl"), ["a-old"], 100); writeTs(path.join(dir, "new.jsonl"), ["z-new"], 200); assert.deepEqual(drainProject(root, CWD), ["z-new", "a-old"]); - // Slice 5 ports deleteFromProject; its observable effect on the drain is - // simulated directly here: an atomic rewrite of the affected file that - // empties it — the mtime jumps to NOW, and the drain order must not move. - fs.writeFileSync(path.join(dir, "old.jsonl"), "", "utf8"); - fs.utimesSync(path.join(dir, "old.jsonl"), new Date(), new Date()); + // Deleting from the old file rewrites it — mtime jumps to NOW. + deleteFromProject(root, CWD, "a-old"); assert.deepEqual(drainProject(root, CWD), ["z-new"]); // Re-add with an OLD ts via direct write: still ordered by ts, not mtime. writeTs(path.join(dir, "old2.jsonl"), ["b-old"], 150); @@ -57,9 +57,9 @@ test("global drain puts the legacy seed last regardless of its fresh mtime", () assert.deepEqual(drainGlobal(root), ["fresh", "legacy-2", "legacy-1"]); }); -test( +const sealedDrainTest = skipIf(process.getuid?.() === 0); +sealedDrainTest( "an unreadable store file is skipped; the rest drain in the expected order", - { skip: process.getuid?.() === 0 }, () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ord-sealed-")); const dir = path.join(root, "projects", projectHash(CWD)); diff --git a/tests/history-gc.test.ts b/tests/history-gc.test.ts index f7de7b40e..ebd04c1e0 100644 --- a/tests/history-gc.test.ts +++ b/tests/history-gc.test.ts @@ -3,18 +3,19 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { gcProjectDir, projectHash } from "../extensions/history/store.ts"; +import { + compactProjectDir, + gcProjectDir, + projectHash, +} from "../extensions/history/store.ts"; +// node:test has no test.skipIf (Bun-ism): emulate via the options object. +const skipIf = + (condition: unknown) => + (name: string, fn: () => unknown) => + test(name, { skip: condition ? "requires non-root" : false }, fn); -// GC/compaction (slice 6): threshold no-op below the limits, keep-newest -// semantics, and the failure paths — the compact file lands atomically -// before any original is removed, cleanup failures are tolerated, unreadable -// files are skipped, and an append landing mid-compaction is never lost. -// All fixtures live under os.tmpdir(): the user's real ~/.pi store root is -// never touched. (Ported from the dev repo's test/history/gc.test.ts; the -// dev-only compactProjectDir shortcut is gone — gcProjectDir with explicit -// thresholds is the single PR-branch entry point.) -const CWD = "/pi-history-test/project-gc"; +const CWD = "/Users/admin/Dev/pi/pi-history"; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-gc-")); @@ -54,40 +55,6 @@ function totalLines(dir: string): number { return total; } -/** Line texts of the single compact-*.jsonl file in dir (must exist). */ -function compactTexts(dir: string): string[] { - const compact = fs.readdirSync(dir).find((f) => f.startsWith("compact-")); - assert.ok(compact, "a compact-*.jsonl file must exist"); - return fs - .readFileSync(path.join(dir, compact), "utf8") - .trim() - .split("\n") - .map((l) => (JSON.parse(l) as { text: string }).text); -} - -/** - * Replace fs.rmSync (the shared CJS exports object store.ts resolves at - * call time) for the duration of fn; the original is always restored. - * `rmSync` inside the replacement is the captured original, so replacements - * can observe-or-fail and then call through. - */ -function withRmSyncPatched( - replacement: (file: string, rmSync: (file: string) => void) => void, - fn: () => void, -): void { - type RmSync = (file: string) => void; - const realRmSync = fs.rmSync.bind(fs) as RmSync; - const target = fs as unknown as { rmSync: RmSync }; - target.rmSync = (file: string) => { - replacement(file, realRmSync); - }; - try { - fn(); - } finally { - target.rmSync = realRmSync; - } -} - test("under both thresholds: GC is a no-op", () => { const root = makeRoot(); const dir = projectRoot(root); @@ -120,15 +87,8 @@ test("file-count threshold merges the oldest files into one compact file", () => // 12 files -> newest 1 kept + 1 compact file = 2 files; all lines kept. assert.equal(fs.readdirSync(dir).length, 2); assert.equal(totalLines(dir), 120); - // The compact file is the renamed final artifact, not a staging leftover. - assert.match( - fs.readdirSync(dir).find((f) => f.startsWith("compact-")) ?? "", - /^compact-\d+-\d+\.jsonl$/, - ); - assert.deepEqual( - fs.readdirSync(dir).filter((f) => f.includes(".tmp-")), - [], - ); + const compact = fs.readdirSync(dir).find((f) => f.startsWith("compact-")); + assert.ok(compact); // The newest original file survives untouched by name. assert.equal(fs.readdirSync(dir).includes("f12.jsonl"), true); }); @@ -151,9 +111,9 @@ test("line-count threshold triggers compaction too", () => { assert.equal(fs.readdirSync(dir).includes("g3.jsonl"), true); }); -test("GC on a missing project dir is a no-op", () => { +test("compactProjectDir on a missing dir is a no-op", () => { const root = makeRoot(); - const result = gcProjectDir(root, "/does/not/exist"); + const result = compactProjectDir(root, "/does/not/exist"); assert.deepEqual(result, { compacted: false, merged: 0 }); }); @@ -179,41 +139,44 @@ test("compaction keeps the newest 10 files, merges the rest", () => { assert.equal(names.includes("h06.jsonl"), true); }); -// node:test has no test.skipIf (Bun-ism): root skips via the options object. -test( - "an unreadable file (chmod 000) is skipped; GC still compacts the readable tail", - { skip: process.getuid?.() === 0 ? "requires non-root" : false }, +const sealedGcTest = skipIf(process.getuid?.() === 0); +sealedGcTest( + "compactProjectDir skips an unreadable file's content and compacts the readable entries", () => { const root = makeRoot(); const dir = projectRoot(root); fs.mkdirSync(dir, { recursive: true }); - // 3 files, keepNewest 1 -> the two oldest merge; the sealed one sits in - // the merged tail so its bytes hit the unreadable-skip branch (both the - // line-counting pass and the merge pass skip it). + // 3 files, keepNewest 1 → the two oldest merge; the sealed one sits in + // the merged tail so its content hits the unreadable-skip branch. writeFile(dir, "readable-old.jsonl", 5, 1000); const sealed = writeFile(dir, "sealed-old.jsonl", 5, 2000); writeFile(dir, "newest.jsonl", 5, 3000); fs.chmodSync(sealed, 0o000); try { - const result = gcProjectDir(root, CWD, { - fileThreshold: 2, - lineThreshold: 100000, - keepNewest: 1, - }); + const result = compactProjectDir(root, CWD, { keepNewest: 1 }); // The merged count covers the whole tail, sealed file included. assert.deepEqual(result, { compacted: true, merged: 2 }); + const compact = fs.readdirSync(dir).find((f) => f.startsWith("compact-")); + if (compact === undefined) { + throw new Error("the compact file must exist"); + } + const compactTexts = fs + .readFileSync(path.join(dir, compact), "utf8") + .trim() + .split("\n") + .map((l) => (JSON.parse(l) as { text: string }).text); // Only the readable tail file's entries compacted; the sealed bytes // were skipped, never fatal. (writeFile names entries `${name}-${i}`.) - assert.deepEqual(compactTexts(dir), [ + assert.deepEqual(compactTexts, [ "readable-old.jsonl-0", "readable-old.jsonl-1", "readable-old.jsonl-2", "readable-old.jsonl-3", "readable-old.jsonl-4", ]); - // Cleanup semantics: the tail originals (sealed one included) are + // GC cache semantics: the tail originals (sealed one included) are // removed after the compact file lands — unlink needs no read access. - assert.equal(fs.existsSync(sealed), false); + assert.equal(fs.readdirSync(dir).includes("sealed-old.jsonl"), false); assert.equal(fs.readdirSync(dir).includes("newest.jsonl"), true); } finally { // The compaction removes the sealed original; restore only if it @@ -226,135 +189,3 @@ test( } }, ); - -test("the compact file lands complete before any original is removed", () => { - const root = makeRoot(); - const dir = projectRoot(root); - fs.mkdirSync(dir, { recursive: true }); - for (let i = 1; i <= 12; i++) { - writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000); - } - // Observe, do not replace: at the FIRST cleanup unlink the compact file - // must already exist on disk with the full merged content (110 lines). - // That is the crash-safe ordering contract: readers never see the tail - // gone with no compact file in its place. - let compactCompleteAtFirstRm: boolean | null = null; - withRmSyncPatched( - (file, rmSync) => { - if (compactCompleteAtFirstRm === null) { - const parent = path.dirname(file); - const compact = fs - .readdirSync(parent) - .find((f) => f.startsWith("compact-")); - compactCompleteAtFirstRm = - compact !== undefined && - fs - .readFileSync(path.join(parent, compact), "utf8") - .trim() - .split("\n") - .filter((l) => l.trim().length > 0).length === 110; - } - rmSync(file); - }, - () => { - const result = gcProjectDir(root, CWD, { - fileThreshold: 10, - lineThreshold: 10000, - keepNewest: 1, - }); - assert.deepEqual(result, { compacted: true, merged: 11 }); - }, - ); - assert.equal(compactCompleteAtFirstRm, true); -}); - -test("rm failure is tolerated: originals survive, GC still reports success", () => { - const root = makeRoot(); - const dir = projectRoot(root); - fs.mkdirSync(dir, { recursive: true }); - for (let i = 1; i <= 12; i++) { - writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000); - } - // Simulate every cleanup unlink failing (e.g. originals held by another - // process): the compact file already landed, so a surviving original is - // harmless — readers dedupe by identity. - withRmSyncPatched( - () => { - throw new Error("simulated EBUSY: original still held"); - }, - () => { - const result = gcProjectDir(root, CWD, { - fileThreshold: 10, - lineThreshold: 10000, - keepNewest: 1, - }); - // The success shape is unchanged even though cleanup failed. - assert.deepEqual(result, { compacted: true, merged: 11 }); - }, - ); - // The compact file is complete on disk... - assert.equal(compactTexts(dir).length, 110); - // ...and every original survived the failed cleanup (12 + 1 compact). - assert.equal(fs.readdirSync(dir).length, 13); -}); - -test("an append landing during compaction is never lost (active writer)", () => { - const root = makeRoot(); - const dir = projectRoot(root); - fs.mkdirSync(dir, { recursive: true }); - // 12 old files (merge-tail candidates) + one active writer file with the - // newest mtime. The freshness rule keeps the active file out of the merge - // tail — that is what makes concurrent appends safe during GC. - for (let i = 1; i <= 12; i++) { - writeFile(dir, `t${String(i).padStart(2, "0")}.jsonl`, 5, i * 1000); - } - const active = writeFile(dir, "active.jsonl", 5, 99_000); - // Mid-compaction (first cleanup unlink), the active writer appends a line. - let appended = false; - withRmSyncPatched( - (file, rmSync) => { - if (!appended) { - appended = true; - fs.appendFileSync( - active, - `${JSON.stringify({ v: 1, text: "during-gc" })}\n`, - "utf8", - ); - } - rmSync(file); - }, - () => { - const result = gcProjectDir(root, CWD, { - fileThreshold: 10, - lineThreshold: 100000, - keepNewest: 10, - }); - // 13 files > threshold 10; tail = 3 oldest; active writer untouched. - assert.deepEqual(result, { compacted: true, merged: 3 }); - }, - ); - // The active file survived by name with every line: the pre-GC lines and - // the line appended mid-compaction. - const activeTexts = fs - .readFileSync(active, "utf8") - .trim() - .split("\n") - .map((l) => (JSON.parse(l) as { text: string }).text); - assert.deepEqual(activeTexts, [ - "active.jsonl-0", - "active.jsonl-1", - "active.jsonl-2", - "active.jsonl-3", - "active.jsonl-4", - "during-gc", - ]); - // The tail's 15 lines all compacted; nothing from kept files was merged. - const mergedTexts = compactTexts(dir); - assert.equal(mergedTexts.length, 15); - assert.ok(mergedTexts.includes("t01.jsonl-0")); - assert.ok(mergedTexts.includes("t03.jsonl-4")); - assert.ok(!mergedTexts.some((t) => t.startsWith("active."))); - assert.ok(!mergedTexts.some((t) => t.startsWith("t04."))); - // Whole-dir accounting: 13 x 5 original lines + 1 mid-GC append. - assert.equal(totalLines(dir), 66); -}); diff --git a/tests/history-header-layout.test.ts b/tests/history-header-layout.test.ts new file mode 100644 index 000000000..4734967b4 --- /dev/null +++ b/tests/history-header-layout.test.ts @@ -0,0 +1,52 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + planHeaderLayout, + SCOPE_RADIO_COMPACT_GLOBAL, + SCOPE_RADIO_COMPACT_PROJECT, + SCOPE_RADIO_FULL_GLOBAL, + SCOPE_RADIO_FULL_PROJECT, + scopeRadioText, +} from "../extensions/history/selector-helpers.ts"; + +const LEFT = + " History Search ".length + + " · 1 of 10 ".length + + " · loaded 10 of 27 ".length; +const RADIO = SCOPE_RADIO_FULL_PROJECT.length; +const GAP = 4; + +test("inline while counts plus radio plus minimum gap fit the width", () => { + assert.equal( + planHeaderLayout(LEFT + GAP + RADIO, LEFT, RADIO, GAP), + "inline", + ); + assert.equal(planHeaderLayout(200, LEFT, RADIO, GAP), "inline"); +}); + +test("stacked (tablet) once the spacer would drop below the minimum gap", () => { + assert.equal( + planHeaderLayout(LEFT + GAP + RADIO - 1, LEFT, RADIO, GAP), + "stacked", + ); + assert.equal(planHeaderLayout(LEFT, LEFT, RADIO, GAP), "stacked"); +}); + +test("compact (mobile) when even the counts line no longer fits", () => { + assert.equal(planHeaderLayout(LEFT - 1, LEFT, RADIO, GAP), "compact"); + assert.equal(planHeaderLayout(30, LEFT, RADIO, GAP), "compact"); +}); + +test("radio pins the user-directed labels", () => { + assert.equal(SCOPE_RADIO_FULL_PROJECT, "◉ Current project | ○ All projects"); + assert.equal(SCOPE_RADIO_FULL_GLOBAL, "○ Current project | ◉ All projects"); + assert.equal(SCOPE_RADIO_COMPACT_PROJECT, "◉ Current project | ○ All"); + assert.equal(SCOPE_RADIO_COMPACT_GLOBAL, "○ Current | ◉ All projects"); +}); + +test("scopeRadioText abbreviates only in compact mode", () => { + assert.equal(scopeRadioText("project", false), SCOPE_RADIO_FULL_PROJECT); + assert.equal(scopeRadioText("global", false), SCOPE_RADIO_FULL_GLOBAL); + assert.equal(scopeRadioText("project", true), SCOPE_RADIO_COMPACT_PROJECT); + assert.equal(scopeRadioText("global", true), SCOPE_RADIO_COMPACT_GLOBAL); +}); diff --git a/tests/history-hide-prompts.test.ts b/tests/history-hide-prompts.test.ts index 03c054863..011b4d042 100644 --- a/tests/history-hide-prompts.test.ts +++ b/tests/history-hide-prompts.test.ts @@ -1,15 +1,19 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { hidePrompt, loadHiddenPrompts } from "../extensions/history/hide-prompts.ts"; -import { promptDedupKey } from "../extensions/history/selector-helpers.ts"; +import { + deletionActionsFor, + promptDedupKey, +} from "../extensions/history/selector-helpers.ts"; -// Unit WU4 — tombstone write half + read half (spec C4, design §D6). fs-only -// coverage. The dev suite's deleteCurrent source-parse pins (T27/T28) and -// the deletionActionsFor planner pins cover the slice-3 selector branch and -// the slice-5 delete flow; they port with those slices. +// Unit WU4 — S4 tombstone write half + deletion planner + deleteCurrent +// branch (spec C4, design §D6/§F). fs-only: the overlay file is read as +// TEXT for the delete-flow pins (command-registration pattern — never +// imported; it pulls the pi-tui runtime graph). function makeStateDir(name: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), `hide-prompts-${name}-`)); @@ -66,9 +70,9 @@ test("T25 (AC-S4-2): duplicate hides compact to one key; a missing file reads as assert.ok(loaded.has(promptDedupKey("same text"))); }); -// T26 — AC-S4-5: corrupt hidden.json is fail-open (READ half) AND the next -// hide rewrites the file clean as a sorted compact array — the rewrite half -// is the recovery path. +// T26 — AC-S4-5: corrupt hidden.json is fail-open (READ half, green since +// WU3) AND the next hide rewrites the file clean as a sorted compact array — +// the rewrite half is the RED seam here. test("T26 (AC-S4-5): corrupt hidden.json loads as empty and the next hide rewrites it clean", () => { const stateDir = makeStateDir("t26"); fs.writeFileSync( @@ -83,6 +87,160 @@ test("T26 (AC-S4-5): corrupt hidden.json loads as empty and the next hide rewrit assert.equal(loadHiddenPrompts(stateDir).size, 1); }); +// T27 — AC-S4-3: session delete flow. Planner: tombstone only. The hide +// lands atomically in the state dir (only hidden.json remains — the .tmp +// staging file was renamed into place). Source-parse pins on the +// deleteCurrent branch: the hide call present, the disk delete UNREACHABLE +// from the session path (it sits inside the editor-store guard), splice + +// bookkeeping AFTER the hide, a failed session hide aborts WITHOUT +// splicing, and no raw fs write ever appears in the branch (transcripts are +// never written). +test("T27 (AC-S4-3): session delete — tombstone-only plan, atomic hide, branch hides then splices after", () => { + assert.deepEqual(deletionActionsFor("session"), { + deleteFromEditorStore: false, + writeTombstone: true, + }); + + // fs behavior: the hide writes the shared key; the state dir holds only + // hidden.json afterwards (no orphaned .tmp staging file). + const stateDir = makeStateDir("t27"); + assert.deepEqual(hidePrompt(stateDir, "session row"), { + status: "written", + }); + assert.deepEqual(readHideFile(stateDir), [promptDedupKey("session row")]); + assert.deepEqual(fs.readdirSync(stateDir).sort(), ["hidden.json"]); + + // Source-parse the deleteCurrent branch in src/index.ts. + const overlaySource = fs.readFileSync( + fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)), + "utf8", + ); + const decl = overlaySource.indexOf("private deleteCurrent("); + assert.ok(decl !== -1, "deleteCurrent must exist"); + const end = overlaySource.indexOf("\n }", decl); + assert.ok(end > decl, "deleteCurrent's body must close"); + const body = overlaySource.slice(decl, end); + + const hideAt = body.indexOf("hidePrompt("); + assert.ok(hideAt !== -1, "the branch must write the tombstone hide"); + const diskDeleteAt = body.indexOf( + "deleteFromProject(PI_HISTORY_ROOT, CURRENT_CWD, selected.text)", + ); + assert.ok( + diskDeleteAt !== -1, + "the editor branch keeps its exact call shape", + ); + // The disk delete is unreachable from the session path: it sits INSIDE + // the editor-store guard (no block closer between guard and call). + const editorGuardAt = body.indexOf("if (actions.deleteFromEditorStore)"); + assert.ok( + editorGuardAt !== -1 && editorGuardAt < diskDeleteAt, + "the disk delete must be guarded by the editor provenance", + ); + assert.ok( + !body.slice(editorGuardAt, diskDeleteAt).includes("\n }"), + "the disk delete call must sit inside the editor-store guard block", + ); + + // Session path: hide first, then splice + bookkeeping. + const spliceAt = body.indexOf("this.records.splice("); + const backfillAt = body.indexOf("loadedCountAfterDelete("); + assert.ok( + hideAt < spliceAt && spliceAt < backfillAt, + "session path: hide, then splice, then the Change 2 bookkeeping", + ); + + // A failed session hide aborts WITHOUT splicing: the hide-error gate + // precedes the splice and its early return is exclusive to the + // non-editor path. + const gateAt = body.indexOf('if (hide.status === "error")'); + assert.ok( + gateAt !== -1 && gateAt < spliceAt, + "the hide-error gate must precede the splice", + ); + const gate = body.slice(gateAt, spliceAt); + assert.ok( + gate.includes("if (!actions.deleteFromEditorStore)"), + "the abort must be conditional on the non-editor path", + ); + assert.ok( + gate.includes("return;"), + "a failed session hide aborts without splicing", + ); + + // Transcript invariant: the branch never writes files directly. + assert.ok( + !body.includes("writeFileSync"), + "no raw writes in the delete branch", + ); + assert.ok( + !body.includes("appendFileSync"), + "no raw appends in the delete branch", + ); +}); + +// T28 — AC-S4-4: editor delete flow. Planner: disk delete AND tombstone. +// Source-parse pins: the Change 1 call shape stays exact and its status +// gate unchanged; the twin-suppression hide follows the deleted status; in +// the hide-error gate the toast fires and the splice proceeds on the editor +// path (the row is legitimately gone) — only the session path returns. +test("T28 (AC-S4-4): editor delete — exact disk-delete shape, twin suppression after deleted, splice proceeds through hide errors", () => { + assert.deepEqual(deletionActionsFor("editor"), { + deleteFromEditorStore: true, + writeTombstone: true, + }); + + const overlaySource = fs.readFileSync( + fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)), + "utf8", + ); + const decl = overlaySource.indexOf("private deleteCurrent("); + assert.ok(decl !== -1, "deleteCurrent must exist"); + const end = overlaySource.indexOf("\n }", decl); + assert.ok(end > decl, "deleteCurrent's body must close"); + const body = overlaySource.slice(decl, end); + + // Change 1 shape unchanged: exact call, followed by the existing gate. + const diskDeleteAt = body.indexOf( + "deleteFromProject(PI_HISTORY_ROOT, CURRENT_CWD, selected.text)", + ); + assert.ok(diskDeleteAt !== -1, "the Change 1 call shape must be exact"); + const earlyReturnAt = body.indexOf("if (removed === 0) return;"); + assert.ok( + earlyReturnAt > diskDeleteAt, + "the existing status gate must follow the disk delete", + ); + + // Twin suppression: the tombstone write follows the deleted status so the + // session twin of the same text cannot resurface (R7). + const hideAt = body.indexOf("hidePrompt("); + assert.ok( + hideAt > earlyReturnAt, + "the twin-suppression hide must follow the deleted status", + ); + + // Error-semantics shape: the hide-error gate toasts, and the ONLY early + // return inside it sits behind the non-editor guard — the editor row is + // legitimately gone and splices even when the twin suppression fails. + const spliceAt = body.indexOf("this.records.splice("); + const gateAt = body.indexOf('if (hide.status === "error")'); + assert.ok(gateAt !== -1, "hide errors must be gated"); + const gate = body.slice(gateAt, spliceAt); + assert.ok( + gate.includes('this.onNotify?.(hide.message, "error")'), + "a hide error must toast", + ); + const abortGuardAt = gate.indexOf("if (!actions.deleteFromEditorStore)"); + assert.ok( + abortGuardAt !== -1, + "the early return must be exclusive to the session path", + ); + assert.ok( + !gate.slice(0, abortGuardAt).includes("return;"), + "no unconditional abort before the editor/session split — the editor path splices", + ); +}); + // WU4c — write-failure path (AC-S4-2 triangulation): a state dir that cannot // be created (its parent is a regular file) makes the atomic write return // false, and hidePrompt maps that to the toast-suitable error object — diff --git a/tests/history-lazy-windowing.test.ts b/tests/history-lazy-windowing.test.ts index 5ee40838a..f804da454 100644 --- a/tests/history-lazy-windowing.test.ts +++ b/tests/history-lazy-windowing.test.ts @@ -1,7 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import fs from "node:fs"; import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; import { buildPromptRecords, filterPrompts, @@ -16,14 +17,14 @@ import { // Unit 2a — L1+L2 windowing helpers (spec C1/C2, design §D3/§D4). // // The ratified constant VALUES (design R3) are pinned here as test literals -// while the named constants themselves land in extensions/history/index.ts: +// while the named constants themselves land in src/index.ts in Unit 2b: // // INITIAL_BATCH = 10 · BATCH_SIZE = 10 · PRELOAD_BUFFER = 3 (trigger at 8th; milestones 10/20/30) // // Every helper is a parameterized pure function over UNFILTERED counts only: // `filteredRecords.length` appears in no trigger or growth expression (the // §8a regression pin, AC-L2-2). All behaviors below use the helpers exactly -// as the §B2 wiring does in the selector — grow-before-move, one batch per +// as the §B2 wiring will in Unit 2b — grow-before-move, one batch per // threshold crossing, derived exhaustion (no stored flag). // T4 — AC-L1-1: initial window clamp, min(INITIAL_BATCH, records.length). @@ -142,9 +143,7 @@ test("the selector stores no exhausted/isLoaded flag — exhaustion is derivatio test("trigger arithmetic is unfiltered-only: exact C2 predicate, no filteredRecords in growth helpers (AC-L2-2)", () => { const helpersSource = fs.readFileSync( - fileURLToPath( - new URL("../extensions/history/selector-helpers.ts", import.meta.url), - ), + fileURLToPath(new URL("../extensions/history/selector-helpers.ts", import.meta.url)), "utf8", ); const bodyOf = (name: string): string => { @@ -296,43 +295,42 @@ test("nextLoadedCount steps min(L + max(1, batchSize), R) including the degenera // --------------------------------------------------------------------------- // Unit 2b — §B2 wiring pins (T9) + headerRow-only constraint (T10). // -// Source-parse tests over extensions/history/index.ts. The body extractor -// mirrors dispatch.test.ts's methodBody(): slice from the method declaration -// to the first "\n }" — which is exactly why every nested if added by the -// §B2 wiring must close at 4-space indent (a 4-space closer cannot match the +// Source-parse tests over src/index.ts. The body extractor mirrors +// dispatch.test.ts's methodBody(): slice from the method declaration to the +// first "\n }" — which is exactly why every nested if added by the §B2 +// wiring must close at 4-space indent (a 4-space closer cannot match the // first-close slice, so the method close is still found). -// -// Slice-3 adaptation note: upstream wires the growth trigger INLINE in -// moveUp/moveDown (no shared growLoadedWindowIfNeeded helper — that shape is -// dev-repo drift). The pins below assert the same AC contracts against the -// inline form. function methodBodyOf(name: string): string { const decl = selectorSource.indexOf(`private ${name}(`); - assert.ok(decl >= 0, `private ${name}() should exist in extensions/history/index.ts`); + assert.ok(decl >= 0, `private ${name}() should exist in src/index.ts`); const end = selectorSource.indexOf("\n }", decl); assert.ok(end > decl, `private ${name}() body should close`); return selectorSource.slice(decl, end); } // T9 — AC-L1-4: batch append points — growth wiring in the three downward -// paths ONLY (moveUp carries the older-direction growth check); every other -// upward site and applyFilter stay pure. +// paths ONLY; every upward site and applyFilter stay pure. test("growth wiring appears in moveDown, moveUp, pageListDown, jumpToLast (AC-L1-4)", () => { const down = methodBodyOf("moveDown"); assert.ok( - down.includes("shouldGrowWindow("), - "moveDown must evaluate the C2 trigger", + down.includes("this.growLoadedWindowIfNeeded()"), + "moveDown must route through the shared growth helper", ); + const up = methodBodyOf("moveUp"); assert.ok( - down.includes("nextLoadedCount("), - "moveDown must grow via nextLoadedCount", + up.includes("this.growLoadedWindowIfNeeded()"), + "moveUp must route through the shared growth helper (older-direction growth)", ); - const up = methodBodyOf("moveUp"); + const grow = methodBodyOf("growLoadedWindowIfNeeded"); assert.ok( - up.includes("shouldGrowWindow(") && up.includes("nextLoadedCount("), - "moveUp must carry the older-direction growth check", + grow.includes("shouldGrowWindow("), + "the growth helper must evaluate the C2 trigger", + ); + assert.ok( + grow.includes("nextLoadedCount("), + "the growth helper must grow via nextLoadedCount", ); const pageDown = methodBodyOf("pageListDown"); assert.ok( @@ -364,8 +362,8 @@ test("growth wiring appears in moveDown, moveUp, pageListDown, jumpToLast (AC-L1 test("growth runs BEFORE the index computation in every downward path (AC-L1-7, AC-L1-5, AC-L1-6)", () => { const down = methodBodyOf("moveDown"); - const growAt = down.indexOf("shouldGrowWindow("); - assert.notEqual(growAt, -1, "moveDown must evaluate the C2 trigger"); + const growAt = down.indexOf("this.growLoadedWindowIfNeeded()"); + assert.notEqual(growAt, -1, "moveDown must route through the growth helper"); assert.ok( growAt < down.indexOf("moveSelectedIndex("), "moveDown must grow before the modulo — wrap-to-0 only on the exhausted set", @@ -394,19 +392,26 @@ test("growth arithmetic names only this.loadedCount and this.records.length (AC- const down = methodBodyOf("moveDown"); const downGrow = down.slice(0, down.indexOf("moveSelectedIndex(")); assert.ok( - downGrow.includes("shouldGrowWindow(") && - downGrow.includes("nextLoadedCount("), - "moveDown's growth region must run the trigger + one batch before the modulo", + downGrow.includes("this.growLoadedWindowIfNeeded()"), + "moveDown's growth region must run the shared helper before the modulo", ); assert.ok( !downGrow.includes("filteredRecords"), "moveDown's pre-modulo region must read UNFILTERED counts only", ); + const grow = methodBodyOf("growLoadedWindowIfNeeded"); + assert.ok( + grow.includes("this.loadedCount") && grow.includes("this.records.length"), + "the growth helper must pass the unfiltered counts", + ); + assert.ok( + !grow.includes("filteredRecords"), + "the growth helper must read UNFILTERED counts only", + ); const page = methodBodyOf("pageListDown"); const pageGrow = page.slice(0, page.indexOf("pageSelectedIndex(")); assert.ok( - pageGrow.includes("loadedCountForTarget(") && - pageGrow.includes("this.loadedCount") && + pageGrow.includes("this.loadedCount") && pageGrow.includes("this.records.length"), "pageListDown's catch-up must pass the unfiltered counts", ); @@ -491,7 +496,7 @@ test("the header keeps the position segment plus the loaded suffix on the existi const ctorEnd = selectorSource.indexOf('this.applyFilter("")', ctorAt); const ctorAddChild = selectorSource.slice(ctorAt, ctorEnd).split("this.addChild(").length - 1; - assert.equal(ctorAddChild, 12, "the constructor child sequence is unchanged"); + assert.equal(ctorAddChild, 14, "the constructor child sequence is unchanged"); }); // T14 — AC-L2-3 revision (user-directed 2026-09-08): a non-empty query diff --git a/tests/history-legacy-migrate-v2.test.ts b/tests/history-legacy-migrate-v2.test.ts index 77d43586a..38e4ba139 100644 --- a/tests/history-legacy-migrate-v2.test.ts +++ b/tests/history-legacy-migrate-v2.test.ts @@ -4,6 +4,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { globalSeedPath, migrateLegacyStores } from "../extensions/history/store.ts"; +// node:test has no test.skipIf (Bun-ism): emulate via the options object. +const skipIf = + (condition: unknown) => + (name: string, fn: () => unknown) => + test(name, { skip: condition ? "requires non-root" : false }, fn); + function makeDirs(): { root: string; agentDir: string } { const base = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-mig-")); @@ -110,54 +116,7 @@ test("malformed v1 jsonl lines are skipped, not fatal", () => { 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}.imported`), true); - assert.deepEqual(fileTexts(globalSeedPath(root)), ["survives-retry"]); - }, -); - +const sealedLegacyTest = skipIf(process.getuid?.() === 0); sealedLegacyTest( "an unreadable legacy file is skipped; the readable file still migrates", () => { diff --git a/tests/history-max-results-cap.test.ts b/tests/history-max-results-cap.test.ts index fb319c8ec..938087c0e 100644 --- a/tests/history-max-results-cap.test.ts +++ b/tests/history-max-results-cap.test.ts @@ -1,19 +1,15 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { filterPrompts } from "../extensions/history/selector-helpers.ts"; /** * WU5 tests (AC-S5-1, AC-S5-2): the MAX_RESULTS raise 1000 → 10000 is an * OUTPUT cap only — filterPrompts caps both of its slice sites; the load - * path never snapshots. Pure import (no pi-tui graph) plus a source-parse - * pin on the selector-helpers slice sites. - * - * The dev suite's openHistorySelector body pin (no slice() on the load - * path) covers the slice-3 selector wiring in extensions/history/index.ts - * and ports with that slice — index.ts stays at its slice-1 surface here. + * path never snapshots. Pure .mjs import (no pi-tui graph) plus a + * source-parse pin on the load path (command-registration pattern). */ interface CapRecord { @@ -55,19 +51,36 @@ test("T29 (AC-S5-1): filtered-query slice caps at the raised 10000", () => { assert.ok(result.every((r) => r.searchText.includes("match"))); }); -// T30 — AC-S5-2: output-cap-only semantics (source-parse). Selector-helpers -// reads the constant at exactly the two sanctioned filterPrompts slice -// sites — no other cap exists in the helper module. +// T30 — AC-S5-2: output-cap-only semantics (source-parse). The load path in +// openHistorySelector carries NO slicing call — a snapshot cap would have to +// slice there — and selector-helpers.ts reads the constant at exactly the two +// sanctioned filterPrompts slice sites. Expected GREEN already BEFORE the +// WU5 wiring (the load path carries no cap today); it must STAY green after. -const helperSource = fs.readFileSync( - fileURLToPath( - new URL("../extensions/history/selector-helpers.ts", import.meta.url), - ), +const indexSource = fs.readFileSync( + fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)), + "utf8", +); +const pureSource = fs.readFileSync( + fileURLToPath(new URL("../extensions/history/selector-helpers.ts", import.meta.url)), "utf8", ); -test("T30 (AC-S5-2): filterPrompts hosts exactly the two sanctioned cap slice sites", () => { - const sliceSites = helperSource.split("slice(0, MAX_RESULTS)").length - 1; +function openHistorySelectorBody(): string { + const start = indexSource.indexOf("async function openHistorySelector("); + assert.ok(start >= 0, "openHistorySelector should exist"); + const end = indexSource.indexOf("export default function", start); + assert.ok(end > start, "extension entry point should follow"); + return indexSource.slice(start, end); +} + +test("T30 (AC-S5-2): the load path carries no slicing call — output cap only", () => { + const body = openHistorySelectorBody(); + assert.ok( + !body.includes("slice("), + "no snapshot cap in the load path: openHistorySelector must not slice records", + ); + const sliceSites = pureSource.split("slice(0, MAX_RESULTS)").length - 1; assert.equal( sliceSites, 2, diff --git a/tests/history-multi-reader.test.ts b/tests/history-multi-reader.test.ts index c82ebcbc7..0c9434ab8 100644 --- a/tests/history-multi-reader.test.ts +++ b/tests/history-multi-reader.test.ts @@ -3,201 +3,170 @@ 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, + drainGlobal, + drainProject, + globalSeedPath, projectHash, - registryPath, - sessionFilePath, + seedFilePath, } 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"; +const PROJECT_A = "/Users/admin/Dev/pi/pi-history"; +const PROJECT_B = "/Users/admin/Dev/github/pi"; function makeRoot(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-multi-reader-")); + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-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); +function writeLines( + file: string, + texts: string[], + opts?: { ts?: number }, +): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `${texts + .map((t) => JSON.stringify({ v: 1, text: t, ts: opts?.ts ?? 1000 })) + .join("\n")}\n`, + "utf8", + ); } -// --- parallel writers --- +function setMtime(file: string, ms: number): void { + fs.utimesSync(file, new Date(ms), new Date(ms)); +} -test("growth from a concurrent instance is visible on the next read", () => { +test("empty project dir drains nothing", () => { 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.deepEqual(drainProject(root, PROJECT_A), []); +}); + +test("single file drains newest-first (reverse of file order)", () => { + const root = makeRoot(); + writeLines(path.join(root, "projects", projectHash(PROJECT_A), "s1.jsonl"), [ + "old", + "mid", + "new", ]); - assert.equal(a.lineCount, 1); - assert.equal(b.lineCount, 1); + assert.deepEqual(drainProject(root, PROJECT_A), ["new", "mid", "old"]); +}); + +test("multiple files merge by file mtime, then newest-first inside", () => { + const root = makeRoot(); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + writeLines(path.join(dir, "older-session.jsonl"), ["a1", "a2"]); + writeLines(path.join(dir, "newer-session.jsonl"), ["b1", "b2"]); + setMtime(path.join(dir, "older-session.jsonl"), 1000); + setMtime(path.join(dir, "newer-session.jsonl"), 2000); + assert.deepEqual(drainProject(root, PROJECT_A), ["b2", "b1", "a2", "a1"]); }); -test("interleaved captures from multiple writers never clobber each other", () => { +test("duplicates across files keep only the newest occurrence", () => { 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", + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + writeLines(path.join(dir, "old.jsonl"), ["shared", "only-old"]); + writeLines(path.join(dir, "new.jsonl"), ["shared", "only-new"]); + setMtime(path.join(dir, "old.jsonl"), 1000); + setMtime(path.join(dir, "new.jsonl"), 2000); + assert.deepEqual(drainProject(root, PROJECT_A), [ + "only-new", + "shared", + "only-old", ]); - 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", () => { +test("case-insensitive identity: DUPLICATE matches duplicate", () => { 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); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + writeLines(path.join(dir, "old.jsonl"), ["duplicate"]); + writeLines(path.join(dir, "new.jsonl"), ["DUPLICATE"]); + setMtime(path.join(dir, "old.jsonl"), 1000); + setMtime(path.join(dir, "new.jsonl"), 2000); + const drained = drainProject(root, PROJECT_A); + assert.equal(drained.length, 1); + assert.equal(drained[0], "DUPLICATE"); }); -// --- 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("limit stops the drain early (newest kept)", () => { + const root = makeRoot(); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + const texts: string[] = []; + for (let i = 1; i <= 30; i++) texts.push(`p${i}`); + writeLines(path.join(dir, "s.jsonl"), texts); + const drained = drainProject(root, PROJECT_A, 5); + assert.deepEqual(drained, ["p30", "p29", "p28", "p27", "p26"]); +}); + +test("seed.jsonl participates as an ordinary source file", () => { + const root = makeRoot(); + writeLines(seedFilePath(root, PROJECT_A), ["seeded-old", "seeded-new"]); + setMtime(seedFilePath(root, PROJECT_A), 500); + const drained = drainProject(root, PROJECT_A); + assert.deepEqual(drained, ["seeded-new", "seeded-old"]); }); -test("a torn final line is tolerated: skipped by readers, later appends continue", () => { +test("malformed lines are skipped", () => { 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", + const file = path.join(root, "projects", projectHash(PROJECT_A), "s.jsonl"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + [ + JSON.stringify({ v: 1, text: "good" }), + "{torn", + JSON.stringify({ v: 1, text: "also-good" }), + "", + ].join("\n"), + "utf8", ); + assert.deepEqual(drainProject(root, PROJECT_A), ["also-good", "good"]); +}); + +// --- global drain --- + +test("global drain merges all projects newest-first with the legacy seed", () => { + const root = makeRoot(); + const dirA = path.join(root, "projects", projectHash(PROJECT_A)); + const dirB = path.join(root, "projects", projectHash(PROJECT_B)); + // Distinct entry ts values make the cross-project order explicit: + // fileSortKey keys on the newest entry ts, so equal-ts files would leave + // the order to directory enumeration (accidental, not asserted). + writeLines(path.join(dirA, "s1.jsonl"), ["a-oldest", "a-newest"], { + ts: 3000, + }); + writeLines(path.join(dirB, "s1.jsonl"), ["b-mid"], { ts: 2000 }); + writeLines(globalSeedPath(root), ["legacy-oldest"], { ts: 1000 }); + const drained = drainGlobal(root); + assert.deepEqual(drained, ["a-newest", "a-oldest", "b-mid", "legacy-oldest"]); }); -// --- atomic-write collisions --- +test("global drain dedupes across projects", () => { + const root = makeRoot(); + const dirA = path.join(root, "projects", projectHash(PROJECT_A)); + const dirB = path.join(root, "projects", projectHash(PROJECT_B)); + // Distinct entry ts: A must drain before B (see the merge test above). + writeLines(path.join(dirA, "s.jsonl"), ["shared-prompt"], { ts: 2000 }); + writeLines(path.join(dirB, "s.jsonl"), ["shared-prompt", "b-only"], { + ts: 1000, + }); + assert.deepEqual(drainGlobal(root), ["shared-prompt", "b-only"]); +}); -test("rapid same-target atomic writes leave one valid document and no staging files", () => { +test("growth from a concurrent instance is visible on the next drain", () => { 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, []); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + writeLines(path.join(dir, "s1.jsonl"), ["first"]); + assert.deepEqual(drainProject(root, PROJECT_A), ["first"]); + writeLines(path.join(dir, "s2.jsonl"), ["from-other-instance"]); + setMtime(path.join(dir, "s2.jsonl"), Date.now() + 5000); + assert.deepEqual(drainProject(root, PROJECT_A), [ + "from-other-instance", + "first", + ]); }); -test("interleaved registry updates from two instances keep both entries", () => { +test("global drain on a fresh root without a projects dir is empty", () => { 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); + assert.deepEqual(drainGlobal(root), []); }); diff --git a/tests/history-openflow-integration.test.ts b/tests/history-openflow-integration.test.ts index 1737f887a..37c654672 100644 --- a/tests/history-openflow-integration.test.ts +++ b/tests/history-openflow-integration.test.ts @@ -1,14 +1,14 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import fs from "node:fs"; import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; /** - * WU5 tests (AC-S6-1..3): the open-flow wiring in extensions/history/index.ts. - * NEVER import it — it pulls the pi-tui runtime graph (design §D3). The - * wiring is pinned by source-parse (command-registration pattern); loader - * behavior uses fs-only fixtures under the OS temp dir — NEVER the user's - * real ~/.pi/agent/history. + * WU5 tests (AC-S6-1..3): the open-flow wiring in src/index.ts. NEVER + * import src/index.ts — it pulls the pi-tui runtime graph (design §D3). + * The wiring is pinned by source-parse (command-registration pattern); the + * loader behavior uses fs-only fixtures under the OS temp dir — NEVER the */ const indexSource = fs.readFileSync( @@ -27,14 +27,14 @@ function openHistorySelectorBody(): string { /** Method body slice (lazy-windowing.test.ts pattern; first "\n }" close). */ function methodBodyOf(name: string): string { const decl = indexSource.indexOf(`private ${name}(`); - assert.ok(decl >= 0, `private ${name}() should exist in extensions/history/index.ts`); + assert.ok(decl >= 0, `private ${name}() should exist in src/index.ts`); const end = indexSource.indexOf("\n }", decl); assert.ok(end > decl, `private ${name}() body should close`); return indexSource.slice(decl, end); } // --------------------------------------------------------------------------- -// T31 — AC-S6-1: store-only drain wiring (source-parse, §I load-bearing shape). +// T31 — AC-S6-1: combined-loader wiring (source-parse, §I load-bearing shape). // --------------------------------------------------------------------------- test("T31 (AC-S6-1): the store drain is the entries source — no live transcript merge (§I pin 1)", () => { @@ -49,8 +49,8 @@ test("T31 (AC-S6-1): the store drain is the entries source — no live transcrip "the live transcript merge is GONE from the open flow (user-directed store-only scopes)", ); assert.ok( - body.indexOf("if (entries.length === 0)") >= 0, - "the PR-branch empty guard stands: no history warns instead of opening an empty overlay", + body.indexOf("if (entries.length === 0)") === -1, + "the empty guard is gone — the selector always opens", ); }); @@ -73,6 +73,11 @@ test("T31 (AC-S6-1): the three command-registration pins hold beside the swap", 2, "exactly the two entry-point call sites — the swap adds no occurrence", ); + const body = openHistorySelectorBody(); + assert.ok( + !body.includes('"No prompt history available."'), + "the warning string is removed from the shared entry point", + ); }); // --------------------------------------------------------------------------- @@ -125,7 +130,6 @@ test("T33 (AC-S6-3): loaded segment present, indexing segment removed", () => { "the indexing segment stays removed", ); }); - test("T33 (AC-S6-3): Change 2 structural pins still hold beside the third segment", () => { assert.ok( indexSource.includes("private static readonly OVERLAY_LINES = 30;"), @@ -139,5 +143,5 @@ test("T33 (AC-S6-3): Change 2 structural pins still hold beside the third segmen const ctorEnd = indexSource.indexOf('this.applyFilter("")', ctorAt); const ctorAddChild = indexSource.slice(ctorAt, ctorEnd).split("this.addChild(").length - 1; - assert.equal(ctorAddChild, 12, "the constructor child sequence is unchanged"); + assert.equal(ctorAddChild, 14, "the constructor child sequence is unchanged"); }); diff --git a/tests/history-overlay-margin.test.ts b/tests/history-overlay-margin.test.ts new file mode 100644 index 000000000..816229f32 --- /dev/null +++ b/tests/history-overlay-margin.test.ts @@ -0,0 +1,95 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + editorOverlayMargin, + SIDEBAR_OVERLAY_PADDING, + SIDEBAR_RAIL_OVERLAY_MARGIN, +} from "../extensions/history/selector-helpers.ts"; + +function terminalWithState(state: unknown): object { + return { + [Symbol.for("gentle-pi.experimental-sidebar.state")]: state, + } as object; +} + +test("margin constant pins the gentle-shell rail geometry (RAIL_WIDTH 50 + GAP 3)", () => { + assert.equal(SIDEBAR_RAIL_OVERLAY_MARGIN, 53); +}); + +test("padding constant pins the user-directed 1-column breathing room", () => { + assert.equal(SIDEBAR_OVERLAY_PADDING, 1); +}); + +test("returns 0 for absent, primitive, or null terminals", () => { + assert.equal(editorOverlayMargin(undefined), 0); + assert.equal(editorOverlayMargin(null), 0); + assert.equal(editorOverlayMargin(42), 0); + assert.equal(editorOverlayMargin("terminal"), 0); +}); + +test("returns 0 when no sidebar state is stored on the terminal", () => { + assert.equal(editorOverlayMargin({}), 0); +}); + +test("returns 0 for malformed state shapes", () => { + assert.equal(editorOverlayMargin(terminalWithState(undefined)), 0); + assert.equal(editorOverlayMargin(terminalWithState(null)), 0); + assert.equal(editorOverlayMargin(terminalWithState("active")), 0); +}); + +test("returns 0 unless active is exactly true AND ownsHost is a function", () => { + assert.equal( + editorOverlayMargin(terminalWithState({ active: true })), + 0, + "active without ownsHost", + ); + assert.equal( + editorOverlayMargin( + terminalWithState({ active: false, ownsHost: () => true }), + ), + 0, + "inactive", + ); + assert.equal( + editorOverlayMargin(terminalWithState({ active: 1, ownsHost: () => true })), + 0, + "non-boolean truthy active", + ); + assert.equal( + editorOverlayMargin( + terminalWithState({ active: true, ownsHost: "not-a-function" }), + ), + 0, + "non-function ownsHost", + ); +}); + +test("returns the geometry margin plus padding only while the sidebar owns the host", () => { + assert.equal( + editorOverlayMargin( + terminalWithState({ active: true, ownsHost: () => true }), + ), + 54, + ); + assert.equal( + editorOverlayMargin( + terminalWithState({ active: true, ownsHost: () => false }), + ), + 0, + "state present but host not owned (regular mode / unpatched root)", + ); +}); + +test("a throwing ownsHost degrades to 0 instead of breaking the picker", () => { + assert.equal( + editorOverlayMargin( + terminalWithState({ + active: true, + ownsHost: () => { + throw new Error("boom"); + }, + }), + ), + 0, + ); +}); diff --git a/tests/history-preview-layout.test.ts b/tests/history-preview-layout.test.ts index 02509725c..c0b4191aa 100644 --- a/tests/history-preview-layout.test.ts +++ b/tests/history-preview-layout.test.ts @@ -1,11 +1,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import fs from "node:fs"; import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; -const sourcePath = fileURLToPath( - new URL("../extensions/history/index.ts", import.meta.url), -); +const sourcePath = fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)); const source = fs.readFileSync(sourcePath, "utf8"); test("preview rows are bottom-padded so the panel shrinks from the bottom", () => { @@ -54,40 +53,3 @@ test("preview rows are bottom-padded so the panel shrinks from the bottom", () = "preview should not compute top padding", ); }); - -test("row padding measures visible width, stripping SGR escapes", () => { - // Colored list rows carry SGR escape sequences that occupy no terminal - // cells; padding must use the VISIBLE width or the row falls short of - // the overlay width and leaves ghost characters on dismiss. - const renderStart = source.indexOf(" render(width: number): string[] {"); - assert.notStrictEqual(renderStart, -1, "FixedRowText.render should exist"); - - const renderSource = source.slice(renderStart, renderStart + 2200); - const padLine = renderSource - .split("\n") - .find((l) => l.includes('" ".repeat(Math.max(0, width -')); - assert.ok(padLine !== undefined, "final full-width pad should exist"); - assert.ok( - padLine.includes("visible"), - "pad must measure the SGR-stripped visible width, not rendered.length", - ); - assert.ok( - /visible = rendered\.replace\(/.test(renderSource), - "visible width must be derived by stripping escape sequences", - ); -}); - -test("sanitizeForDisplay appends the full astral code point, not a lone surrogate", () => { - const fnStart = source.indexOf("function sanitizeForDisplay("); - assert.notStrictEqual(fnStart, -1, "sanitizeForDisplay should exist"); - - const fnSource = source.slice(fnStart, fnStart + 1200); - assert.ok( - fnSource.includes("String.fromCodePoint(cp)"), - "astral code points must be re-appended whole (emoji survive)", - ); - assert.ok( - fnSource.includes("if (cp > 0xffff) i++"), - "the low surrogate of the pair must still be skipped", - ); -}); diff --git a/tests/history-registry.test.ts b/tests/history-registry.test.ts index 23235f8cd..7dc6aa0b3 100644 --- a/tests/history-registry.test.ts +++ b/tests/history-registry.test.ts @@ -1,68 +1,63 @@ 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, + lookupCwd, 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", + const result = ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); + assert.deepEqual(result, { hash: "28e0f06819c468cb", created: true }); + ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); + const raw = JSON.parse( + fs.readFileSync(path.join(root, "registry.json"), "utf8"), + ); + assert.deepEqual(raw, { + "28e0f06819c468cb": "/Users/admin/Dev/pi/pi-history", }); }); 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"); + ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); + const b = ensureRegistryEntry(root, "/Users/admin/Dev/github/pi"); assert.equal(b.created, true); - const raw = readRegistryFile(root); + const raw = JSON.parse( + fs.readFileSync(path.join(root, "registry.json"), "utf8"), + ); assert.equal(Object.keys(raw).length, 2); - assert.equal(raw[b.hash], "/pi-history-test/project-b"); + assert.equal(raw[b.hash], "/Users/admin/Dev/github/pi"); }); -test("registry.json maps known hashes and omits unknown ones", () => { +test("lookupCwd resolves known hashes and null for unknown", () => { 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); + const { hash } = ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); + assert.equal(lookupCwd(root, hash), "/Users/admin/Dev/pi/pi-history"); + assert.equal(lookupCwd(root, "0000000000000000"), null); + assert.equal(lookupCwd(makeRoot(), hash), null); }); 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"); + fs.writeFileSync(path.join(root, "registry.json"), "{not-json", "utf8"); + assert.equal(lookupCwd(root, "28e0f06819c468cb"), null); + const result = ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); 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", + const raw = JSON.parse( + fs.readFileSync(path.join(root, "registry.json"), "utf8"), + ); + assert.deepEqual(raw, { + "28e0f06819c468cb": "/Users/admin/Dev/pi/pi-history", }); }); @@ -76,7 +71,7 @@ test("no leftover tmp files after writes", () => { 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 cwd = "/Users/admin/Dev/pi/pi-history"; const hash = projectHash(cwd); // Simulate a collision: the short hash is pre-mapped to a different cwd. fs.mkdirSync(root, { recursive: true }); @@ -87,57 +82,30 @@ test("hash collision re-keys the existing occupant; the new cwd keeps the short ); const result = ensureRegistryEntry(root, cwd); assert.deepEqual(result, { hash, created: true }); - const raw = readRegistryFile(root); + const raw = JSON.parse(fs.readFileSync(registryPath(root), "utf8")); 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); + assert.equal(lookupCwd(root, hash), cwd); + assert.equal(lookupCwd(root, longKeys[0]), "/some/other/project"); }); 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. + // empty registry — lookups fail open to null, 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"; + const cwd = "/Users/admin/Dev/pi/pi-history"; for (const shape of shapes) { const root = makeRoot(); fs.writeFileSync(registryPath(root), JSON.stringify(shape), "utf8"); + assert.equal(lookupCwd(root, projectHash(cwd)), null); const result = ensureRegistryEntry(root, cwd); assert.deepEqual(result, { hash: projectHash(cwd), created: true }); - assert.deepEqual(readRegistryFile(root), { [projectHash(cwd)]: cwd }); + const raw = JSON.parse( + fs.readFileSync(path.join(root, "registry.json"), "utf8"), + ); + assert.deepEqual(raw, { [projectHash(cwd)]: cwd }); } }); diff --git a/tests/history-scope-delete.test.ts b/tests/history-scope-delete.test.ts index 11c50f2da..66e5c072b 100644 --- a/tests/history-scope-delete.test.ts +++ b/tests/history-scope-delete.test.ts @@ -4,20 +4,20 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { - appendSessionCapture, deleteFromGlobal, deleteFromProject, globalSeedPath, - openSessionWriter, projectHash, } from "../extensions/history/store.ts"; +// node:test has no test.skipIf (Bun-ism): emulate via the options object. +const skipIf = + (condition: unknown) => + (name: string, fn: () => unknown) => + test(name, { skip: condition ? "requires non-root" : false }, fn); -// Scope delete (design v2): sweepFiles' atomic per-file rewrite semantics -// plus the project/global delete entry points. Synthetic project cwds — -// never real directories on any machine (identity only feeds projectHash; -// the fixtures live in tmpdirs and never touch the user's real ~/.pi). -const PROJECT_A = "/fixtures/pi-history/project-a"; -const PROJECT_B = "/fixtures/pi-history/project-b"; + +const PROJECT_A = "/Users/admin/Dev/pi/pi-history"; +const PROJECT_B = "/Users/admin/Dev/github/pi"; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-del-")); @@ -106,10 +106,10 @@ test("delete leaves no tmp files behind", () => { assert.deepEqual(leftovers, []); }); -// node:test has no test.skipIf (Bun-ism): root skips via the options object. -test( +const sealedFileTest = skipIf(process.getuid?.() === 0); + +sealedFileTest( "an unreadable store file (chmod 000) is skipped; readable copies still swept", - { skip: process.getuid?.() === 0 ? "requires non-root" : false }, () => { const root = makeRoot(); const dir = path.join(root, "projects", projectHash(PROJECT_A)); @@ -140,24 +140,3 @@ test("a file whose every line is deleted becomes empty (kept, not removed)", () assert.equal(fs.existsSync(file), true); assert.equal(fs.readFileSync(file, "utf8"), ""); }); - -// Active-writer safety (design v2): the sweep rewrites the writer's own -// file IN PLACE (tmp + rename, never a removal — emptied files are kept), -// so a concurrently live writer keeps working by path: its next capture -// appends into the swept file, and the surviving + new lines parse fine. -test("a sweep with a concurrent live writer keeps the writer's file functional", () => { - const root = makeRoot(); - const state = openSessionWriter(root, PROJECT_A, "instance-1"); - appendSessionCapture(state, "victim"); - appendSessionCapture(state, "keeper"); - - const result = deleteFromProject(root, PROJECT_A, "victim"); - assert.deepEqual(result, { filesAffected: 1, removed: 1 }); - - // The same writer state keeps appending after the sweep — the file was - // rewritten under the writer's feet, not removed. - appendSessionCapture(state, "after-delete"); - assert.equal(state.lineCount, 3); - assert.equal(fs.existsSync(state.filePath), true); - assert.deepEqual(fileTexts(state.filePath), ["keeper", "after-delete"]); -}); diff --git a/tests/history-seed-bootstrap.test.ts b/tests/history-seed-bootstrap.test.ts index d8126f254..24ca210cf 100644 --- a/tests/history-seed-bootstrap.test.ts +++ b/tests/history-seed-bootstrap.test.ts @@ -8,12 +8,14 @@ import { projectHash, seedFilePath, } from "../extensions/history/store.ts"; +// node:test has no test.skipIf (Bun-ism): emulate via the options object. +const skipIf = + (condition: unknown) => + (name: string, fn: () => unknown) => + test(name, { skip: condition ? "requires non-root" : false }, fn); -// 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--"; + +const CWD = "/Users/admin/Dev/pi/pi-history"; function makeDirs(): { root: string; sessionsRoot: string } { const base = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-seed-")); @@ -78,7 +80,7 @@ test("no sessions and no project dir: bootstrap seeds nothing", () => { test("empty project dir bootstraps from the project's transcripts", () => { const { root, sessionsRoot } = makeDirs(); - writeSession(sessionsRoot, DIR, "s1.jsonl", [ + writeSession(sessionsRoot, `--Users-admin-Dev-pi-pi-history--`, "s1.jsonl", [ "real prompt", "/compact", " ", @@ -92,7 +94,9 @@ test("empty project dir bootstraps from the project's transcripts", () => { test("only the project's own session dir is scanned", () => { const { root, sessionsRoot } = makeDirs(); - writeSession(sessionsRoot, DIR, "s1.jsonl", ["mine"]); + writeSession(sessionsRoot, `--Users-admin-Dev-pi-pi-history--`, "s1.jsonl", [ + "mine", + ]); writeSession(sessionsRoot, "--Other--", "s2.jsonl", ["not mine"]); bootstrapProjectSeed(root, CWD, sessionsRoot, 500); assert.deepEqual(seedTexts(root), ["mine"]); @@ -102,7 +106,12 @@ 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); + writeSession( + sessionsRoot, + `--Users-admin-Dev-pi-pi-history--`, + "big.jsonl", + texts, + ); const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); assert.deepEqual(result, { seeded: 500, ran: true }); const all = seedTexts(root); @@ -123,7 +132,12 @@ test("project dir already populated above target: no scan, seed untouched", () = ).join("\n")}\n`, "utf8", ); - const marker = writeSession(sessionsRoot, DIR, "s.jsonl", ["marker"]); + const marker = writeSession( + sessionsRoot, + `--Users-admin-Dev-pi-pi-history--`, + "s.jsonl", + ["marker"], + ); fs.utimesSync( marker, new Date(Date.now() + 5000), @@ -135,14 +149,7 @@ test("project dir already populated above target: no scan, seed untouched", () = 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, - ); +const sealedStoreTest = skipIf(process.getuid?.() === 0); sealedStoreTest( "an unreadable existing store file is skipped during counting; seeding still runs from transcripts", () => { @@ -156,7 +163,12 @@ sealedStoreTest( "utf8", ); fs.chmodSync(sealed, 0o000); - writeSession(sessionsRoot, DIR, "s1.jsonl", ["from transcript"]); + writeSession( + sessionsRoot, + `--Users-admin-Dev-pi-pi-history--`, + "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. diff --git a/tests/history-seed-regen.test.ts b/tests/history-seed-regen.test.ts index e4e16539e..289884f4c 100644 --- a/tests/history-seed-regen.test.ts +++ b/tests/history-seed-regen.test.ts @@ -5,11 +5,8 @@ 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--"; +const CWD = "/Users/admin/Dev/pi/pi-history"; +const DIR = `--Users-admin-Dev-pi-pi-history--`; function setup() { const base = fs.mkdtempSync(path.join(os.tmpdir(), "seed2-")); diff --git a/tests/history-session-scan-directory.test.ts b/tests/history-session-scan-directory.test.ts index d868b5073..8f72698b4 100644 --- a/tests/history-session-scan-directory.test.ts +++ b/tests/history-session-scan-directory.test.ts @@ -4,6 +4,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { listSessionFiles } from "../extensions/history/session-scan.ts"; +// node:test has no test.skipIf (Bun-ism): emulate via the options object. +const skipIf = + (condition: unknown) => + (name: string, fn: () => unknown) => + test(name, { skip: condition ? "requires non-root" : false }, fn); + /** * WU1b-carried T7 (AC-S1-7): the one-level directory exclusion matrix. The @@ -57,14 +63,7 @@ test("one-level scan rule: only top-level jsonl of cwd dirs; nested payloads, su } }); -// 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, - ); +const sealedDirTest = skipIf(process.getuid?.() === 0); sealedDirTest( "an unreadable child dir (chmod 000) is skipped; sibling dirs still list", () => { diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index e384379d6..e0fbda75b 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -5,17 +5,15 @@ import os from "node:os"; import path from "node:path"; import { appendSessionCapture, - openSessionWriter, projectHash, sessionFilePath, } from "../extensions/history/store.ts"; -import promptHistoryExtension 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"; +const CWD = "/Users/admin/Dev/pi/pi-history"; function fileTexts(file: string): string[] { return fs @@ -25,10 +23,6 @@ function fileTexts(file: string): string[] { .map((l) => (JSON.parse(l) as { text: string }).text); } -function openWriterForTest(root: string, instanceId: string) { - return openSessionWriter(root, CWD, instanceId); -} - test("no file is created until the first capture", () => { const root = makeRoot(); const state = openWriterForTest(root, "sess-1"); @@ -88,36 +82,9 @@ 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 exactly the final wiring surface", () => { - // Module load must stay side-effect free (importing index.ts parses the - // whole graph without touching the real ~/.pi store root). Wiring as of - // slice 6 (final): before_agent_start capture, session_shutdown GC, - // tool_call overlay dismiss, the ctrl+shift+r shortcut, and the - // history command. - 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: (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", "session_shutdown", "tool_call"], - ); - 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"); - } -}); +// Helper kept local: openWriter is the U3 surface under test. +import { openSessionWriter } from "../extensions/history/store.ts"; + +function openWriterForTest(root: string, instanceId: string) { + return openSessionWriter(root, CWD, instanceId); +} diff --git a/tests/history-store-paths.test.ts b/tests/history-store-paths.test.ts index a6e5be45a..431fe43b8 100644 --- a/tests/history-store-paths.test.ts +++ b/tests/history-store-paths.test.ts @@ -15,23 +15,21 @@ import { 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"); + const hash = projectHash("/Users/admin/Dev/pi/pi-history"); 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", + projectHash("/Users/admin/Dev/pi/pi-history"), + "28e0f06819c468cb", ); }); test("distinct paths produce distinct hashes", () => { assert.notEqual( - projectHash("/pi-history-test/project-a"), - projectHash("/pi-history-test/project-b"), + projectHash("/Users/admin/Dev/pi/pi-history"), + projectHash("/Users/admin/Dev/github/pi"), ); }); @@ -56,7 +54,7 @@ test("nonexistent path falls back to hashing the raw string (no throw)", () => { }); test("path derivations compose under the root", () => { - const cwd = "/pi-history-test/project-a"; + const cwd = "/Users/admin/Dev/pi/pi-history"; const hash = projectHash(cwd); assert.equal(projectDir(ROOT, cwd), path.join(ROOT, "projects", hash)); assert.equal( @@ -72,8 +70,8 @@ test("path derivations compose under the root", () => { }); 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"); + const a = projectDir(ROOT, "/Users/admin/Dev/pi/pi-history"); + const b = projectDir(ROOT, "/Users/admin/Dev/github/pi"); assert.notEqual(a, b); assert.equal(path.dirname(a), path.dirname(b)); }); diff --git a/tests/history-wheel-mouse.test.ts b/tests/history-wheel-mouse.test.ts index fbb4a33b7..5bae67d79 100644 --- a/tests/history-wheel-mouse.test.ts +++ b/tests/history-wheel-mouse.test.ts @@ -1,23 +1,24 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import fs from "node:fs"; import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; // Unit 4 — L6 wheel slice (spec C5, design §D6). // -// Source-parse structural pins on extensions/history/index.ts (no pi-tui -// runtime graph — the same discipline as the other source-parse suites). -// The overlay renders only through pi-tui, so the unit-level contract is the -// SHAPE of the handleMouse override: +// Source-parse structural pins on src/index.ts (no pi-tui runtime graph — +// the same discipline as the other source-parse suites). The overlay renders +// only through pi-tui, so the unit-level contract is the SHAPE of the +// handleMouse override: // // - wheel-only: every non-wheel event type returns undefined (press/click/ -// drag stay host-owned) and the dispatch table gains no extra entry (wheel -// is not a keybinding — dispatch.test.ts remains the authoritative +// drag stay host-owned) and the 12-entry dispatch table gains no 13th entry +// (wheel is not a keybinding — dispatch.test.ts remains the authoritative // untouched pin); // - ONE consumed wheel return: `handled: true` plus the synthetic target // enrichment, reached by every wheel path including the no-op regions — // this closes the pre-existing fullscreen SGR-fallthrough hazard by -// construction; +// construction (see tmp/c2u4-qa-prechange-record.md); // - fixed 30-row geometry routing: list region y 5–14, preview region y 17–26, // all other rows consumed no-ops; // - list wheel: sign × |wheelDelta| steps through moveDown (the arrow grow @@ -32,7 +33,7 @@ const selectorSource = fs.readFileSync( "utf8", ); -// T13 — AC-L6-1: wheel-only override + no extra dispatch entry. +// T13 — AC-L6-1: wheel-only override + no 13th dispatch entry. test("handleMouse override is wheel-only and the dispatch table keeps 12 entries (AC-L6-1)", () => { const decl = selectorSource.indexOf("override handleMouse("); @@ -142,7 +143,7 @@ test("region constants 5-14 / 17-26 route the y comparisons (AC-L6-3)", () => { const body = selectorSource.slice(decl, end); assert.ok( - body.includes("event.y >= LIST_WHEEL_Y_FIRST") && + body.includes("event.y >= this.listWheelFirstRow") && body.includes("event.y <= LIST_WHEEL_Y_LAST"), "the list branch must compare y against the list band", ); @@ -169,7 +170,7 @@ test("list wheel routes sign-clamped steps through moveDown/moveUp (AC-L6-4)", ( "delta must default an absent wheelDelta to 0", ); - const listStart = body.indexOf("if (event.y >= LIST_WHEEL_Y_FIRST"); + const listStart = body.indexOf("if (event.y >= this.listWheelFirstRow"); const listEnd = body.indexOf("} else if (", listStart); assert.ok( listStart >= 0 && listEnd > listStart, From e5746ec9bf8dd84d215781e4acdded24d8af86fa Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:50:54 -0300 Subject: [PATCH 04/13] refactor(history): extract shared header counts helper Sync of pi-history a1c13b9: headerCountsText() now serves the inline and stacked header branches; no behavior change. --- extensions/history/index.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 83fa91ab8..15e4f7181 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -427,6 +427,19 @@ class PromptHistorySelector extends Container implements Focusable { this.rebuildListWithWidth(this.lastWidth); } + /** Styled title + position + loaded-counts prefix shared by the inline and stacked header layouts. */ + private headerCountsText( + titleText: string, + positionText: string, + loadedText: string, + ): string { + return ( + this.theme.fg("accent", this.theme.bold(titleText)) + + this.theme.fg("dim", positionText) + + this.theme.fg("dim", loadedText) + ); + } + /** Rebuild list rows: header counter + entries. Always MAX_VISIBLE rows (MAX_VISIBLE - 1 in compact mode). */ private rebuildListWithWidth(width: number): void { const count = this.filteredRecords.length; @@ -450,9 +463,7 @@ class PromptHistorySelector extends Container implements Focusable { this.headerMode = mode; if (mode === "inline") { this.headerRow.setText( - this.theme.fg("accent", this.theme.bold(titleText)) + - this.theme.fg("dim", positionText) + - this.theme.fg("dim", loadedText) + + this.headerCountsText(titleText, positionText, loadedText) + // Right-aligned scope radio: pad from plain-text lengths so the // radio ends flush at the header's last column at any width. " ".repeat(Math.max(1, width - leftWidth - radioText.length)) + @@ -464,9 +475,7 @@ class PromptHistorySelector extends Container implements Focusable { // Tablet: the spacer is deleted — the radio wraps to its own row // under the full counts line (user-directed paste, leading space). this.headerRow.setText( - this.theme.fg("accent", this.theme.bold(titleText)) + - this.theme.fg("dim", positionText) + - this.theme.fg("dim", loadedText), + this.headerCountsText(titleText, positionText, loadedText), ); this.headerLine2.setText(` ${this.theme.fg("dim", radioText)}`); this.headerLine3.setText(""); From fdef2fc2cf3c4a6766395ca821754b41bc172bf8 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:06:18 -0300 Subject: [PATCH 05/13] fix(history): satisfy upstream typecheck gate - import ExtensionCommandContext (the real pi-coding-agent export) instead of the shim-only ShortcutContext name; ctx params take Pick - skipIf shim casts its callback to TestFn's return type so node's test options overload typechecks (6 sealed-file test files) --- extensions/history/index.ts | 6 +++--- tests/history-drain-order.test.ts | 6 +++++- tests/history-gc.test.ts | 6 +++++- tests/history-legacy-migrate-v2.test.ts | 6 +++++- tests/history-scope-delete.test.ts | 6 +++++- tests/history-seed-bootstrap.test.ts | 6 +++++- tests/history-session-scan-directory.test.ts | 6 +++++- 7 files changed, 33 insertions(+), 9 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 15e4f7181..cf1ff0036 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -7,7 +7,7 @@ import { join } from "node:path"; import { DynamicBorder, type ExtensionAPI, - type ShortcutContext, + type ExtensionCommandContext, type Theme, } from "@earendil-works/pi-coding-agent"; import { @@ -949,7 +949,7 @@ function createPromptHistorySelectorFactory( } async function runPromptHistorySelection( - ctx: ShortcutContext, + ctx: Pick, records: PromptRecord[], ): Promise { const historyGlobals: PiHistoryGlobals = globalThis as Record< @@ -1050,7 +1050,7 @@ function drainForScope(scope: HistoryScope): string[] { } async function openHistorySelector( - ctx: Pick, + ctx: Pick, ): Promise { // Store-only drain (user-directed): both scopes read the store files // symmetrically — no live transcript merge (the one-time seed bootstrap diff --git a/tests/history-drain-order.test.ts b/tests/history-drain-order.test.ts index 0047dc4ed..e6d877066 100644 --- a/tests/history-drain-order.test.ts +++ b/tests/history-drain-order.test.ts @@ -14,7 +14,11 @@ import { const skipIf = (condition: unknown) => (name: string, fn: () => unknown) => - test(name, { skip: condition ? "requires non-root" : false }, fn); + test( + name, + { skip: condition ? "requires non-root" : false }, + fn as () => void | Promise, + ); const CWD = "/Users/admin/Dev/pi/pi-history"; diff --git a/tests/history-gc.test.ts b/tests/history-gc.test.ts index ebd04c1e0..50064dbd9 100644 --- a/tests/history-gc.test.ts +++ b/tests/history-gc.test.ts @@ -12,7 +12,11 @@ import { const skipIf = (condition: unknown) => (name: string, fn: () => unknown) => - test(name, { skip: condition ? "requires non-root" : false }, fn); + test( + name, + { skip: condition ? "requires non-root" : false }, + fn as () => void | Promise, + ); const CWD = "/Users/admin/Dev/pi/pi-history"; diff --git a/tests/history-legacy-migrate-v2.test.ts b/tests/history-legacy-migrate-v2.test.ts index 38e4ba139..12b8a6d91 100644 --- a/tests/history-legacy-migrate-v2.test.ts +++ b/tests/history-legacy-migrate-v2.test.ts @@ -8,7 +8,11 @@ import { globalSeedPath, migrateLegacyStores } from "../extensions/history/store const skipIf = (condition: unknown) => (name: string, fn: () => unknown) => - test(name, { skip: condition ? "requires non-root" : false }, fn); + test( + name, + { skip: condition ? "requires non-root" : false }, + fn as () => void | Promise, + ); function makeDirs(): { root: string; agentDir: string } { diff --git a/tests/history-scope-delete.test.ts b/tests/history-scope-delete.test.ts index 66e5c072b..1f9546568 100644 --- a/tests/history-scope-delete.test.ts +++ b/tests/history-scope-delete.test.ts @@ -13,7 +13,11 @@ import { const skipIf = (condition: unknown) => (name: string, fn: () => unknown) => - test(name, { skip: condition ? "requires non-root" : false }, fn); + test( + name, + { skip: condition ? "requires non-root" : false }, + fn as () => void | Promise, + ); const PROJECT_A = "/Users/admin/Dev/pi/pi-history"; diff --git a/tests/history-seed-bootstrap.test.ts b/tests/history-seed-bootstrap.test.ts index 24ca210cf..f8e42d197 100644 --- a/tests/history-seed-bootstrap.test.ts +++ b/tests/history-seed-bootstrap.test.ts @@ -12,7 +12,11 @@ import { const skipIf = (condition: unknown) => (name: string, fn: () => unknown) => - test(name, { skip: condition ? "requires non-root" : false }, fn); + test( + name, + { skip: condition ? "requires non-root" : false }, + fn as () => void | Promise, + ); const CWD = "/Users/admin/Dev/pi/pi-history"; diff --git a/tests/history-session-scan-directory.test.ts b/tests/history-session-scan-directory.test.ts index 8f72698b4..01db61258 100644 --- a/tests/history-session-scan-directory.test.ts +++ b/tests/history-session-scan-directory.test.ts @@ -8,7 +8,11 @@ import { listSessionFiles } from "../extensions/history/session-scan.ts"; const skipIf = (condition: unknown) => (name: string, fn: () => unknown) => - test(name, { skip: condition ? "requires non-root" : false }, fn); + test( + name, + { skip: condition ? "requires non-root" : false }, + fn as () => void | Promise, + ); /** From 26bd9b536a9472537e70982d93229826ec7e2d90 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:42:17 -0300 Subject: [PATCH 06/13] test(history): remove machine-specific fixed paths from test fixtures - replace hardcoded /Users/admin/Dev/pi/pi-history and /Users/admin/Dev/github/pi constants with synthetic /pi-history-fixtures/project-a|project-b literals - pin projectHash with a portable known vector (nonexistent path falls back to raw-string hashing), replacing the layout-dependent 28e0f06819c468cb digest - registry tests use mkdtemp project fixtures with relative assertions - update the 6 session-slug literals coupled to the old cwd constant - replace bun:test-only test.skipIf wrappers with node:test-compatible skip-option wrappers in gc/scope-delete/drain-order/seed-bootstrap - bring gc/scope-delete/drain-order/seed-bootstrap in line with the newer reviewed pi-history test versions (drift since the slices were cut) Verified: node --test tests/history-*.test.ts 188 pass / 0 fail Review: review-134310abd04cf6c9 (reliability lens, approved) --- tests/history-drain-hidden.test.ts | 2 +- tests/history-drain-order.test.ts | 16 +++-------- tests/history-gc.test.ts | 16 +++-------- tests/history-multi-reader.test.ts | 4 +-- tests/history-registry.test.ts | 41 ++++++++++++++++------------ tests/history-scope-delete.test.ts | 18 ++++-------- tests/history-seed-bootstrap.test.ts | 26 ++++++------------ tests/history-seed-regen.test.ts | 4 +-- tests/history-session-writer.test.ts | 2 +- tests/history-store-paths.test.ts | 28 +++++++++++-------- 10 files changed, 68 insertions(+), 89 deletions(-) diff --git a/tests/history-drain-hidden.test.ts b/tests/history-drain-hidden.test.ts index 58ac72064..6c37149a1 100644 --- a/tests/history-drain-hidden.test.ts +++ b/tests/history-drain-hidden.test.ts @@ -10,7 +10,7 @@ import { projectHash, } from "../extensions/history/store.ts"; -const CWD = "/Users/admin/Dev/pi/pi-history"; +const CWD = "/pi-history-fixtures/project-a"; function write(file: string, texts: string[], ts = 100): void { fs.mkdirSync(path.dirname(file), { recursive: true }); diff --git a/tests/history-drain-order.test.ts b/tests/history-drain-order.test.ts index e6d877066..f5430dc80 100644 --- a/tests/history-drain-order.test.ts +++ b/tests/history-drain-order.test.ts @@ -10,18 +10,8 @@ import { globalSeedPath, projectHash, } from "../extensions/history/store.ts"; -// node:test has no test.skipIf (Bun-ism): emulate via the options object. -const skipIf = - (condition: unknown) => - (name: string, fn: () => unknown) => - test( - name, - { skip: condition ? "requires non-root" : false }, - fn as () => void | Promise, - ); - -const CWD = "/Users/admin/Dev/pi/pi-history"; +const CWD = "/pi-history-fixtures/project-a"; function writeTs(file: string, texts: string[], ts: number): void { fs.mkdirSync(path.dirname(file), { recursive: true }); @@ -61,7 +51,9 @@ test("global drain puts the legacy seed last regardless of its fresh mtime", () assert.deepEqual(drainGlobal(root), ["fresh", "legacy-2", "legacy-1"]); }); -const sealedDrainTest = skipIf(process.getuid?.() === 0); +const isRoot = process.getuid?.() === 0; +const sealedDrainTest = (name: string, fn: () => unknown) => + test(name, { skip: isRoot && "requires a non-root user" }, fn); sealedDrainTest( "an unreadable store file is skipped; the rest drain in the expected order", () => { diff --git a/tests/history-gc.test.ts b/tests/history-gc.test.ts index 50064dbd9..2256977f2 100644 --- a/tests/history-gc.test.ts +++ b/tests/history-gc.test.ts @@ -8,18 +8,8 @@ import { gcProjectDir, projectHash, } from "../extensions/history/store.ts"; -// node:test has no test.skipIf (Bun-ism): emulate via the options object. -const skipIf = - (condition: unknown) => - (name: string, fn: () => unknown) => - test( - name, - { skip: condition ? "requires non-root" : false }, - fn as () => void | Promise, - ); - -const CWD = "/Users/admin/Dev/pi/pi-history"; +const CWD = "/pi-history-fixtures/project-a"; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-gc-")); @@ -143,7 +133,9 @@ test("compaction keeps the newest 10 files, merges the rest", () => { assert.equal(names.includes("h06.jsonl"), true); }); -const sealedGcTest = skipIf(process.getuid?.() === 0); +const isRoot = process.getuid?.() === 0; +const sealedGcTest = (name: string, fn: () => unknown) => + test(name, { skip: isRoot && "requires a non-root user" }, fn); sealedGcTest( "compactProjectDir skips an unreadable file's content and compacts the readable entries", () => { diff --git a/tests/history-multi-reader.test.ts b/tests/history-multi-reader.test.ts index 0c9434ab8..64b4fc4c2 100644 --- a/tests/history-multi-reader.test.ts +++ b/tests/history-multi-reader.test.ts @@ -11,8 +11,8 @@ import { seedFilePath, } from "../extensions/history/store.ts"; -const PROJECT_A = "/Users/admin/Dev/pi/pi-history"; -const PROJECT_B = "/Users/admin/Dev/github/pi"; +const PROJECT_A = "/pi-history-fixtures/project-a"; +const PROJECT_B = "/pi-history-fixtures/project-b"; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-reader-")); diff --git a/tests/history-registry.test.ts b/tests/history-registry.test.ts index 7dc6aa0b3..7ca59d94f 100644 --- a/tests/history-registry.test.ts +++ b/tests/history-registry.test.ts @@ -14,35 +14,41 @@ function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-registry-")); } +function makeProject(prefix = "pi-history-registry-proj-"): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + test("creates the registry with the first entry (idempotent)", () => { const root = makeRoot(); - const result = ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); - assert.deepEqual(result, { hash: "28e0f06819c468cb", created: true }); - ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); + const cwd = makeProject(); + const result = ensureRegistryEntry(root, cwd); + assert.deepEqual(result, { hash: projectHash(cwd), created: true }); + ensureRegistryEntry(root, cwd); const raw = JSON.parse( fs.readFileSync(path.join(root, "registry.json"), "utf8"), ); - assert.deepEqual(raw, { - "28e0f06819c468cb": "/Users/admin/Dev/pi/pi-history", - }); + assert.deepEqual(raw, { [projectHash(cwd)]: cwd }); }); test("second project appends without touching the first", () => { const root = makeRoot(); - ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); - const b = ensureRegistryEntry(root, "/Users/admin/Dev/github/pi"); + const cwdA = makeProject(); + const cwdB = makeProject(); + ensureRegistryEntry(root, cwdA); + const b = ensureRegistryEntry(root, cwdB); assert.equal(b.created, true); const raw = JSON.parse( fs.readFileSync(path.join(root, "registry.json"), "utf8"), ); assert.equal(Object.keys(raw).length, 2); - assert.equal(raw[b.hash], "/Users/admin/Dev/github/pi"); + assert.equal(raw[b.hash], cwdB); }); test("lookupCwd resolves known hashes and null for unknown", () => { const root = makeRoot(); - const { hash } = ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); - assert.equal(lookupCwd(root, hash), "/Users/admin/Dev/pi/pi-history"); + const cwd = makeProject(); + const { hash } = ensureRegistryEntry(root, cwd); + assert.equal(lookupCwd(root, hash), cwd); assert.equal(lookupCwd(root, "0000000000000000"), null); assert.equal(lookupCwd(makeRoot(), hash), null); }); @@ -50,15 +56,14 @@ test("lookupCwd resolves known hashes and null for unknown", () => { test("corrupt registry json is treated as empty and rebuilt on next entry", () => { const root = makeRoot(); fs.writeFileSync(path.join(root, "registry.json"), "{not-json", "utf8"); - assert.equal(lookupCwd(root, "28e0f06819c468cb"), null); - const result = ensureRegistryEntry(root, "/Users/admin/Dev/pi/pi-history"); + assert.equal(lookupCwd(root, "0000000000000000"), null); + const cwd = makeProject(); + const result = ensureRegistryEntry(root, cwd); assert.equal(result.created, true); const raw = JSON.parse( fs.readFileSync(path.join(root, "registry.json"), "utf8"), ); - assert.deepEqual(raw, { - "28e0f06819c468cb": "/Users/admin/Dev/pi/pi-history", - }); + assert.deepEqual(raw, { [projectHash(cwd)]: cwd }); }); test("no leftover tmp files after writes", () => { @@ -71,7 +76,7 @@ test("no leftover tmp files after writes", () => { test("hash collision re-keys the existing occupant; the new cwd keeps the short hash", () => { const root = makeRoot(); - const cwd = "/Users/admin/Dev/pi/pi-history"; + const cwd = makeProject(); const hash = projectHash(cwd); // Simulate a collision: the short hash is pre-mapped to a different cwd. fs.mkdirSync(root, { recursive: true }); @@ -96,7 +101,7 @@ test("wrong-shaped registry (array / scalar / null) fails open and is rebuilt on // empty registry — lookups fail open to null, and the next entry rebuilds // a valid object-mapped registry around itself. const shapes: unknown[] = [["an", "array"], "scalar-string", null]; - const cwd = "/Users/admin/Dev/pi/pi-history"; + const cwd = makeProject(); for (const shape of shapes) { const root = makeRoot(); fs.writeFileSync(registryPath(root), JSON.stringify(shape), "utf8"); diff --git a/tests/history-scope-delete.test.ts b/tests/history-scope-delete.test.ts index 1f9546568..9480efd8f 100644 --- a/tests/history-scope-delete.test.ts +++ b/tests/history-scope-delete.test.ts @@ -9,19 +9,9 @@ import { globalSeedPath, projectHash, } from "../extensions/history/store.ts"; -// node:test has no test.skipIf (Bun-ism): emulate via the options object. -const skipIf = - (condition: unknown) => - (name: string, fn: () => unknown) => - test( - name, - { skip: condition ? "requires non-root" : false }, - fn as () => void | Promise, - ); - -const PROJECT_A = "/Users/admin/Dev/pi/pi-history"; -const PROJECT_B = "/Users/admin/Dev/github/pi"; +const PROJECT_A = "/pi-history-fixtures/project-a"; +const PROJECT_B = "/pi-history-fixtures/project-b"; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-del-")); @@ -110,7 +100,9 @@ test("delete leaves no tmp files behind", () => { assert.deepEqual(leftovers, []); }); -const sealedFileTest = skipIf(process.getuid?.() === 0); +const isRoot = process.getuid?.() === 0; +const sealedFileTest = (name: string, fn: () => unknown) => + test(name, { skip: isRoot && "requires a non-root user" }, fn); sealedFileTest( "an unreadable store file (chmod 000) is skipped; readable copies still swept", diff --git a/tests/history-seed-bootstrap.test.ts b/tests/history-seed-bootstrap.test.ts index f8e42d197..fcbfe64c8 100644 --- a/tests/history-seed-bootstrap.test.ts +++ b/tests/history-seed-bootstrap.test.ts @@ -8,18 +8,8 @@ import { projectHash, seedFilePath, } from "../extensions/history/store.ts"; -// node:test has no test.skipIf (Bun-ism): emulate via the options object. -const skipIf = - (condition: unknown) => - (name: string, fn: () => unknown) => - test( - name, - { skip: condition ? "requires non-root" : false }, - fn as () => void | Promise, - ); - -const CWD = "/Users/admin/Dev/pi/pi-history"; +const CWD = "/pi-history-fixtures/project-a"; function makeDirs(): { root: string; sessionsRoot: string } { const base = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-seed-")); @@ -84,7 +74,7 @@ test("no sessions and no project dir: bootstrap seeds nothing", () => { test("empty project dir bootstraps from the project's transcripts", () => { const { root, sessionsRoot } = makeDirs(); - writeSession(sessionsRoot, `--Users-admin-Dev-pi-pi-history--`, "s1.jsonl", [ + writeSession(sessionsRoot, `--pi-history-fixtures-project-a--`, "s1.jsonl", [ "real prompt", "/compact", " ", @@ -98,7 +88,7 @@ test("empty project dir bootstraps from the project's transcripts", () => { test("only the project's own session dir is scanned", () => { const { root, sessionsRoot } = makeDirs(); - writeSession(sessionsRoot, `--Users-admin-Dev-pi-pi-history--`, "s1.jsonl", [ + writeSession(sessionsRoot, `--pi-history-fixtures-project-a--`, "s1.jsonl", [ "mine", ]); writeSession(sessionsRoot, "--Other--", "s2.jsonl", ["not mine"]); @@ -112,7 +102,7 @@ test("caps at the target keeping the newest", () => { for (let i = 1; i <= 600; i++) texts.push(`p${i}`); writeSession( sessionsRoot, - `--Users-admin-Dev-pi-pi-history--`, + `--pi-history-fixtures-project-a--`, "big.jsonl", texts, ); @@ -138,7 +128,7 @@ test("project dir already populated above target: no scan, seed untouched", () = ); const marker = writeSession( sessionsRoot, - `--Users-admin-Dev-pi-pi-history--`, + `--pi-history-fixtures-project-a--`, "s.jsonl", ["marker"], ); @@ -153,7 +143,9 @@ test("project dir already populated above target: no scan, seed untouched", () = assert.equal(fs.readFileSync(existing, "utf8").includes("marker"), false); }); -const sealedStoreTest = skipIf(process.getuid?.() === 0); +const isRoot = process.getuid?.() === 0; +const sealedStoreTest = (name: string, fn: () => unknown) => + test(name, { skip: isRoot && "requires a non-root user" }, fn); sealedStoreTest( "an unreadable existing store file is skipped during counting; seeding still runs from transcripts", () => { @@ -169,7 +161,7 @@ sealedStoreTest( fs.chmodSync(sealed, 0o000); writeSession( sessionsRoot, - `--Users-admin-Dev-pi-pi-history--`, + `--pi-history-fixtures-project-a--`, "s1.jsonl", ["from transcript"], ); diff --git a/tests/history-seed-regen.test.ts b/tests/history-seed-regen.test.ts index 289884f4c..cf8e7265e 100644 --- a/tests/history-seed-regen.test.ts +++ b/tests/history-seed-regen.test.ts @@ -5,8 +5,8 @@ import os from "node:os"; import path from "node:path"; import { bootstrapProjectSeed, seedFilePath } from "../extensions/history/store.ts"; -const CWD = "/Users/admin/Dev/pi/pi-history"; -const DIR = `--Users-admin-Dev-pi-pi-history--`; +const CWD = "/pi-history-fixtures/project-a"; +const DIR = `--pi-history-fixtures-project-a--`; function setup() { const base = fs.mkdtempSync(path.join(os.tmpdir(), "seed2-")); diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index e0fbda75b..e0c7ab819 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -13,7 +13,7 @@ function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-writer-")); } -const CWD = "/Users/admin/Dev/pi/pi-history"; +const CWD = "/pi-history-fixtures/project-a"; function fileTexts(file: string): string[] { return fs diff --git a/tests/history-store-paths.test.ts b/tests/history-store-paths.test.ts index 431fe43b8..10dec14a0 100644 --- a/tests/history-store-paths.test.ts +++ b/tests/history-store-paths.test.ts @@ -14,22 +14,28 @@ import { const ROOT = path.join(os.tmpdir(), "pi-history-test-root"); +// A path that does not exist on any machine: realpathSync fails and +// projectHash falls back to hashing the raw string, so this vector pins the +// algorithm with a digest that is identical everywhere. +const KNOWN_VECTOR_INPUT = "/pi-history-known-vector/missing-project"; +const KNOWN_VECTOR_EXPECTED = "fdcfb7426fb80158"; + +function makeProject(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + test("projectHash returns 16 lowercase hex chars", () => { - const hash = projectHash("/Users/admin/Dev/pi/pi-history"); - assert.match(hash, /^[0-9a-f]{16}$/); + assert.match(projectHash(makeProject("paths-shape-")), /^[0-9a-f]{16}$/); }); test("known vector: stable hash for a fixed path", () => { - assert.equal( - projectHash("/Users/admin/Dev/pi/pi-history"), - "28e0f06819c468cb", - ); + assert.equal(projectHash(KNOWN_VECTOR_INPUT), KNOWN_VECTOR_EXPECTED); }); test("distinct paths produce distinct hashes", () => { assert.notEqual( - projectHash("/Users/admin/Dev/pi/pi-history"), - projectHash("/Users/admin/Dev/github/pi"), + projectHash(makeProject("paths-distinct-a-")), + projectHash(makeProject("paths-distinct-b-")), ); }); @@ -54,7 +60,7 @@ test("nonexistent path falls back to hashing the raw string (no throw)", () => { }); test("path derivations compose under the root", () => { - const cwd = "/Users/admin/Dev/pi/pi-history"; + const cwd = makeProject("paths-compose-"); const hash = projectHash(cwd); assert.equal(projectDir(ROOT, cwd), path.join(ROOT, "projects", hash)); assert.equal( @@ -70,8 +76,8 @@ test("path derivations compose under the root", () => { }); test("two cwds map to sibling project dirs", () => { - const a = projectDir(ROOT, "/Users/admin/Dev/pi/pi-history"); - const b = projectDir(ROOT, "/Users/admin/Dev/github/pi"); + const a = projectDir(ROOT, makeProject("paths-sibling-a-")); + const b = projectDir(ROOT, makeProject("paths-sibling-b-")); assert.notEqual(a, b); assert.equal(path.dirname(a), path.dirname(b)); }); From c18271f15a3a66e77a725a24cd8879124d43a888 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:54:52 -0300 Subject: [PATCH 07/13] fix(test): match node:test TestFn callback type in skip wrappers The test.skipIf-style wrappers typed their callback as () => unknown, which is not assignable to node:test's TestFn (return void | Promise). Type the callback accordingly to clear the 4 new TS2345 diagnostics reported by the type gate. --- tests/history-drain-order.test.ts | 2 +- tests/history-gc.test.ts | 2 +- tests/history-scope-delete.test.ts | 2 +- tests/history-seed-bootstrap.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/history-drain-order.test.ts b/tests/history-drain-order.test.ts index f5430dc80..aefa9743d 100644 --- a/tests/history-drain-order.test.ts +++ b/tests/history-drain-order.test.ts @@ -52,7 +52,7 @@ test("global drain puts the legacy seed last regardless of its fresh mtime", () }); const isRoot = process.getuid?.() === 0; -const sealedDrainTest = (name: string, fn: () => unknown) => +const sealedDrainTest = (name: string, fn: () => void | Promise) => test(name, { skip: isRoot && "requires a non-root user" }, fn); sealedDrainTest( "an unreadable store file is skipped; the rest drain in the expected order", diff --git a/tests/history-gc.test.ts b/tests/history-gc.test.ts index 2256977f2..c1772e1e2 100644 --- a/tests/history-gc.test.ts +++ b/tests/history-gc.test.ts @@ -134,7 +134,7 @@ test("compaction keeps the newest 10 files, merges the rest", () => { }); const isRoot = process.getuid?.() === 0; -const sealedGcTest = (name: string, fn: () => unknown) => +const sealedGcTest = (name: string, fn: () => void | Promise) => test(name, { skip: isRoot && "requires a non-root user" }, fn); sealedGcTest( "compactProjectDir skips an unreadable file's content and compacts the readable entries", diff --git a/tests/history-scope-delete.test.ts b/tests/history-scope-delete.test.ts index 9480efd8f..776667d3f 100644 --- a/tests/history-scope-delete.test.ts +++ b/tests/history-scope-delete.test.ts @@ -101,7 +101,7 @@ test("delete leaves no tmp files behind", () => { }); const isRoot = process.getuid?.() === 0; -const sealedFileTest = (name: string, fn: () => unknown) => +const sealedFileTest = (name: string, fn: () => void | Promise) => test(name, { skip: isRoot && "requires a non-root user" }, fn); sealedFileTest( diff --git a/tests/history-seed-bootstrap.test.ts b/tests/history-seed-bootstrap.test.ts index fcbfe64c8..d3e7d86d7 100644 --- a/tests/history-seed-bootstrap.test.ts +++ b/tests/history-seed-bootstrap.test.ts @@ -144,7 +144,7 @@ test("project dir already populated above target: no scan, seed untouched", () = }); const isRoot = process.getuid?.() === 0; -const sealedStoreTest = (name: string, fn: () => unknown) => +const sealedStoreTest = (name: string, fn: () => void | Promise) => test(name, { skip: isRoot && "requires a non-root user" }, fn); sealedStoreTest( "an unreadable existing store file is skipped during counting; seeding still runs from transcripts", From 1c59b1103596c36219fd865f84cb122b4c0fb1cd Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:03:57 -0300 Subject: [PATCH 08/13] fix(history): restore review fixes clobbered by the upstream sync Sync 72b5adfe faithfully mirrored pi-history main, which never received the review fixes from the slice reviews (PR #819); the sync silently reverted them along with their test pins. Restore everything on the slice-06 PR branch, adapted to the current upstream-parity sources, with the test-hygiene commits (portable fixtures, skip wrappers) already cherry-picked in: - store: compact artifact name is pid-scoped (compact--.jsonl) so two concurrent instances can never rename onto the same file (silent compact loss) - store: migrateLegacyStores renames legacy sources to .imported only AFTER the global seed write succeeds - a failed write no longer strands entries with the one-shot gate blocking retry - store: ensureRegistryEntry returns an existing long-key mapping unchanged so collision assignments stay stable across calls - index: sanitizeForDisplay re-appends astral code points whole (String.fromCodePoint) - emoji no longer lose half their code point - index: FixedRowText.render pads by the SGR-stripped visible width - colored rows no longer fall short and leave ghost characters - index: writer init (migrate/registry/seed) is scheduled via setImmediate so bootstrap never runs on the first-prompt path - index: PRELOAD_BUFFER comment corrected to the constant's real value - tests: restore the GC crash-safety trio (atomic ordering, rm-failure tolerance, active-writer mid-compaction) plus the pid filename pin, the migration retry test, the registry stability test, the setImmediate scheduling pin, and the padding + astral pins - compactProjectDir stays exported for upstream parity (no knip gate configured); gcProjectDir remains the wired and tested entry point Gates: full history suite 196 pass / 0 fail under bun (node:test sources). No tsc/typecheck script exists in this repo; the suite run parses every changed file via node's type stripping. --- extensions/history/index.ts | 26 ++- extensions/history/store.ts | 42 ++-- tests/history-command-registration.test.ts | 21 ++ tests/history-gc.test.ts | 234 ++++++++++++++++++--- tests/history-legacy-migrate-v2.test.ts | 33 +++ tests/history-preview-layout.test.ts | 37 ++++ tests/history-registry.test.ts | 36 ++++ 7 files changed, 376 insertions(+), 53 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index cf1ff0036..f9f379a7c 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -64,8 +64,8 @@ import { const SHORTCUT = "ctrl+shift+r"; const MAX_VISIBLE = 10; const PREVIEW_ROWS = 10; -// Lazy windowing (design §D3; user-tuned 2026-09-08). PRELOAD_BUFFER=2 -// fires growth as the cursor enters the final 2 loaded rows; BATCH_SIZE=10 +// Lazy windowing (design §D3; user-tuned 2026-09-08). PRELOAD_BUFFER=3 +// fires growth as the cursor enters the final 3 loaded rows; BATCH_SIZE=10 // loads exactly one viewport per growth; INITIAL_BATCH=10 paints one // viewport at open. PRELOAD_BUFFER <= MAX_VISIBLE keeps a jump within one // viewport covered by the catch-up loop; review all three together. @@ -130,7 +130,10 @@ function sanitizeForDisplay(text: string): string { } else if (cp >= 0x80 && cp < 0xa0) { out += `\\x${cp.toString(16).padStart(2, "0")}`; } else { - out += text[i]; + // Astral code points (> 0xFFFF) span a surrogate pair; append the + // full code point, not just the high surrogate at text[i], so emoji + // and other non-BMP characters survive sanitization intact. + out += cp > 0xffff ? String.fromCodePoint(cp) : text[i]; } if (cp > 0xffff) i++; // skip low surrogate of astral pair } @@ -193,7 +196,10 @@ class FixedRowText { : truncateToWidth(this.text, width, "…"); // Pad to full terminal width so the overlay fully overwrites // whatever is beneath it and leaves no ghost characters on dismiss. - return [rendered + " ".repeat(Math.max(0, width - rendered.length))]; + // Measure the VISIBLE width: SGR escape sequences (colored rows from + // rebuildListWithWidth) occupy no terminal cells. + const visible = rendered.replace(/\x1b\[[0-9;]*m/g, ""); + return [rendered + " ".repeat(Math.max(0, width - visible.length))]; } } @@ -1082,6 +1088,18 @@ function recordsFromEntries( export default function promptHistoryExtension(pi: ExtensionAPI) { // One writer per extension load; see getWriter() for the init order. + // Warm migrate/registry/seed OFF the first-prompt path: the scheduled + // init runs once, immediately after load. A prompt arriving earlier + // falls back to the synchronous lazy init in getWriter(), whose + // writerState guard makes whichever runs second a no-op — bootstrap + // work is never duplicated. + setImmediate(() => { + try { + getWriter(); + } catch { + // init is best-effort; the lazy path retries on the next prompt + } + }); // Persist every delivered user prompt (write-through, append-only JSONL). // The local ExtensionAPI stub types handler args as unknown; narrow here. diff --git a/extensions/history/store.ts b/extensions/history/store.ts index 5ccba43cb..e59825950 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -115,6 +115,11 @@ export function ensureRegistryEntry( 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 @@ -520,9 +525,10 @@ function writeSeedFileAtomic(seed: string, collected: StoreEntry[]): number { * 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; each - * source is renamed `.imported`, never deleted. Gated: an existing global - * seed means migration already ran. + * Content lands in `pi-history/history-global.jsonl` chronologically; only + * after the seed write succeeds is each source renamed `.imported`, never + * deleted — a failed write leaves sources untouched for a later retry. + * Gated: an existing global seed means migration already ran. */ export function migrateLegacyStores( root: string, @@ -537,15 +543,8 @@ export function migrateLegacyStores( const legacyArray = path.join(agentDir, "editor-history.json"); if (fs.existsSync(legacyArray)) { const texts = loadSharedHistory(legacyArray); - if (texts.length > 0) { - for (let i = texts.length - 1; i >= 0; i--) { - collected.push({ v: 1, text: texts[i] }); - } - } - try { - fs.renameSync(legacyArray, `${legacyArray}.imported`); - } catch { - // The seed write below is the source of truth; rename failure is benign. + for (let i = texts.length - 1; i >= 0; i--) { + collected.push({ v: 1, text: texts[i] }); } } @@ -553,16 +552,21 @@ export function migrateLegacyStores( const v1File = path.join(agentDir, "editor-history.jsonl"); if (fs.existsSync(v1File)) { collected.push(...readValidLines(v1File)); - try { - fs.renameSync(v1File, `${v1File}.imported`); - } catch { - // benign - } } if (collected.length === 0) return { migrated: 0, ran: false }; const migrated = writeSeedFileAtomic(seed, collected); + + // The seed write is the source of truth: rename sources only once it + // succeeded, so a failure can never strand entries in .imported files. + for (const src of [legacyArray, v1File]) { + try { + if (fs.existsSync(src)) fs.renameSync(src, `${src}.imported`); + } catch { + // benign: the seed gate prevents duplicate import on the next run + } + } return { migrated, ran: true }; } @@ -769,7 +773,7 @@ export function gcProjectDir( /** * Merge all but the newest GC_KEEP_NEWEST files into one - * `compact-.jsonl` (chronological within the merged content). One + * `compact--.jsonl` (chronological within the merged content). One * atomic write; the originals are removed only after the compact file * lands. Readers see either the old set or the compacted set. */ @@ -800,7 +804,7 @@ function compactFiles(filesMtimeDesc: string[], keepNewest: number): GcResult { if (mergedLines.length === 0) return { compacted: false, merged: 0 }; const dir = path.dirname(toMerge[0]); - const compact = path.join(dir, `compact-${Date.now()}.jsonl`); + const compact = path.join(dir, `compact-${process.pid}-${Date.now()}.jsonl`); const tmp = `${compact}.tmp-${process.pid}-${Date.now()}`; fs.writeFileSync(tmp, `${mergedLines.join("\n")}\n`, "utf8"); fs.renameSync(tmp, compact); diff --git a/tests/history-command-registration.test.ts b/tests/history-command-registration.test.ts index 1bd615c9c..f37df90b7 100644 --- a/tests/history-command-registration.test.ts +++ b/tests/history-command-registration.test.ts @@ -61,3 +61,24 @@ test("in-UI hint describes multi-word AND substring matching, not fuzzy", () => "hint should describe multi-word AND substring filtering (AC-P1-6.1)", ); }); + +test("writer init is scheduled off the first-prompt path via setImmediate", () => { + const entry = source.indexOf("export default function promptHistoryExtension"); + assert.notStrictEqual(entry, -1, "extension entry point should exist"); + + const body = source.slice(entry); + assert.ok( + body.includes("setImmediate(() => {"), + "init must be scheduled with setImmediate so bootstrap never runs on\nthe first-prompt path", + ); + assert.ok( + /setImmediate\(\(\) => \{[\s\S]*?getWriter\(\);/.test(body), + "the scheduled callback should warm getWriter()", + ); + // The synchronous fallback stays: a prompt arriving before the + // scheduled call still initializes lazily inside the capture handler. + assert.ok( + /before_agent_start[\s\S]*?appendSessionCapture\(getWriter\(\)/.test(body), + "capture handler keeps the synchronous getWriter() fallback", + ); +}); diff --git a/tests/history-gc.test.ts b/tests/history-gc.test.ts index c1772e1e2..c7a9440b4 100644 --- a/tests/history-gc.test.ts +++ b/tests/history-gc.test.ts @@ -3,13 +3,19 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { - compactProjectDir, - gcProjectDir, - projectHash, -} from "../extensions/history/store.ts"; +import { gcProjectDir, projectHash } from "../extensions/history/store.ts"; -const CWD = "/pi-history-fixtures/project-a"; +// GC/compaction (slice 6): threshold no-op below the limits, keep-newest +// semantics, and the failure paths — the compact file lands atomically +// before any original is removed, cleanup failures are tolerated, unreadable +// files are skipped, and an append landing mid-compaction is never lost. +// All fixtures live under os.tmpdir(): the user's real ~/.pi store root is +// never touched. (Ported from the dev repo's test/history/gc.test.ts. +// compactProjectDir stays exported for upstream parity but carries no +// callers here — gcProjectDir with explicit thresholds is the wired and +// tested entry point.) + +const CWD = "/pi-history-fixtures/project-gc"; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-gc-")); @@ -49,6 +55,40 @@ function totalLines(dir: string): number { return total; } +/** Line texts of the single compact-*.jsonl file in dir (must exist). */ +function compactTexts(dir: string): string[] { + const compact = fs.readdirSync(dir).find((f) => f.startsWith("compact-")); + assert.ok(compact, "a compact-*.jsonl file must exist"); + return fs + .readFileSync(path.join(dir, compact), "utf8") + .trim() + .split("\n") + .map((l) => (JSON.parse(l) as { text: string }).text); +} + +/** + * Replace fs.rmSync (the shared CJS exports object store.ts resolves at + * call time) for the duration of fn; the original is always restored. + * `rmSync` inside the replacement is the captured original, so replacements + * can observe-or-fail and then call through. + */ +function withRmSyncPatched( + replacement: (file: string, rmSync: (file: string) => void) => void, + fn: () => void, +): void { + type RmSync = (file: string) => void; + const realRmSync = fs.rmSync.bind(fs) as RmSync; + const target = fs as unknown as { rmSync: RmSync }; + target.rmSync = (file: string) => { + replacement(file, realRmSync); + }; + try { + fn(); + } finally { + target.rmSync = realRmSync; + } +} + test("under both thresholds: GC is a no-op", () => { const root = makeRoot(); const dir = projectRoot(root); @@ -81,8 +121,15 @@ test("file-count threshold merges the oldest files into one compact file", () => // 12 files -> newest 1 kept + 1 compact file = 2 files; all lines kept. assert.equal(fs.readdirSync(dir).length, 2); assert.equal(totalLines(dir), 120); - const compact = fs.readdirSync(dir).find((f) => f.startsWith("compact-")); - assert.ok(compact); + // The compact file is the renamed final artifact, not a staging leftover. + assert.match( + fs.readdirSync(dir).find((f) => f.startsWith("compact-")) ?? "", + /^compact-\d+-\d+\.jsonl$/, + ); + assert.deepEqual( + fs.readdirSync(dir).filter((f) => f.includes(".tmp-")), + [], + ); // The newest original file survives untouched by name. assert.equal(fs.readdirSync(dir).includes("f12.jsonl"), true); }); @@ -105,9 +152,9 @@ test("line-count threshold triggers compaction too", () => { assert.equal(fs.readdirSync(dir).includes("g3.jsonl"), true); }); -test("compactProjectDir on a missing dir is a no-op", () => { +test("GC on a missing project dir is a no-op", () => { const root = makeRoot(); - const result = compactProjectDir(root, "/does/not/exist"); + const result = gcProjectDir(root, "/does/not/exist"); assert.deepEqual(result, { compacted: false, merged: 0 }); }); @@ -133,46 +180,41 @@ test("compaction keeps the newest 10 files, merges the rest", () => { assert.equal(names.includes("h06.jsonl"), true); }); -const isRoot = process.getuid?.() === 0; -const sealedGcTest = (name: string, fn: () => void | Promise) => - test(name, { skip: isRoot && "requires a non-root user" }, fn); -sealedGcTest( - "compactProjectDir skips an unreadable file's content and compacts the readable entries", +// node:test has no test.skipIf (Bun-ism): root skips via the options object. +test( + "an unreadable file (chmod 000) is skipped; GC still compacts the readable tail", + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, () => { const root = makeRoot(); const dir = projectRoot(root); fs.mkdirSync(dir, { recursive: true }); - // 3 files, keepNewest 1 → the two oldest merge; the sealed one sits in - // the merged tail so its content hits the unreadable-skip branch. + // 3 files, keepNewest 1 -> the two oldest merge; the sealed one sits in + // the merged tail so its bytes hit the unreadable-skip branch (both the + // line-counting pass and the merge pass skip it). writeFile(dir, "readable-old.jsonl", 5, 1000); const sealed = writeFile(dir, "sealed-old.jsonl", 5, 2000); writeFile(dir, "newest.jsonl", 5, 3000); fs.chmodSync(sealed, 0o000); try { - const result = compactProjectDir(root, CWD, { keepNewest: 1 }); + const result = gcProjectDir(root, CWD, { + fileThreshold: 2, + lineThreshold: 100000, + keepNewest: 1, + }); // The merged count covers the whole tail, sealed file included. assert.deepEqual(result, { compacted: true, merged: 2 }); - const compact = fs.readdirSync(dir).find((f) => f.startsWith("compact-")); - if (compact === undefined) { - throw new Error("the compact file must exist"); - } - const compactTexts = fs - .readFileSync(path.join(dir, compact), "utf8") - .trim() - .split("\n") - .map((l) => (JSON.parse(l) as { text: string }).text); // Only the readable tail file's entries compacted; the sealed bytes // were skipped, never fatal. (writeFile names entries `${name}-${i}`.) - assert.deepEqual(compactTexts, [ + assert.deepEqual(compactTexts(dir), [ "readable-old.jsonl-0", "readable-old.jsonl-1", "readable-old.jsonl-2", "readable-old.jsonl-3", "readable-old.jsonl-4", ]); - // GC cache semantics: the tail originals (sealed one included) are + // Cleanup semantics: the tail originals (sealed one included) are // removed after the compact file lands — unlink needs no read access. - assert.equal(fs.readdirSync(dir).includes("sealed-old.jsonl"), false); + assert.equal(fs.existsSync(sealed), false); assert.equal(fs.readdirSync(dir).includes("newest.jsonl"), true); } finally { // The compaction removes the sealed original; restore only if it @@ -185,3 +227,135 @@ sealedGcTest( } }, ); + +test("the compact file lands complete before any original is removed", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + for (let i = 1; i <= 12; i++) { + writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000); + } + // Observe, do not replace: at the FIRST cleanup unlink the compact file + // must already exist on disk with the full merged content (110 lines). + // That is the crash-safe ordering contract: readers never see the tail + // gone with no compact file in its place. + let compactCompleteAtFirstRm: boolean | null = null; + withRmSyncPatched( + (file, rmSync) => { + if (compactCompleteAtFirstRm === null) { + const parent = path.dirname(file); + const compact = fs + .readdirSync(parent) + .find((f) => f.startsWith("compact-")); + compactCompleteAtFirstRm = + compact !== undefined && + fs + .readFileSync(path.join(parent, compact), "utf8") + .trim() + .split("\n") + .filter((l) => l.trim().length > 0).length === 110; + } + rmSync(file); + }, + () => { + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 1, + }); + assert.deepEqual(result, { compacted: true, merged: 11 }); + }, + ); + assert.equal(compactCompleteAtFirstRm, true); +}); + +test("rm failure is tolerated: originals survive, GC still reports success", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + for (let i = 1; i <= 12; i++) { + writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000); + } + // Simulate every cleanup unlink failing (e.g. originals held by another + // process): the compact file already landed, so a surviving original is + // harmless — readers dedupe by identity. + withRmSyncPatched( + () => { + throw new Error("simulated EBUSY: original still held"); + }, + () => { + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 1, + }); + // The success shape is unchanged even though cleanup failed. + assert.deepEqual(result, { compacted: true, merged: 11 }); + }, + ); + // The compact file is complete on disk... + assert.equal(compactTexts(dir).length, 110); + // ...and every original survived the failed cleanup (12 + 1 compact). + assert.equal(fs.readdirSync(dir).length, 13); +}); + +test("an append landing during compaction is never lost (active writer)", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // 12 old files (merge-tail candidates) + one active writer file with the + // newest mtime. The freshness rule keeps the active file out of the merge + // tail — that is what makes concurrent appends safe during GC. + for (let i = 1; i <= 12; i++) { + writeFile(dir, `t${String(i).padStart(2, "0")}.jsonl`, 5, i * 1000); + } + const active = writeFile(dir, "active.jsonl", 5, 99_000); + // Mid-compaction (first cleanup unlink), the active writer appends a line. + let appended = false; + withRmSyncPatched( + (file, rmSync) => { + if (!appended) { + appended = true; + fs.appendFileSync( + active, + `${JSON.stringify({ v: 1, text: "during-gc" })}\n`, + "utf8", + ); + } + rmSync(file); + }, + () => { + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 100000, + keepNewest: 10, + }); + // 13 files > threshold 10; tail = 3 oldest; active writer untouched. + assert.deepEqual(result, { compacted: true, merged: 3 }); + }, + ); + // The active file survived by name with every line: the pre-GC lines and + // the line appended mid-compaction. + const activeTexts = fs + .readFileSync(active, "utf8") + .trim() + .split("\n") + .map((l) => (JSON.parse(l) as { text: string }).text); + assert.deepEqual(activeTexts, [ + "active.jsonl-0", + "active.jsonl-1", + "active.jsonl-2", + "active.jsonl-3", + "active.jsonl-4", + "during-gc", + ]); + // The tail's 15 lines all compacted; nothing from kept files was merged. + const mergedTexts = compactTexts(dir); + assert.equal(mergedTexts.length, 15); + assert.ok(mergedTexts.includes("t01.jsonl-0")); + assert.ok(mergedTexts.includes("t03.jsonl-4")); + assert.ok(!mergedTexts.some((t) => t.startsWith("active."))); + assert.ok(!mergedTexts.some((t) => t.startsWith("t04."))); + // Whole-dir accounting: 13 x 5 original lines + 1 mid-GC append. + assert.equal(totalLines(dir), 66); +}); diff --git a/tests/history-legacy-migrate-v2.test.ts b/tests/history-legacy-migrate-v2.test.ts index 12b8a6d91..c9d665b46 100644 --- a/tests/history-legacy-migrate-v2.test.ts +++ b/tests/history-legacy-migrate-v2.test.ts @@ -120,6 +120,39 @@ test("malformed v1 jsonl lines are skipped, not fatal", () => { assert.deepEqual(fileTexts(globalSeedPath(root)), ["good"]); }); +// chmod-based failure injection is also invisible to the superuser. +const seedFailureTest = skipIf(process.getuid?.() === 0); +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}.imported`), true); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["survives-retry"]); + }, +); + const sealedLegacyTest = skipIf(process.getuid?.() === 0); sealedLegacyTest( "an unreadable legacy file is skipped; the readable file still migrates", diff --git a/tests/history-preview-layout.test.ts b/tests/history-preview-layout.test.ts index c0b4191aa..56abd381f 100644 --- a/tests/history-preview-layout.test.ts +++ b/tests/history-preview-layout.test.ts @@ -53,3 +53,40 @@ test("preview rows are bottom-padded so the panel shrinks from the bottom", () = "preview should not compute top padding", ); }); + +test("row padding measures visible width, stripping SGR escapes", () => { + // Colored list rows carry SGR escape sequences that occupy no terminal + // cells; padding must use the VISIBLE width or the row falls short of + // the overlay width and leaves ghost characters on dismiss. + const renderStart = source.indexOf(" render(width: number): string[] {"); + assert.notStrictEqual(renderStart, -1, "FixedRowText.render should exist"); + + const renderSource = source.slice(renderStart, renderStart + 2200); + const padLine = renderSource + .split("\n") + .find((l) => l.includes('" ".repeat(Math.max(0, width -')); + assert.ok(padLine !== undefined, "final full-width pad should exist"); + assert.ok( + padLine.includes("visible"), + "pad must measure the SGR-stripped visible width, not rendered.length", + ); + assert.ok( + /visible = rendered\.replace\(/.test(renderSource), + "visible width must be derived by stripping escape sequences", + ); +}); + +test("sanitizeForDisplay appends the full astral code point, not a lone surrogate", () => { + const fnStart = source.indexOf("function sanitizeForDisplay("); + assert.notStrictEqual(fnStart, -1, "sanitizeForDisplay should exist"); + + const fnSource = source.slice(fnStart, fnStart + 1200); + assert.ok( + fnSource.includes("String.fromCodePoint(cp)"), + "astral code points must be re-appended whole (emoji survive)", + ); + assert.ok( + fnSource.includes("if (cp > 0xffff) i++"), + "the low surrogate of the pair must still be skipped", + ); +}); diff --git a/tests/history-registry.test.ts b/tests/history-registry.test.ts index 7ca59d94f..726ba0de4 100644 --- a/tests/history-registry.test.ts +++ b/tests/history-registry.test.ts @@ -1,5 +1,6 @@ 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"; @@ -96,6 +97,41 @@ test("hash collision re-keys the existing occupant; the new cwd keeps the short assert.equal(lookupCwd(root, 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 readRegistryFile = (dir: string): Record => + JSON.parse(fs.readFileSync(registryPath(dir), "utf8")); + 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 — lookups fail open to null, and the next entry rebuilds From 5d85a8fd65854a08fa0308d10ade7942521237ac Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:47:37 -0300 Subject: [PATCH 09/13] fix(history): make prompt capture opt-in and document the store Review follow-up on the slice-01 PR: the before_agent_start handler recorded delivered prompts by default while the deletion UI is still unshipped, so an intermediate release could accumulate sensitive prompts with no removal path. - Capture is now strictly opt-in via GENTLE_PI_HISTORY_CAPTURE=1|true|on (default off); the switch doubles as the disable path, is checked per prompt, and a disabled session writes nothing - no registry entry, no files. - promptHistoryExtension takes injectable deps (env/root/cwd/ instanceId/now) with one writer closure per extension load. - New tests: strict opt-in matrix, default-off inertness, opted-in capture, disable-leaves-existing-files. - docs/prompt-history.md documents the switch, storage locations, permissions/readers, and disable/removal semantics; the README docs table gains a pointer. --- README.md | 1 + docs/prompt-history.md | 61 +++++++++++++++++ extensions/history/index.ts | 89 +++++++++++++++++++++---- tests/history-session-writer.test.ts | 97 ++++++++++++++++++++++++++-- 4 files changed, 232 insertions(+), 16 deletions(-) create mode 100644 docs/prompt-history.md diff --git a/README.md b/README.md index 9e20d72f4..0207c389c 100644 --- a/README.md +++ b/README.md @@ -880,6 +880,7 @@ To opt out: | `docs/skill-style-guide.md` | Normative style guide used by the packaged skill creation/improvement skills. | | `docs/native-authority-architecture.md` | Post-U8 ownership boundary, reproducible slimming metrics, Windows evidence, exact #191 seam, and the `review-integration/v1`→`v2` migration status, including the "compact-v2" naming disambiguation. | | `docs/review-integration.md` | Negotiated provider/consumer contract and the current Gentle Pi adoption boundary. | +| `docs/prompt-history.md` | Prompt-history slice 1: opt-in capture switch, storage layout, readers, and disable/removal semantics. | ## Development 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/index.ts b/extensions/history/index.ts index f9f379a7c..d6dd97ea6 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -1,6 +1,11 @@ // SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history // SPDX-License-Identifier: MIT +// 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"; @@ -1086,14 +1091,74 @@ function recordsFromEntries( return buildPromptRecords(dedupePromptEntries(entries)); } -export default function promptHistoryExtension(pi: ExtensionAPI) { - // One writer per extension load; see getWriter() for the init order. - // Warm migrate/registry/seed OFF the first-prompt path: the scheduled - // init runs once, immediately after load. A prompt arriving earlier - // falls back to the synchronous lazy init in getWriter(), whose - // writerState guard makes whichever runs second a no-op — bootstrap - // work is never duplicated. + +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 ?? CURRENT_CWD; + const instanceId = deps.instanceId ?? INSTANCE_ID; + const now = deps.now ?? Date.now; + let writerState: SessionWriterState | null = null; + + /** + * 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, AGENT_DIR); + } catch { + // migration is best-effort; the gate keeps it one-shot + } + try { + ensureRegistryEntry(root, cwd); + } catch { + // registry is advisory + } + try { + bootstrapProjectSeed( + root, + cwd, + SESSIONS_ROOT, + 500, + PI_HISTORY_NAV_STATE_DIR, + ); + } catch { + // bootstrap is a rebuildable cache + } + writerState = openSessionWriter(root, cwd, instanceId); + } + return writerState; + }; + + // Warm migrate/registry/seed OFF the first-prompt path, but only for + // opted-in sessions: with capture disabled nothing may be written — + // no registry entry, no seed files, no store (docs/prompt-history.md). setImmediate(() => { + if (!captureEnabled(env)) return; try { getWriter(); } catch { @@ -1101,12 +1166,14 @@ export default function promptHistoryExtension(pi: ExtensionAPI) { } }); - // Persist every delivered user prompt (write-through, append-only JSONL). - // The local ExtensionAPI stub types handler args as unknown; narrow here. + // 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 ?? "", Date.now()); + 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. @@ -1116,7 +1183,7 @@ export default function promptHistoryExtension(pi: ExtensionAPI) { // Backup pass: enforce the 1000-line limit on graceful shutdown. pi.on("session_shutdown", () => { try { - gcProjectDir(PI_HISTORY_ROOT, CURRENT_CWD); + gcProjectDir(root, cwd); } catch { // GC is best-effort } diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index e0c7ab819..81058d075 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -5,9 +5,13 @@ 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-")); @@ -23,6 +27,32 @@ function fileTexts(file: string): string[] { .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]); + }, + // 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: () => {}, + }; + 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"); @@ -82,9 +112,66 @@ test("two writers own separate files in the same project dir", () => { assert.deepEqual(files, ["inst-a.jsonl", "inst-b.jsonl"]); }); -// Helper kept local: openWriter is the U3 surface under test. -import { openSessionWriter } from "../extensions/history/store.ts"; +test("the extension entry wires capture first, then the selector surface", () => { + // Module load must stay side-effect free (importing index.ts parses the + // whole extension graph without touching the real ~/.pi store root). + // Capture is registered first; the selector adds session_shutdown GC, + // tool_call dismissal, the shortcut, and the /history command beside it. + const registered: Array<[string, unknown]> = []; + const pi = { + on: (event: string, handler: unknown) => { + registered.push([event, handler]); + }, + registerShortcut: () => {}, + registerCommand: () => {}, + }; + promptHistoryExtension(pi as never); + assert.deepEqual( + registered.map(([event]) => event), + ["before_agent_start", "session_shutdown", "tool_call"], + ); + // The capture handler is callable but is NEVER invoked here: a real + // invocation would run getWriter() against ~/.pi/agent/history. + assert.equal(typeof registered[0][1], "function"); +}); -function openWriterForTest(root: string, instanceId: string) { - return openSessionWriter(root, CWD, instanceId); -} +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"]); +}); From a663195c6871647ac22869c04ae8cb4a37df2aff Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:21:45 -0300 Subject: [PATCH 10/13] chore(readme): remove README delta from history slice The history slice branches must not touch README.md: the docs table lives in main and evolves independently of the extension slices. The opt-in capture documentation stays in docs/prompt-history.md; the README pointer row introduced by the capture-gate commit is dropped and README.md is restored to upstream/main verbatim. --- README.md | 1036 +++++++++++------------------------------------------ 1 file changed, 207 insertions(+), 829 deletions(-) diff --git a/README.md b/README.md index 0207c389c..a8a5ee727 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,44 @@ -# gentle-pi + -[![npm](https://img.shields.io/npm/v/gentle-pi?color=blue)](https://www.npmjs.com/package/gentle-pi) -[![pi package](https://img.shields.io/badge/Pi-package-6f42c1)](https://pi.dev/packages/gentle-pi) -[![license](https://img.shields.io/npm/l/gentle-pi?color=blue)](LICENSE) -[![GitHub stars](https://img.shields.io/github/stars/Gentleman-Programming/gentle-pi?style=flat&color=yellow)](https://github.com/Gentleman-Programming/gentle-pi/stargazers) -[![Gentle-AI](https://img.shields.io/badge/Gentle--AI-ecosystem-ff69b4)](https://github.com/Gentleman-Programming/gentle-ai) -[![Gentleman Programming](https://img.shields.io/badge/by-Gentleman%20Programming-black)](https://github.com/Gentleman-Programming) -[![YouTube](https://img.shields.io/badge/YouTube-Gentleman%20Programming-red?logo=youtube&logoColor=white)](https://www.youtube.com/c/GentlemanProgramming) -[![Discord](https://img.shields.io/badge/Discord-community-5865F2?logo=discord&logoColor=white)](https://discord.com/invite/gentleman-programming-769863833996754944) -[![SDD/OpenSpec](https://img.shields.io/badge/SDD-OpenSpec-00ADD8)](#sddopenspec-flow) -[![Subagents](https://img.shields.io/badge/Pi-subagents-brightgreen)](#what-it-adds) +
+ gentle-shell — Ecosystem, Agent, One shell +
+ +

gentle-shell™

+ +

Your coding agent for controlled development in the workspace you lead.

+ +

+ npm + Pi-native package + MIT license + GitHub stars + Last commit +

+ +

+ + Website +  ·  + Quickstart +  ·  + Docs +  ·  + Wiki + +

+ +
+ +

Your terminal can run an agent. Your workspace should help you lead it.
gentle-shell is your coding agent, bringing your changes, tasks, and engineering workflow together—built for Pi.

-**[Gentle-AI website](https://gentle-ai.gentlemanprogramming.com/)** • **[Gentle-AI wiki](https://gentle-ai-wiki.gentlemanprogramming.com/)** • **[Engram](https://engram.gentlemanprogramming.com/)** +

One workspace. A coding agent you direct. A workflow you can inspect.

+ +

BUILT FOR PI  ·  Coding-agent workspace  ·  Focused agents  ·  ODD

+ +

+ ★ Star gentle-shell on GitHub +

@@ -27,934 +54,285 @@ - Star History Chart + Star History Chart -
- -**Turn Pi from a powerful coding agent into a controlled development harness.** - -`gentle-pi` installs **el Gentleman** in Pi: a senior-architect operating layer for Spec-Driven Development, focused subagents, strict TDD evidence, reviewable work units, safety guards, project/user skill discovery, and bounded native review. - -Pi already has strong tools. `gentle-pi` adds the discipline for using them well, keeps review evidence Git-derived instead of agent narration, and leaves delivery decisions to ordinary repository policy. - -`gentle-pi` is the Pi-native package from the [Gentle-AI ecosystem](https://github.com/Gentleman-Programming/gentle-ai), built by [Gentleman Programming](https://github.com/Gentleman-Programming): the broader open-source project for turning AI coding agents into disciplined engineering environments with SDD workflows, skills, memory integrations, model routing, and review guardrails across multiple agents. - -> **Trademark notice:** The gentle-pi name and logo are trademarks of Alan Buscaglia. The MIT License applies to the code; it does not permit implying endorsement or official affiliation. See [TRADEMARKS.md](TRADEMARKS.md). - -Follow the project and the community around it: - -- GitHub: [Gentleman-Programming](https://github.com/Gentleman-Programming) -- YouTube: [Gentleman Programming](https://www.youtube.com/c/GentlemanProgramming) -- Community Discord: [Gentleman Programming](https://discord.com/invite/gentleman-programming-769863833996754944) - -Startup intro collaboration: thanks to [@aporcelli](https://github.com/aporcelli) for [`pi-gentle-startup`](https://github.com/aporcelli/pi-gentle-startup), which inspired the clean-screen startup animation, compact runtime panel, and pink visual treatment. - -## The problem - -Most coding-agent sessions fail for operational reasons, not model reasons: - -- the agent jumps into code before requirements are clear; -- architectural decisions disappear into chat history; -- one request quietly becomes a huge multi-area diff; -- tests run late, or not at all; -- reviewers get handed a wall of changes; -- subagents are available, but the parent session has no orchestration discipline; -- project skills exist, but the model forgets to load them. - -`gentle-pi` fixes the workflow around the agent. - -## What it adds - -| Capability | What it does | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | -| **el Gentleman persona** | Makes Pi behave like a senior architect and teacher, not a generic chatbot. Spanish responses use Rioplatense voseo by default; neutral mode is saved globally with project overrides. | -| **Configurable startup intro** | Adds a rose/text-logo startup intro, compact runtime panel, color presets, and commands to hide or show the decorative parts. | -| **Work routing discipline** | Small tasks stay inline. Context-heavy exploration can be delegated. Large or risky changes go through SDD/OpenSpec. | -| **SDD/OpenSpec assets** | Installs phase agents and chains for `init`, `onboard`, `explore`, `proposal`, `spec`, `design`, `tasks`, `apply`, `verify`, `sync`, and `archive`. | -| **Lazy SDD preflight** | Resolves SDD mode, artifact store, delivery strategy, and review budget once per session; prompts only when a choice is genuinely unresolved. | -| **Subagent orchestration** | Keeps one parent session responsible while child agents explore, implement, test, or review with focused context. | -| **Strict TDD support** | When project config declares a test command, apply/verify phases must record RED → GREEN → TRIANGULATE → REFACTOR evidence. | -| **Closed choice prompts** | Per-option hover/click/wheel in fullscreen; keyboard selection in either TUI mode. | -| **Native pointer regions** | Compose hover, press, click, and wheel behavior around public TUI components. | -| **Agent overlay close control** | Adds a header close button that adapts to available width. | -| **Reviewer protection** | Surfaces review workload risk before a task turns into an oversized PR. | -| **Per-agent model assignment** | Pi-native modal for assigning stronger or cheaper models to specific SDD/custom agents. | -| **Skill discovery registry** | Maintains `.atl/skill-registry.md` from project and user skills so review/comment/PR workflows do not silently miss the right skill. | -| **Skill creation workflow** | Provides the `gentle-ai-skill-creator`/`gentle-ai-skill-improver` skills, `/skill-creation` prompt, and packaged style guide for LLM-first skills. | -| **Delivery skills** | Includes issue-first PRs, chained PRs, work-unit commits, cognitive docs, comment writing, and Judgment Day review. | -| **Bounded native review** | Freezes one candidate, dispatches only controller-selected lenses, and records native authority. Review outcomes are informational; delivery follows ordinary repository policy. | -| **Verified native runtime** | Provisions the exact package-local Gentle AI v2.7.0 runtime: signed, SHA-256-pinned release archives on Darwin/Linux and a Go SumDB-verified source build on Windows x64/arm64. It validates package-local integrity and rejects PATH, global, sibling, symlink, and mode fallbacks. | -| **Runtime safety** | Blocks destructive shell commands, asks for confirmation for sensitive operations, and blocks direct read/write/edit access to sensitive paths. | - -## Native pointer regions - -Compose pointer behavior around public `Text`, `Box`, or custom content without making it a keyboard target: - -```ts -const scope = createNativePointerScope(); -const openInput = scope.wrap(new Text("Open input", 0, 0), { - onClick: () => { - openInputEditor(); - return { handled: true }; - }, -}); -const panel = new Container(); -panel.addChild(openInput); -const observer = scope.createMouseObserver(() => tui.requestRender()); -``` - -Pass `observer` around the root's native mouse dispatch; reuse `panel` as custom or overlay content. -Pointer input is fullscreen-only. Regions preserve a consuming child's native result and do not focus -`Text`, activate on press or wheel, synthesize outside leave events, or alter terminal tracking. -Callers own keyboard policy, theme state, and business actions. - -**Migration note:** Do not enable `pi-tool-cards` and `quiet-tools` together: Pi rejects duplicate `bash`, `read`, `edit`, and `write` registrations. Disable or remove the standalone package during migration; gentle-pi does not change those package registrations or delete that repository. The global fullscreen setting described below is a separate install-time change. - -## Install - -```bash -pi install npm:gentle-pi@0.14.0 -``` - -### Install-time fullscreen - -For this release, a successful postinstall in Pi's **global npm-managed** `agent-home/npm/node_modules/gentle-pi` installation persists `"tuiMode": "fullscreen"` in `agent-home/settings.json`, preserving other settings. Agent home resolves through `GENTLE_PI_AGENT_HOME`, then `PI_CODING_AGENT_DIR`, then `~/.pi/agent`. Use `/settings` to switch back to regular; rerunning this recognized postinstall resets it to fullscreen. Existing project overrides still take precedence. - -Project-local installs (`pi install -l`), Git/local-path installs, temporary packages, development checkouts, ordinary npm consumers, and pnpm symlink-store packages do **not** receive this change. Updates or installs that do not execute postinstall cannot reassert it; this is not a universal install/update guarantee or a change to historical releases. - -Malformed/nonobject JSON, symlink/nonregular settings, unsafe paths, or a busy settings lock fail without replacing settings. The installer coordinates with Pi's cooperative settings lock and uses atomic replacement; it does not guarantee safety against noncooperating writers or malicious concurrent directory replacement. Already-fullscreen settings remain byte-identical. Native installation failure leaves settings untouched; `GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1` skips only native provisioning, not the recognized global fullscreen setting. - -### RDD version policy - -Native RDD started in `gentle-pi` `v0.15.0` on 2026-07-10 with bounded review transactions. Every release from `v0.15.0` onward is part of the unstable RDD development line. New releases will continue improving RDD until the project declares the line stable. The stable version for normal use without native RDD is the last preceding release, `v0.14.0`. - -```bash -# Stable version without native RDD -pi install npm:gentle-pi@0.14.0 - -# Latest released RDD build (unstable) -pi install npm:gentle-pi@latest -``` - -The latest RDD package installs Gentle AI only into its private `.gentle-ai/` directory. Darwin and Linux use pinned release assets with asset and executable SHA-256 verification (signed archives for stable pins such as the current v2.7.0; raw prerelease binaries only under a prerelease pin). Windows x64 and arm64 build the exact `v2.7.0` source tag with a local Go 1.25.10+ toolchain, a sealed Go environment, `GOTOOLCHAIN=local`, and `GOSUMDB=sum.golang.org`; it does not download Go automatically. Windows provenance is Go-toolchain plus SumDB evidence and postinstall tamper detection, **not** Authenticode or protection against a malicious joint binary-and-manifest replacement. Package-private locks coordinate cooperative concurrent or crashed installers; their tombstones fail closed. A malicious same-user process with write access to package-private `node_modules` is outside that protocol because it can already replace package code, binary, or manifest, and portable Node has no pathname-delete CAS. It never uses `PATH` or a global `gentle-ai` installation. For development or offline installs only, set `GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1`; native review operations then fail closed with an actionable `package-local-binary-missing` error until the package is reinstalled normally. - -Recommended companion packages: - -```bash -pi install npm:pi-intercom -pi install npm:gentle-engram -pi install npm:pi-web-access -pi install npm:pi-lens -pi install npm:@juicesharp/rpiv-ask-user-question -``` - -Then start Pi in a project: - -```bash -pi -``` - -`gentle-pi` provides SDD agents as global Pi runtime assets, not per-project setup. The first SDD flow in a session still runs a one-time SDD preflight for preferences; for natural-language requests, el Gentleman decides when SDD is needed and runs the explicit preflight first. - -## Quick start - -```text -/gentle:status Check package, SDD assets, OpenSpec, and global model config. -/gentle:doctor Run read-only diagnostics for SDD assets, config, tools, and guards. -/gentle:sdd-preflight Run or reuse the session SDD preflight explicitly. -/gentle-sdd-init Create or refresh openspec/config.yaml (openspec/both stores only). -/gentle:models Assign global model/effort routing to SDD/custom agents. -/gentle:persona Switch between gentleman and neutral persona modes. -/gentle:background-subagents Show or set the managed background-subagents policy, with its deciding source. -/gentle:banner Configure startup rose, text logo, and color preset. -``` - -Typical flow: - -1. Open Pi in your repo. -2. Run `/gentle:status`. -3. Run `/gentle-sdd-init` once per project, or when test/project capabilities change. This also runs the session SDD preflight. -4. For a substantial change, ask Pi to use SDD. Natural-language requests are classified by the parent agent, not by brittle runtime regexes. -5. Review the phase artifacts instead of trusting floating chat context. - -## Core workflow - -1. **Install and inspect.** Install `gentle-pi`, open Pi in the target repository, then run `/gentle:status` or `/gentle:doctor`. -2. **Plan when risk justifies it.** Small work stays direct; substantial work uses SDD with Engram, OpenSpec, or both so requirements and decisions survive compaction. -3. **Build with evidence.** One focused writer implements the approved scope. When Strict TDD is available, apply and verify preserve RED → GREEN → TRIANGULATE → REFACTOR evidence. -4. **Use runtime-owned RDD when available.** Gentle AI supplies any runtime-specific review instructions; this package does not recreate a lifecycle in documentation or prompts. -5. **Deliver through ordinary repository policy.** Review and Judgment Day evidence is informational only; Pi never creates a delivery route, authorization, target rederivation, or receipt gate. - -> **Trust what the system can derive, not what an agent claims.** Agents analyze the candidate. The package-local Gentle AI runtime owns scope, risk, findings, and review authority. Review outcomes inform delivery; ordinary repository policy decides delivery commands. Dangerous-command safety and destructive-review consent remain independent. See Gentle AI's [review authority threat model](https://github.com/Gentleman-Programming/gentle-ai/blob/main/docs/review-authority-threat-model.md) and [Chapter 21 — Verifiable Trust](https://the-amazing-gentleman-programming-book.vercel.app/en/book/Chapter21_Verifiable-Trust). - -## How the harness decides what to do - -`gentle-pi` routes through the smallest safe workflow: - -| Request shape | Harness | -| --------------------------------------------------------------------------- | ---------------------------- | -| Small, clear, local edit | Inline direct work. | -| Unknown codebase area or context-heavy investigation | Focused subagent delegation. | -| Large, ambiguous, architectural, product-facing, or high-review-risk change | SDD/OpenSpec flow. | - -The goal is not ceremony. The goal is to avoid accidental chaos. Once a task stops being small, delegation is mandatory. - -### Delegation triggers - -`gentle-pi` keeps the parent session thin and delegates at the narrowest useful point. When the Pi Subagents extension is installed, the preferred runtime is the `subagent_*` tool family because it runs the user's configured project/global subagent definitions and preserves history/background behavior. With the background policy on, delegations default to background mode: the terminal stays free and each result comes back as a message that starts a new turn; task mode is reserved for delegations that must ask the user something mid-flight. If those tools are unavailable, the parent should fall back to Pi's native `Agent` tool or another available delegation mechanism. The requirement is delegation; the runtime is capability-dependent. - -| Trigger | Required behavior | -| --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| Reading 4+ files to understand a flow | Launch `scout`, `context-builder`, or the closest read-only mapping subagent. | -| Touching 2+ non-trivial code files | Delegate one writer; do not continue inline unless delegation is unavailable. | -| Commit, push, or PR after code changes | Follow the loaded native instruction, or ordinary repository policy when none is supplied. | -| Wrong cwd, worktree/git accident, merge recovery, confusing test/env issue | Stop, preserve the affected scope, and investigate separately before resuming. | -| Long monolithic session with accumulating complexity, roughly 20 tool calls, 5 exploratory reads, or 2 non-mechanical edits | Pause and delegate the remaining work, or stop and explain the exact blocker. | - -The intended balanced loop for a bounded bugfix is: - -```text -parent git/status + clarify → one worker writes authorized fixes → focused verification → parent reports -``` - -`scout`/`context-builder` save parent context by compressing broad exploration. `worker` preserves a single writer thread. Any RDD-specific actor behavior belongs to the runtime instruction supplied by Gentle AI, not to this README. - -### Review authority recovery and reset safety - -Legacy pre-graph authority is never migrated. `gentle_review inspect` reports an exact repository-bound destructive reset challenge for legacy corruption; after that fresh interactive authorization, RESET and RECOVER_LOCK route to the audited native `gentle-ai review reclaim` operation and RECOVER routes to native `gentle-ai review recover`, so every destructive transition is executed and audited by the native authority store. Native inputs the request did not carry return a `native-input-required` envelope instead of being invented. Existing graph-v1 ordinary lineages remain readable and gate-validatable but are read-only; Judgment Day remains mutable on graph-v1. - -`gentle_review abandon`, `quarantine-legacy`, and `reconcile-authority` remain explicit v2.1.11 maintenance routes. Pi derives and displays the published nine-line `gentle-ai.review-abandon-authorization/v2` binding only for a caller-specified compact lineage, revision, snapshot identity, and discarded-work summary (captured lens results, findings presence, evidence-record presence); the native CLI re-derives non-terminal compact-v2 eligibility and the exact discarded work before accepting it. Legacy quarantine accepts only `historical findings freeze changed unrelated transaction state` with disposition `quarantine-malformed-freeze-event` and uses its exact eight-line binding. Both require fresh interactive approval and fail closed headlessly. - -`gentle_review reconcile-authority` accepts one predecessor lineage and revision, one successor lineage and revision, an actor, and a reason. Pi derives the exact seven-line `gentle-ai.review-reconcile-authorization/v1` binding, or appends exactly `anomalies=unchanged_target,malformed_recovery_authorization` for the published dual anomaly in that order. Native code re-derives every anomaly; malformed bindings, changed revisions, unavailable native support, cancellation, and native refusal fail closed through typed envelopes. - -Reconciliation is intentionally narrow: native code may quarantine only the bound invalid compact-v2 recovery successor and persists the returned audit record; the predecessor stays untouched. Pi never recreates the retired `prepare-supersession`/`supersede` authority writer and never falls back to RESET or RECOVER. - -`gentle_review repair-legacy-alias` is the sole v2.1.11 route for `unsupported historical v1 operation alias`. The model supplies only lineage, actor, and reason. Pi freshly reads the native inventory, derives the canonical repository, exact legacy revision, fixed diagnostic, and fixed `quarantine-approved-historical-alias` disposition, displays the LF-only eight-line binding, and requires a new interactive approval. Native re-derives eligibility and quarantines rather than rewriting or validating the historical chain. - -`review dispose-result` is deliberately unsupported by Pi pending a separate design; it has no controller operation or fallback. All maintenance routes fail closed headlessly and never auto-run against legacy history. - -Native lifecycle status remains informational. VALIDATE does not authorize delivery; commit, push, PR, and release commands follow ordinary repository policy. Recovery grants no new budget, and legacy graph bundle export/import is retired. - -This is the post-U8 boundary, not the final architecture. [Issue #191](https://github.com/Gentleman-Programming/gentle-pi/issues/191) is the immediate final unit in this same delivery: extract the remaining Pi command-projection and lifecycle-gate surface from `review-transaction.ts`, repoint runtime enforcement, then delete only dependencies proven unreachable without weakening graph-v1 Judgment Day. The branch-wide High-tier 4R runs after that extraction, before the single size-exception PR. - -### Review Lens Selection (architecture reference) - -`reviewer` is not an installed subagent name. It is historical routing vocabulary, not a static instruction. When a runtime-specific Gentle AI instruction applies, it alone determines whether any concrete lens is used: - -| Context | Review lens | -| --- | --- | -| Clear naming, structure, maintainability, small refactors | `review-readability` | -| Behavior, state, tests, determinism, regressions | `review-reliability` | -| Shell/process integration, partial failures, recovery, degraded dependencies | `review-resilience` | -| Security, permissions, data exposure/loss, architecture, dependencies | `review-risk` | -| Large PR, hot path, or >400 changed lines | Full 4R: `review-risk`, `review-resilience`, `review-readability`, `review-reliability` | - -The former compact controller classified documentation/comment/formatting-only changes as zero-lens, standard changes as one dominant lens, and higher-risk paths as full 4R. This describes compatibility architecture only; never derive or run those choices from this README. - -### Review authority architecture (reference only) - -Gentle AI dynamically supplies runtime-specific RDD instructions. `gentle-pi` does not define an RDD lifecycle, command route, approval path, recovery sequence, or fallback. The historical compact-controller material below documents architecture and compatibility boundaries only; it is not an operator instruction. - -Concretely: `gentle-pi` mirrors the Gentle AI provider contract bundle's `orchestration/pi.md` locally (`contracts/review-provider-contract-mirror/`, verified against the mirror lock's recorded SHA-256 before injection) and injects that mirrored text into the primary session's system prompt at session start. Gentle AI does not write anything into Pi's system prompt; when the mirrored contract is absent, unreadable, or fails digest verification, `gentle-pi` invents no fallback lifecycle. - -```mermaid -flowchart TD - A["Clarify scope and acceptance criteria"] --> B{"Choose the smallest safe workflow"} - B -->|Small and local| C["Inline implementation"] - B -->|Context-heavy or multi-file| D["Focused subagent"] - B -->|Large or architectural| E["SDD phase artifacts"] - C --> F["Implement with test evidence"] - D --> F - E --> F - F --> G["Independent verification"] - G --> H["Target-scoped native status"] - H -->|Ambiguous or corrupted| X["Blocked: native maintainer action"] - H -->|Unrelated| I["START freezes candidate, scope, tier, lenses, and budget"] - - subgraph Ordinary_review["Ordinary bounded review"] - I --> R["reviewing"] - R --> J["Run each selected lens once"] - J --> K{"Severe candidate-caused blocker?"} - K -->|No| A1["approved"] - K -->|Yes| C1["correction_required"] - C1 --> C2["Forecast bounded correction"] - C2 --> C3["Apply scoped fix"] - C3 --> V["validating"] - V -->|Validator passes| A1 - V -->|Fails, malformed, or out of scope| E1["escalated"] - end - - A1 --> O["Review outcome is informational"] - E1 --> O -``` - -VALIDATE is informational. Commit, push, PR, and release commands follow ordinary repository policy; RDD never authorizes, rewrites, consumes review state for, or blocks them. Dangerous-command safety and destructive-review consent remain independent. - -Native contract pairing is exact: this adapter resolves only the integrity-verified package-local Gentle AI v2.7.0 executable, independently hashes it, then negotiates `gentle-ai.review-integration/v2` outside the repository. Capabilities are cached by that executable digest. Every START, target status, FINALIZE, validate, and BIND-SDD request passes the same contract identifier. Negotiated envelopes decode exactly against the vendored schemas; `recover` routes only the provider-selected `action_disposition`, and optional additions require a future compatible schema/minor that the provider explicitly advertises and the consumer negotiates. - -Contract `/v2` replaces the Base64 `candidate_diff` reviewer transport of `/v1` with immutable `base_tree`/`candidate_tree` plus an ordered `changed_path_manifest` and never an inline patch. `gentle-pi` negotiates `/v2` only, with no dual-lane fallback; the cutover landed as one atomic commit against gentle-ai v2.2.2 (tracked by the `migrate-review-integration-v2` change), and the `/v1` schemas stay packaged because the `/v2` schemas `$ref` into their fragments. This provider contract version is unrelated to Pi's own internal "compact-v2" review-authority naming used below — the shared digit is coincidental, not a version pairing. - -Target status owns `current_target`, `unrelated`, `ambiguous`, and `corrupted` applicability and returns one native action. Pi does not reconstruct ordinary authority from provider-private files or choose a lineage from repository-wide history. Restart recovery rebuilds only the derived candidate view from the native Git/content projection, including intended-untracked paths, symlinks, and immutable gitlink identities. Native failure envelopes retain their exact mutation outcome, replayability, required inputs, request digest, and next action. After an unknown or lost mutating result, Pi calls target status before any replay decision and returns only the provider-declared action. - -Once the pinned gentle-ai runtime (currently v2.7.0) has written review authority, rollback MUST preserve every native store and receipt and MUST NOT run a downgraded binary against that repository. Disable the Pi route or roll forward to a compatible authority-aware release instead; deleting authority data or reinstalling an older binary is not a rollback path. - -### FINALIZE wrapper input - -`gentle_review` accepts `input` as a JSON-serialized object string. For initial results, provide `review_result.lens_results[]`; each selected lens appears exactly once with `lens`, `findings`, and non-empty `evidence`. A clean lens uses `findings: []`. Pair `final_evidence` with exactly one of `final_verification_passed` or `final_verification_outcome`. - -```json -{ - "review_result": { - "lens_results": [ - { - "lens": "review-reliability", - "findings": [], - "evidence": ["complete candidate reviewed"] - } - ] - } -} -``` - -This is the Pi wrapper contract, not the native CLI file contract. The native command receives separate `--result`, `--refuter`, `--validation`, and `--evidence` files from the wrapper. - -START derives the complete Git/untracked snapshot, lineage, persisted `low | medium | high` tier, zero/one/four lenses, authored changed lines, and correction budget `min(200, ceil(original_changed_lines / 2))`. Generated `testdata/golden/**` stays in snapshot identity but does not count as authored risk lines. - -Every finding requires `evidence_class`, `causal_disposition`, and concrete changed-hunk, candidate-created-path, differential-test, or before/after proof. Missing IDs are assigned natively and selected-lens results are canonicalized deterministically. - -Actor output is untrusted data and cannot authorize transitions, fixes, receipts, gates, or delivery. - -Only severe `introduced`, `behavior-activated`, or `worsened` findings with valid proof enter correction IDs. `pre-existing` and `base-only` become follow-ups; `unknown`, insufficient, malformed, or inconclusive severe claims escalate. WARNING and SUGGESTION are informational. - -Deterministic blockers need no refuter. Inferential blockers use exactly one complete read-only refuter batch. - -Refuter proof may be independent concrete reproduction evidence; it does not need to duplicate reviewer `proof_refs`. Invalid, empty, malformed, missing, duplicate, unknown, or inconclusive refuter output escalates without a replacement refuter. - -When native IDs are assigned to inferential findings, the first FINALIZE returns their canonical rows and a content-derived request hash without mutation; the second replays identical lens input with that hash and one complete refuter batch. - -Ordinary permits one correction transaction within the original budget. FINALIZE requires a positive forecast before editing and derives actual correction lines from Git; one targeted validator and final verification close that transaction. Initial lenses are never rerun, while frozen findings and genesis scope remain unchanged. - -The validator checks original criteria and correction regression only and cannot add scope or findings. Final evidence is hashed during FINALIZE, never at START. - -Compact ordinary has five states: `reviewing`, `correction_required`, `validating`, `approved`, and `escalated`. - -The validator cannot change claims, add findings, request fixes, launch actors, or request another attempt. A failed correction escalates instead of opening another review budget. - -Compact authority uses content-derived CAS under the Git common directory. Exact retries are idempotent; stale/semantic retries, terminal mutation, and same-lineage graph-v1/compact-v2 ambiguity fail closed. - -Trust boundary: The local orchestrator and same-user process are trusted to execute selected actors and submit their exact outputs. Native code owns scope, risk, IDs, canonicalization, state, receipts, and gates, and rejects malformed or inconsistent results structurally and causally. Malicious same-user host/process authenticity is a non-goal because that actor can replace the extension or mutate local authority; externally trusted attestation would require a separately privileged signer/service and is not claimed. - -Ordinary ends only as `approved` or `escalated`. +
-Judgment Day starts only when explicitly requested and replaces ordinary review for that lineage. +Built for Pi. Shaped by Gentle-AI. -Judgment Day starts with exactly two blind judges and zero refuters. - -Judgment Day alone may iterate discovery and scoped re-judgment, for at most two rounds. - -Findings surviving round two escalate; no third-round transition exists. - -Native review mode and the two candidate choices remain provider-owned lifecycle semantics. For a validated `consent/v3` envelope in the interactive parent TUI, Pi displays those two choices unchanged and adds a clearly separate host-owned action: **Run this review and allow reviews for this Pi session**. Only direct human selection creates this process-memory grant. Its scope is the coordinating live SessionManager session and the canonical Git common-directory identity of the selected repository: it runs the current envelope's exact provider `granted` invocation through the existing one-shot `answer-consent` path, then does the same for later fresh validated envelopes in sibling worktrees of that same clone, including package-owned children. An unrelated repository requires a separate explicit human grant. Reload preserves it; `/tree` retains it; revoke removes the current repository grant; quit, new, resume, fork, or process restart removes all session grants. The command's `status` action reports the in-memory state without changing provider mode or authority. - -The host grant is held only in a schema-checked `globalThis[Symbol.for(...)]` WeakMap registry keyed by session and canonical Git common-directory digest. It is never written through session entries, settings, environment variables, or the old asked latch. A package-owned Gentle Agents child can request one bounded parent-owned stdio authorization for its own validated pending ordinary START; it sends only that target's canonical repository digest, and the parent rechecks the live task, digest, and current parent session grant before the child replays its exact provider grant locally. No candidate bytes, provider vectors, paths, local child grant, or delivery authority crosses that channel. External or legacy `pi-subagents` launchers do not receive this channel and remain unsupported. Headless/RPC/unsupported UI, external processes, model prose, tool arguments, cancellation, identity drift, malformed identity, and uncertain native results cannot create or consume the grant. Native workspace binding remains canonical and target-specific; session-wide consent never authorizes an unselected target or an unrelated repository. The grant conveys no review verdict, forecast/cost approval, acknowledgement, maintenance, delivery, or cross-repository authority. When the host cannot resolve the choice, `gentle_review` returns the original unresolved two-choice provider envelope unchanged for the normal lossless relay. SessionManager binding isolates simultaneous SDK sessions; Pi does not claim universal same-process agent-principal isolation because the SDK exposes no principal identity. - -When RDD is on and an agent loop ends with an unreviewed candidate, `gentle-pi` sends one read-only reminder pointing the agent back to `gentle_review {"operation":"inspect"}` before it reports completion. This nudge is idempotent (at most once per target identity per session), never fires for a headless session or a subagent's own loop, and never runs START or answers consent itself. Pi treats a child `agent_end` as a latest-answer update, not completion: queued retry, compaction, follow-up, required verification, and legitimate post-correction verification remain live until `agent_settled`. It does not claim ready or RDD-ready first, but this ordering rule does not impose a universal full-suite requirement or turn a receipt into a delivery gate. At session start, `gentle-pi` records the current target identity as a baseline, so a candidate that already existed before the session began (the user's own prior work, not this session's output) never draws the reminder. - -Review outcomes and receipt state are informational; commit, push, pull-request, and release delivery follow ordinary repository policy. No one-shot command authorization, publication-target revalidation, or receipt gate is required for delivery, and Pi does not inspect RDD mode or native authority to decide a Bash delivery command. - -Dangerous-command safety remains independent and authoritative. Destructive-review-maintenance consent remains separate from delivery. Review operations, informational VALIDATE, and SDD perform no commit, push, pull-request, release, or publication operation. - -The Pi host relay bounds each locked-down reviewer subprocess by materialized prompt size rather than by one fixed number: a 15-minute floor plus 15 minutes per mebibyte of prompt, clamped to a 2-hour ceiling. Set `GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS` to a positive decimal to replace that derived bound with your own; malformed values are ignored and the same 2-hour ceiling still applies, so no configuration turns a foreground finalize into an unbounded child process. A reviewer killed by the bound reports `pi-host-relay-timeout` with the elapsed time and the limit it was measured against, and it explicitly does not ask you to relaunch the identical slot — that would re-spend the model tokens to reach the same wall. Reviewer results admitted earlier in the same finalize stay admitted and are not re-run. - -Adversarial review roles (the refuter and the targeted validator) are never Pi-authored: the provider renders self-contained `review.capture-refuter` / `review.capture-validation` vectors and Go runs its own locked-down `pi` process on them. Package agent assets remain a package-managed isolated installation. Project and user overrides may shadow a package asset; `gentle-pi` preserves those definitions and does not claim their effective permissions are package-compliant. - -## SDD/OpenSpec flow - -```text -init - ↓ -explore → research (optional) → proposal → spec ─┬→ design ─┐ - └─────────┴→ tasks → apply → verify → sync → archive -``` - -The main loop is intentionally file-backed when you choose `openspec` or `both`: - -```text -planning artifacts implementation evidence canonical update -────────────────── ─────────────────────── ──────────────── -proposal/spec/design/tasks → apply-progress/verify-report → sync-report → archive-report -``` - -For substantial work, the parent session coordinates the flow and each phase writes artifacts. That gives you: - -- explicit requirements and non-goals; -- design decisions that survive compaction; -- task plans reviewers can reason about; -- implementation evidence; -- verification reports; -- sync reports that update canonical specs while keeping the change active; -- archive notes for future agents. - -### OpenSpec artifact model - -`gentle-pi` treats OpenSpec-compatible behavior as part of the harness. You do not need to install the external OpenSpec CLI/package for SDD. - -In file-backed modes, canonical accepted behavior lives in `openspec/specs/`, while active changes carry deltas under `openspec/changes/`: - -```text -openspec/ -├── specs/ # accepted source of truth -│ └── {domain}/spec.md -└── changes/ - ├── {change}/ # active work - │ ├── proposal.md - │ ├── specs/{domain}/spec.md # full spec or delta spec - │ ├── design.md - │ ├── tasks.md - │ ├── apply-progress.md - │ ├── verify-report.md - │ └── sync-report.md - └── archive/YYYY-MM-DD-{change}/ # immutable audit trail -``` - -Delta flow: - -```text -openspec/changes/{change}/specs/{domain}/spec.md - │ - │ sdd-sync applies ADDED / MODIFIED / REMOVED - ▼ -openspec/specs/{domain}/spec.md - │ - │ sdd-archive moves the completed change folder - ▼ -openspec/changes/archive/YYYY-MM-DD-{change}/ -``` - -When a canonical spec already exists, change specs use requirement operation sections: - -```markdown -## ADDED Requirements - -## MODIFIED Requirements - -## REMOVED Requirements -``` - -`MODIFIED` requirements must include the full requirement block, including still-valid scenarios, because sync replaces the canonical block by requirement name. `sdd-sync` syncs file-backed deltas into `openspec/specs/{domain}/spec.md` while keeping the change active; `sdd-archive` then moves the synced change to `openspec/changes/archive/YYYY-MM-DD-{change}/`. - -Engram-only mode is different by design: Engram is working memory and does not maintain a canonical spec merge layer. Use `openspec` or `both` (hybrid file + memory persistence) when you need canonical spec evolution. + -## SDD preflight and project files +

+ +

-`gentle-pi` does not require SDD agents to be copied into every project. The package ensures global Pi SDD assets exist under the Pi agent home and treats project-local files only as overrides/debug copies. Slash SDD flows such as `/sdd-*`, `/gentle-sdd-init`, and the explicit `/gentle:sdd-preflight` command run a lazy preflight and resolve session-scoped SDD preferences. For natural-language requests, the parent agent decides whether the work should use SDD and must run/reuse `/gentle:sdd-preflight` before continuing. +## Features -```text -~/.pi/agent/agents/sdd-*.md -~/.pi/agent/chains/sdd-*.chain.md -~/.pi/agent/gentle-ai/support/strict-tdd*.md -``` +--- -Preflight values resolve in this order: explicit current user/session choice, valid persisted preference, capability or already-selected strategy constraint, canonical default, then a prompt only when genuinely unresolved. Resolved values are reused for later SDD flows in the session. +### gentle-shell — Your coding agent, in the workspace you lead -Canonical values are `auto` execution mode, `openspec` artifact store, `ask-on-risk` delivery strategy, and a `400` changed-line review threshold. The delivery strategy domain is `ask-on-risk`, `auto-chain`, `single-pr`, or `exception-ok`; `chain_strategy` remains deferred until chaining is selected. `exception-ok` requires explicit `size:exception` acceptance and is never inferred. Consent, authorization, security, destructive/publishing, interactive phase approval, and ambiguous-scope gates remain human-controlled. +gentle-shell running a live agent session: a header row with branch, model, and context gauge above the transcript, with status, changes, and todo cards in the right rail -It does **not** overwrite existing global assets unless you explicitly run: +A bare terminal answers "what is the agent doing?" only with scrollback. gentle-shell turns your Pi session into a workspace: agent orchestration, live changes and runtime status, usage monitoring for supported provider accounts, and built-in diff views — so you lead the work instead of chasing it. -```text -/gentle:install-sdd --force -``` +

gentle-shell in action. Screenshot from Gentle-AI.

-Manual preflight command: +**[Docs →](docs/gentle-shell.md)** -```text -/gentle:sdd-preflight -``` +--- -## Skill registry +### el Gentleman — Think before you build -`gentle-pi` keeps a local registry at: +Say what you need once, then keep moving. el Gentleman helps turn intent into clear scope, a sensible next step, and evidence people can review — without making every task feel like a process meeting. -```text -.atl/skill-registry.md -``` +**[Docs →](docs/readme-reference.md#organic-driven-development)** -The registry scans project and user skill roots, not package-owned skills. It exists to catch workflow skills that are present on disk but not visible in Pi's injected skill list. +--- -It scans common roots such as: +### Focused agents — Context with a return path -```text -./skills -.opencode/skills -.claude/skills -.gemini/skills -.cursor/skills -.github/skills -.codex/skills -.qwen/skills -.kiro/skills -.openclaw/skills -.pi/skills -.agent/skills -.agents/skills -.atl/skills -~/.pi/agent/skills -~/.config/agents/skills -~/.agents/skills -~/.kimi/skills -~/.config/opencode/skills -~/.config/kilo/skills -~/.claude/skills -~/.gemini/skills -~/.gemini/antigravity/skills -~/.cursor/skills -~/.copilot/skills -~/.codex/skills -~/.codeium/windsurf/skills -~/.qwen/skills -~/.kiro/skills -~/.openclaw/skills -``` +Diagram of one parent session directing bounded map, implementation, and verification work and receiving evidence back -Behavior: +Bring in help without losing the thread. Focused package-owned Pi agents can map a codebase, implement a bounded change, or verify it, while one parent stays accountable for the scope, the decisions, and the final summary. -- `.atl/` is added to `.gitignore` when needed; -- the registry refreshes on session start; -- startup refresh is skipped when Pi starts with `--no-skills` / `-ns`, `--no-skill-registry`, or `GENTLE_PI_NO_SKILL_REGISTRY=1`; -- `/skill-registry:refresh` forces regeneration; -- a best-effort watcher refreshes when skill files change; -- the registry indexes skill names, full descriptions, scope, and exact `SKILL.md` paths without copying skill body rules. +**[Docs →](docs/readme-reference.md#how-the-harness-decides-what-to-do)** -Skill discovery is a guardrail, not a workflow router: it helps Pi load the right skill without forcing extra ceremony. +--- -`gentle-pi` also ships package-owned `gentle-ai-skill-creator` and `gentle-ai-skill-improver` skills plus the `/skill-creation` prompt for creating or updating project skills. Both skills use `docs/skill-style-guide.md` as their normative style contract. The workflow checks for duplicates, keeps `SKILL.md` concise, uses one-line trigger-rich frontmatter, and reminds maintainers to refresh the registry after skill changes. +### ODD — The everyday workflow -Packaged skills include `cognitive-doc-design`, `comment-writer`, `gentle-ai-judgment-day`, `gentle-ai-skill-creator`, `gentle-ai-skill-improver`, and the other delivery/review skills under `skills/`. SDD init is installed as the packaged `sdd-init` runtime agent under `assets/agents/` and refreshed with the SDD assets. +Organic Driven Development as seven numbered steps: Authorize, Explore, Resolve uncertainty, and Classify across the top row; Classify forks, so small understood work stays light while substantial work gets step five, Track, with one feature document; both paths converge on Implement task by task and then Close, above a dashed band marking that one feature document mirrored in Engram lets work resume across sessions -Compatibility: the package keeps the existing skill folders (`skills/branch-pr`, `skills/cognitive-doc-design`, `skills/comment-writer`, `skills/judgment-day`, `skills/skill-creator`, `skills/skill-registry`, and `skills/work-unit-commits`) but their exported frontmatter names are prefixed to avoid collisions with user/global skills. Treat former package names such as `branch-pr`, `cognitive-doc-design`, `comment-writer`, `judgment-day`, `skill-creator`, `skill-registry`, and `work-unit-commits` as legacy aliases in prose; runtime skill selection should use `gentle-ai-branch-pr`, `gentle-ai-cognitive-doc-design`, `gentle-ai-comment-writer`, `gentle-ai-judgment-day`, `gentle-ai-skill-creator`, `gentle-ai-skill-registry`, and `gentle-ai-work-unit-commits`. +**Organic Driven Development (ODD)** is the everyday path: the agent explores before changing anything, clarifies only real decisions, and keeps small understood work small. Substantial, authorized work gets one recoverable feature document — mirrored in memory when available — so progress, evidence, and the next step survive an interruption; checks follow the configured TDD mode. -Delegation contract: +**[Docs →](docs/readme-reference.md#organic-driven-development)** -- parent/orchestrator resolves project/user skills from the registry and passes matching paths under `## Skills to load before work`; -- SDD subagents still use their assigned executor/phase skill; -- during normal runtime, subagents should not independently discover additional project/user `SKILL.md` files or the registry; -- fallback loading is degraded self-healing and must be reported via `skill_resolution` as `fallback-registry`, `fallback-path`, or `none`. +--- -## Persona modes +### Native review — Review the exact change -```text -/gentle:persona -``` +Diagram showing one frozen candidate passing through risk-scoped native review to an outcome, while human delivery choices stay separate -| Persona | Behavior | -| ----------- | ------------------------------------------------------------------------------------------------------------- | -| `gentleman` | Senior architect, teacher, direct technical feedback, Rioplatense Spanish/voseo when the user writes Spanish. | -| `neutral` | Same discipline, warmer professional language, no regional expression. | +Review the exact change, not a moving target. Native review keeps one candidate in view, returns risk-scoped evidence, and can surface a bounded correction path. You still decide what happens next in your repository. -Saved globally at: +**[Docs →](docs/review-integration.md)** -```text -~/.pi/gentle-ai/persona.json -``` +--- -A project can still override the global default with: +### Gentle Changes — Every edit, attributed and reviewable -```text -.pi/gentle-ai/persona.json -``` +Gentle Changes viewer: worktree accordion with per-file status on the left, the captured diff with line counts on the right, and a keyboard hint row -`/gentle:persona` writes the global config and updates an existing project override when one is present, so the current project does not stay stale. Run `/reload` or start a new Pi session after switching persona. +You should not have to run `git status` to find out what your agent did. Gentle Changes captures the successful write and edit tool calls from the current session and its owned subagents — no repository scans, no background polling — and shows them in a two-pane viewer with per-file line counts and an honest **diff unavailable** when an external edit breaks continuity. Coverage stops at those tools, so shell commands and failed runs leave no row, and a missing entry never proves a clean tree. `alt+g` opens it; `o` drops the real file into your editor. -## Model and effort assignment +**[Docs →](docs/gentle-shell.md#browse-captured-diffs)** -```text -/gentle:models -``` +--- -The modal discovers: +### Gentle Agents — Parallel work with a live view -- project agents in `.pi/subagents/`, `.pi/agents/`, and `.agents/`; -- user agents in `~/.pi/agent/subagents/`, `~/.pi/agent/agents/`, and `~/.agents/`. +Gentle Agents overlay showing a completed subagent thread with model, tokens, and elapsed columns, and the structured handoff it returned -When applying routing, project agents write runtime profiles to `.pi/subagents.json`; global and built-in agents write profiles to `~/.pi/agent/subagents.json`. +Delegating work should not mean losing it. Every subagent runs as its own process with a live card above the editor — model, tokens, cost, elapsed — and `alt+a` opens the full view with retained threads, stop controls, and history restored on resume. A child can ask you a question as an ordinary dialog, and background results come back as cards that start a new turn — nothing polls. -Recommended model/effort shape: +**[Docs →](docs/gentle-shell.md#gentle-agents)** -| Agent kind | Recommended model | Recommended effort (`thinking`) | -| -------------------------- | ---------------------------------------------------- | ------------------------------- | -| Explore, proposal, archive | Fast and cheap is usually enough. | `off` to `low` | -| Spec, design, tasks | Strong reasoning model. | `medium` to `high` | -| Apply | Strong coding and tool-use model. | `medium` to `high` | -| Verify / review | Strong fresh-context model. | `high` | -| Tiny utilities | Inherit active/default model unless they bottleneck. | `inherit` | +--- -Saved globally at: +### Profiles and model routing — One deliberate decision per knob -```text -~/.pi/gentle-ai/models.json -``` +Profiles view: profile list on the left, orchestrator model and effort on the right, with per-role profile routing and effective current routing -Existing project-local `.pi/gentle-ai/models.json` files are still read as a legacy fallback when no global model config exists, but `/gentle:models` writes the shared global config. +Model, effort, and who does what should be choices, not accidents. Named profiles route the orchestrator atomically and independently from packaged and review roles; a repository can pin its profile so its subagents stop following the globally active one, and the panel always shows the routing the runtime actually uses. -Inside `/gentle:models`, press `x` to export the saved routing to `~/.pi/gentle-ai/models.export.json`, or `r` to restore from that file after confirmation. Export uses a versioned envelope and restore writes the normal `models.json` shape before applying routing to agents. +**[Docs →](docs/readme-reference.md#agent-model-profiles)** -Config shape (per agent): +--- -```json -{ - "sdd-design": { - "model": "anthropic/claude-sonnet-4", - "thinking": "high" - }, - "sdd-archive": { - "model": "openai/gpt-5-mini" - } -} -``` +### Command palette — Every command, one keystroke away -Legacy string entries are still accepted and treated as `model`-only config. +Extension commands are only useful if you can find them. `alt+k` opens a curated, grouped palette — Configuration, Session, Diagnostics, and Skills — searchable by label, command name, or description, showing entries only when they are actually registered. -## Gentle Shell +**[Docs →](docs/gentle-shell.md#command-palette)** -Gentle Shell is the visual layer gentle-pi puts on top of pi. It follows the Gentle themes: one border language, champagne titles, rose for whatever is alive. +--- -In fullscreen at 140 columns or wider, the right sidebar scrolls **✿ Gentle-Pi ✿ → Status → Changes → Agents → TODO** together. The one-line heading is horizontally centered within the usable rail width, with pink flowers and normal white text in the Gentleman themes. Colors follow the active theme; no artwork scaling or custom fonts are used. Narrow/mobile terminals and regular mode retain bottom widgets without the sidebar heading. The original rose and text logo remain in the main chat startup intro. +### Also in the box -The status bar replaces pi's three-line footer with a single line of segments: +| Component | What it does | +| :--- | :--- | +| Startup and runtime panel | A configurable gentle-shell entry point and visible runtime state for Pi. | +| Skills and delivery guidance | Package skills for documentation, issue work, PRs, reviews, and reviewable work units. | +| Model, effort, persona, and profile controls | Explicit knobs for how Pi routes and presents work. | +| Safety boundaries | Guards around destructive operations and sensitive-path handling. | +| Optional companion packages | Extra capabilities you may choose to add; persistent memory is **not** bundled with `gentle-pi`. | +| Fullscreen workspace layout | Header row plus a scrolling Status → Changes → TODO rail on wide terminals. | +| Live status bar and prompt petal | One-line gauge, cost, and statuses; the petal shows `working` and `queued`. | +| Parent ↔ subagent communication | Delegate, steer, reply, and cross-session notification within your local profile. | +| Native interactive tools | Built-in questions, choices, and review captures — no third-party dependency. | +| Gentle Todo | A plan card that turns amber when the model lets it go stale. | +| Subscription usage | Per-window meters and resets for supported provider accounts. | +| Gentle notices | Gentle AI calls and review reminders as cards in the transcript. | -```text -✿ gentle-pi ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium ⟡ ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub ⟡ MCP: 3 servers enabled Release notes -``` +> **Every component, skill and preset: [Full breakdown →](docs/gentle-shell.md)** -- Context is a gauge, not a number. It turns amber at 80% and red at 95%; after compaction it shows `?%` until the next response. -- Cost carries `sub` when the active model runs on a subscription login. -- Statuses other extensions publish through `setStatus` are appended as trailing segments; the session name sits at the right edge. -- On narrow terminals the session name is dropped first, then trailing segments, before the line is truncated. +--- -The prompt wraps pi's editor in a rounded frame with a petal that shows what the agent is doing: +### What's new in v3.5 -```text -╭─ ✿ working ──────────────────────────────────────────╮ -│ type, or / for commands │ -╰──────────────────────────────────────────────────────╯ -``` +The [v3.5.1 release](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v3.5.1) makes Gentle Shell runnable on its own: -- The petal is still while pi waits, spins with a `working` label while the agent works, and turns amber with a `queued` label when messages are waiting behind the current turn. pi's own "Working" row above the editor is hidden, since the frame already says it. -- The frame uses the theme's border color over the panel background, so the prompt reads as one panel with the cards around it; the editor's scroll indicators stay inside the frame. -- The hint appears only while the editor is empty. -- If another extension already installed a custom editor, Gentle Shell leaves it alone. +- **Standalone launcher:** `npm i -g gentle-pi` installs `gentle-shell`, which opens Pi with the Gentle Shell package loaded from its own home (`~/.gentle-shell/agent`) or, with `--link`, from your existing `~/.pi/agent`; `gentle-shell install npm:` and the other pi subcommands run against the selected home. A bundled or `PATH` pi is used, never a modified one. +- **Link mode take-over:** when `~/.pi/agent` already declares gentle-pi as a path package, the launcher takes over extension loading (`--no-extensions` plus explicit `-e` for every other declared package and loose extension) so tools never register twice. +- **Interactive RPC hosts:** with `GENTLE_SHELL_INTERACTIVE_HOST=1` and `--mode rpc`, ask-user tools use pi's RPC dialogs and gentle-agents publishes live subagent activity for the desktop app. See the [reference](docs/readme-reference.md#interactive-rpc-hosts). -Changes across this session's registered worktrees show up below the editor and as an aggregate `±N` next to the session branch in the bar: +--- -```text -✎ 3 files · +42 −7 · extensions/gentle-shell.ts, lib/shell-bar.ts, tests/x.test.ts · /gentle:changes -``` +

Back to top ↑

-- Each registered root shows **all** dirty files: plain `git diff` against HEAD plus untracked files, including edits that predate this session. There are no baselines or file-level attribution filters. -- The canonical session cwd root is included automatically. Successful standard `read`, `write`, `edit`, `grep`, `find`, and `ls` calls register their target worktree after completion. Failed calls, shell command text, and prose never register roots. Only roots sharing the session's Git common directory are accepted. -- For opaque shell use or worktrees used earlier, call `session_worktree_register` with `{"path":"/path/to/worktree"}`. Registration is explicit, canonicalized, and deduplicated; unrelated dirty siblings remain invisible without an ignored-roots list. -- The root registry persists in Pi custom entries (`gentle-pi.session-worktree/v1`). Exit/resume and `/reload` restore the same session UUID; `/tree` keeps roots session-wide. New sessions, `/fork`, and `/clone` ignore inherited registrations with another UUID. Clean roots stay registered but hidden until dirty; missing/prunable roots are skipped safely. Ephemeral `--no-session` runs cannot persist across exit. -- Counts refresh after every tool call, at the end of each turn, and every 5 seconds in the background, so edits made from nvim or another agent show up without touching pi. `GENTLE_PI_SHELL_CHANGES_WATCH_MS` changes the interval; `off` leaves only the tool-driven refresh. Outside a git repository the widget stays hidden. -- On narrow terminals the file list is dropped before the summary is truncated. +

+ +

-`/gentle:changes` or `alt+g` opens the framed two-pane viewer. Dirty worktrees are accordion groups in the left pane, labeled with branch and directory basename (`detached` when there is no branch). Expand groups to reveal indented changed files; multiple groups can stay expanded. The right pane previews the selected file's lazy-loaded diff, or shows the selected group's full directory and summary. Clean, bare, missing, and prunable roots remain hidden; untracked-only roots are included. +## Get started -- `j`/`k` or up/down traverse visible groups and files, keeping the selection in view. On a group, `enter`, space, or right arrow toggles expansion. Left arrow or backspace moves a file selection to its parent, or collapses the selected group. `ctrl+j`/`ctrl+k` or `pgdn`/`pgup` scroll the diff; `esc` or `q` closes the overlay. -- Opening, pressing `r`, and the background/overlay refresh cadence scan only registered roots. Worktree discovery supplies branch labels, never registration. No changes in registered roots means no widget and an informational notice instead of an overlay. -- While the overlay is open, git is polled every 2 seconds, so edits made from nvim, another agent, or a checkout show up in place. Expansion and selection stick to the raw worktree root and file path across refreshes; a diff reloads when its counts move. -- `GENTLE_PI_SHELL_CHANGES_KEY` rebinds the shortcut (pi key syntax, for example `ctrl+shift+g`); `off` disables it. On macOS, `alt+g` needs the terminal to send Option as Meta. -- On a file row, `o` (or `enter`) opens the selected file in `$VISUAL` or `$EDITOR`, with the selected worktree as the editor's working directory, and returns to pi when the editor exits. Diff lookup and caches are also scoped to that root; identical relative filenames in other worktrees cannot share a diff. -- Untracked files are diffed against an empty file so new files show their full content. +> **Naming transition:** The product is called `gentle-shell`; the current npm package and repository remain `gentle-pi` until migration. -Subscription usage shows in the bar after the cost, and `/gentle:usage` opens a panel with every window per provider: +### Path A: standalone `gentle-shell` (recommended, no pi changes) -```text -✿ gentle-pi ⟡ … ⟡ $9.49 sub ⟡ codex 5h ▰▰▰▰▰▱▱▱ 62% · week 31% -``` +`gentle-shell` opens Pi with the Gentle Shell package loaded, without installing it into your pi agent or editing its `settings.json`. -- For Codex, usage comes from the same account usage endpoint the Codex CLI reads, using the OAuth token pi already holds. It is fetched at session start, at most every 5 minutes after a turn, and on `r` in the panel. Rate-limit headers on SSE responses are picked up too. -- For Claude Pro/Max, usage arrives in the rate-limit headers of every response, so the 5h and weekly windows appear after the first turn. -- The bar names the subscription it shows (`codex`, `claude`) and always follows the active model. The panel puts the active provider first, marked with the petal, and says why it has no data when it does not: API-key providers have no subscription windows, Claude reports after the first response, Codex waits for a fetch. -- Only the plan name and the windows are kept; account details in the payload are discarded. -- Gauges turn amber at 80% and red at 95%, like the context gauge. +```bash +npm i -g gentle-pi -Gentle notices are drawn as cards: the same rounded frame as the prompt, with the left rail and the title in the tone of the notice and the rest of the frame in the theme's border color. +# Own home, never touches your pi install +gentle-shell -```text -╭─ ✿ Gentle AI · review preflight ─────────────────────────────────────╮ -│ Receipt-driven development is enabled, and this worktree holds an… │ -╰──────────────────────────────────────────────────────────────────────╯ +# Reuse your pi sign-ins, models and chats instead +gentle-shell --link ``` -- Every call into the gentle-ai binary and every `gentle_review` tool renders as a card under the rose, `🌹︎ Gentle AI`: the rail is amber while it runs, green when it finished, red when it failed; the expand key sits in the top rule once the tool finished, and the collapsed result shows only its line count. Reviewer captures name their lens (`review capture · risk`; the group lists all four). -- The review preflight reminder renders as a card in the transcript with the expand key in its top rule. -- An active dev-binary override shows above the editor at startup, in amber, naming the binary and its digest, and leaves with the first prompt; an invalid override shows in red with the reason. -- Subagents draw their own card; see Gentle Agents below. - -### Gentle Agents - -The current package requires Pi 0.85.1 or newer (development tests pin 0.85.1). Use the latest Pi release; gentle-pi does not update your installed Pi automatically. Children, including any `GENTLE_PI_AGENTS_PI` override, must emit `agent_settled`: `agent_end` records a run's output but is not completion because retries or queued continuations may follow. - -The `subagent_*` tools and the agents card replace the third-party subagents package (remove `npm:pi-subagents-j0k3r` from your pi packages; while it is still installed the tools stay unregistered and a warning says so at startup). Agent definitions and settings are the ones you already have: markdown agents in `~/.pi/agent/agents/`, `~/.pi/agent/subagents/`, `/.pi/agents/`, `/.pi/subagents/` (project beats global, `subagents/` beats `agents/`), and `subagents.json` at the global and project level (`default_model`, `default_effort`, `default_mode`, `model_profiles`, `stall_timeout_ms`, `max_concurrency`, `history_max_tasks`). - -Agent paths follow `GENTLE_PI_AGENT_HOME`, then `PI_CODING_AGENT_DIR`, then `~/.pi/agent` for definitions, config, history, child sessions, and transcripts. These overrides select the agent profile; they do not sandbox project or shared global resources. +`gentle-shell` alone starts in its own home, `~/.gentle-shell/agent`, and sets that home up on first run — no separate step. Gentle Shell keeps its own home with the Gentle AI companion packages and no conflicting plugins; gentle-pi itself always stays this launcher's own copy, never one installed into the home; your pi install is untouched. That home also defaults to the Gentleman-Cute theme unless you set your own. `gentle-shell --link` reuses `~/.pi/agent` as-is, is never auto-provisioned, and never has its theme touched. -```text -╭─ ❀ Agents · 1 active · 1 done ─────────────────────────────── 1m24s ╮ -│ ✓ sdd-explore map footer data sources gpt-5.6-terra · 34k · $0.27 · 25s │ -│ ◐ sdd-apply write gentle-shell footer gpt-5.6-terra · 12k · $0.09 · 41s │ -╰──────────────────────────────────────────────────────────────────────────────╯ +```bash +# Re-run provisioning by hand, e.g. to see the full install output +gentle-shell setup ``` -Every subagent is its own `pi --mode rpc` child process, so the terminal never runs subagent work: the host reads JSON lines, applies each one as a small delta to a bounded per-task thread, and notifies only the listeners of that task. A task-mode child's question (`ctx.ui.select`, `confirm`, `input`, `editor`) reaches you as an ordinary pi dialog; a background child's question is dismissed. Subagents have no automatic total execution timeout: a long-running child remains live while it continues emitting RPC events. A silent child still times out through the configurable `stall_timeout_ms` watchdog (default four minutes). Closing pi stops the children that are still running. - -- `subagent_list_agents`, `subagent_run` (`agent`, `task`, `label?`, `context?`, `workspace_root?`, `mode?` task or background), `subagent_status`, `subagent_result`, `subagent_list_tasks`, `subagent_reply` (one current-session reply to a live child query), `subagent_cancel`, `subagent_send_message` (steer a running child), `subagent_continue` (resume a finished task in its own session). -- `subagent_run.workspace_root` selects an existing worktree in the session's Git clone. Validation happens before queueing; the child runs at that canonical root. Successful OS spawn registers the root in the originating parent session, including delayed queued launches, even without an active shell listener. Failed spawns do not register. `subagent_continue` retains the previous task's cwd; status and task details expose it. -- A background task's result comes back to the model as a `gentle-agents.result` message, drawn as a rose card, and starts a new turn when the agent is idle; the model never polls. -- A configured child can call `subagent_parent_message` with bounded, well-formed Unicode text. Notifications retain their existing admission semantics. A `kind: "query"` waits for one strictly correlated `subagent_reply` for at most 30 seconds; each child has at most four pending queries, and disconnect, timeout, stop, and send failure settle each request once. The current parent session alone can reply. The first admitted task-mode query ends the original tool response while its child keeps running; its eventual non-cancelled completion returns once as a follow-up only if that same session is still active. Channel closure prevents later sends and automatic retry is not provided. Peer transport, offline delivery, retries, and broadcasts are unsupported. -- The card shows the active session's tasks only: after `/new` or `/resume` the earlier session's tasks leave it and come back with their session. Finished rows stay for one minute (three at most), and the card spends at most a quarter of the terminal (three to eight rows) on tasks; beyond that the rest fold into one `… N more · alt+a to view` line so the editor never leaves the screen. Questions and running work keep their rows first. -- `/gentle:agents` or `alt+a` opens a full-terminal overlay. At 60+ columns, the split view shows groups/tasks beside the retained semantic thread; uppercase `F` or **Fullscreen** expands that thread. At 12–59 columns, click a current subagent directly to inspect its thread; in All sessions, first select its orchestrator. `Enter`/`Tab` also enter a narrow selection. **Back** or `Escape` returns one level, closing only at the root; **Close** or `q` closes globally without cancelling children. Selection and manual thread scrolling survive Back and resize. -- Mouse controls take priority over keyboard hints: **Follow** (`f`), **Open session** (`o`), **Stop** (`s`, legacy `c`, owned active tasks only), and **Scope** (`a`). A compact footer's `>` cycles through actions. Scope switches between this session's direct active children and all open orchestrators, including idle ones. Open writes a markdown transcript for `$EDITOR`, not a resumed child session. `j`/`k` move through lists or scroll an expanded thread; `ctrl+j`/`ctrl+k` and Page Down/Up page the thread. In Pi fullscreen mode, the wheel scrolls the viewport under the pointer; regular terminal mode does not capture mouse input. Below 12 columns or three rows, only a bounded Close cell remains; zero-sized terminals render nothing. -- The thread displays all retained Text, Thinking, Note, and Tool content without an additional presentation cap; existing store limits and truncation markers still apply. Only the selected task is subscribed while the overlay is open. -- Thread entries are presented as labeled Text, Thinking, Note, or Tool blocks; tool blocks show their status and nonempty output. -- Current scope has no orchestrator wrapper and excludes every terminal task. All sessions discovers open Pi instances sharing the same agent profile, even across repositories; it does not infer open sessions from retained tasks. Directory headings support left/right and mouse expansion, and cannot stop or open a task. Peer children and their retained threads are read-only: no local stop, editor-open, or continuation routing, and no import into the local task store. -- Presence refresh is paged while the overlay is open. Graceful shutdown withdraws an instance; after abrupt closure its last heartbeat may remain visible for up to 15 seconds plus the time to complete the next directory refresh. A recent heartbeat is a heuristic, not proof that a process is alive. Same-profile, same-user processes share retained activity text; this is not an authorization channel. -- `alt+s` confirms stopping the current active or queued subagents owned by the current process. `GENTLE_PI_AGENTS_STOP_KEY` rebinds it; `off` disables it. -- Finished tasks are written to `~/.pi/agent/gentle-agents/tasks/` (one JSON per task, newest `history_max_tasks` kept, default 200) and come back on demand for `subagent_result` and `subagent_continue`, never as overlay history. Child sessions live under `~/.pi/agent/gentle-agents/sessions/`. -- `ctrl+shift+a` collapses the card to its first row (`GENTLE_PI_AGENTS_KEY`), `GENTLE_PI_AGENTS_VIEW_KEY` rebinds the overlay, `GENTLE_PI_AGENTS_PI` overrides the pi command used for children, and `GENTLE_PI_AGENTS=0` disables the tools and the card. - -### Gentle Todo +`gentle-shell setup` installs the same companion packages gentle-ai provisions into a regular Pi, into this home only, then removes the one package that conflicts with gentle-pi's own `ask_user_question` tool (gentle-ai #4820). The first `gentle-shell` launch in a home already runs this automatically; `setup` is for re-running it by hand. See **[First run](docs/readme-reference.md#first-run-in-an-isolated-or-custom-home)** for the opt-out (`GENTLE_SHELL_NO_AUTO_SETUP=1`) and failure behavior. -The `todo` tool and its card replace the third-party todo extension (remove `npm:@juicesharp/rpiv-todo` from your pi packages; sessions written by it replay into the new card). - -```text -╭─ ❀ Todos · 1 of 3 ──────────────────────────────────────╮ -│ ✓ Add quiet tool rendering │ -│ ◐ Fix quiet tools conflict · fixing conflict │ -│ ○ Show git bash tails │ -╰─────────────────────────────────────────────────────────╯ +```bash +# Make --link the default +gentle-shell home link ``` -Three things keep the list current, which a static tool description cannot: +Every other argument is forwarded to pi unchanged, for example `gentle-shell --mode rpc` or `gentle-shell -p "..."`. Full flags, env vars, and modes: **[launcher reference](docs/readme-reference.md#gentle-shell-launcher)**. -- `write` replaces the whole list in one call, so the model rewrites the plan instead of patching it; `add`, `update`, `clear`, and `list` remain for single moves. -- Every turn's system prompt carries the open tasks and the rules: in_progress before starting, done right after finishing, update before ending the turn. -- A list that goes two turns untouched while tasks stay open turns amber with `stale · N turns`, and the prompt says so, so the model brings it up to date. +### Path B: inside an existing pi -A finished list stays on screen for the turn it finished in and clears at the next. `ctrl+shift+t` collapses the card to the task in progress (`GENTLE_PI_TODO_KEY` rebinds it, `off` disables it); `GENTLE_PI_TODO=0` disables the tool and the card. +Install the stable release into an existing pi agent, restart Pi, then synchronize the installed assets. -Set `GENTLE_PI_SHELL=0` to keep pi's built-in footer and editor. - -## Commands - -| Command | What it does | -| -------------------------------- | ------------------------------------------------------------------- | -| `/gentle:status` | Shows package, SDD asset, OpenSpec, and global model config status. | -| `/gentle:doctor` | Runs read-only diagnostics for SDD assets, model/persona config, memory tools, and safety guards. | -| `/gentle:models` | Opens global model + effort assignment UI. Press `x` to export and `r` to restore saved routing. | -| `/gentle:persona` | Switches global persona mode, with project override support. | -| `/gentle:background-subagents` | Shows or sets the managed background-subagents policy (`status\|enable\|disable`), naming the source that decided it. | -| `/gentle:telemetry` | Shows or changes the local Gentle AI telemetry trigger (`status\|enable\|disable\|preview`). | -| `/gentle:banner` | Configures startup banner rose, text logo, and color preset. | -| `/gentle:toggle-rose` | Toggles the startup rose. | -| `/gentle:toggle-text-logo` | Toggles the startup text logo. | -| `/gentle:banner-color` | Selects a startup banner color preset. | -| `/gentle-sdd-init` | Initializes or refreshes `openspec/config.yaml` (openspec/both stores only). | -| `/gentle:install-sdd` | Repairs missing global SDD runtime assets without overwriting files. | -| `/gentle:install-sdd --force` | Force-refreshes installed global SDD assets. | -| `/skill-registry:refresh` | Regenerates `.atl/skill-registry.md`. | -| `/skill-creation` | Creates or updates an LLM-first skill using the packaged `gentle-ai-skill-creator` contract and style guide. | - -Package-owned global SDD runtime assets are also refreshed automatically on session start when `gentle-pi` changes. Project-local `.pi/agents` and `.pi/chains` remain manual overrides and are never overwritten by startup refresh. - -### Background subagents policy +```bash +# Published stable release: v3.5.1 +pi install npm:gentle-pi@3.5.1 -Background delegation is off unless you turn it on. The policy is user-owned: only an explicit `/gentle:background-subagents enable` or `disable` writes it, and Pi automation never toggles it. +# Restart Pi, then run: +gentle-ai sync -```text -/gentle:background-subagents Report the effective policy, the deciding source, and the resolved capability. -/gentle:background-subagents enable Write "on" to the global file. -/gentle:background-subagents disable Write "off" to the global file. +# Start Pi in your project +pi ``` -Four sources can decide the policy, and the first hit wins: - -| Priority | Source | Notes | -| -------- | ------------------------------------------------- | ------------------------------------------------------------ | -| 1 | `/.pi/gentle-ai/background-subagents.json` | Project file. Outranks everything, including a global write. | -| 2 | `/background-subagents.json` | Global file, written by `enable`/`disable`. `configHome` honors `GENTLE_PI_CONFIG_HOME` and defaults to `~/.pi/gentle-ai`. | -| 3 | `GENTLE_PI_BACKGROUND_SUBAGENTS` | Exactly `on` or `off`. Any other value is ignored. | -| 4 | Built-in default | `off`. | - -Both files use the strict shape `{"schema":"gentle-pi.background-subagents/v1","policy":"on"}`. A file that is present but malformed fails closed to `off` and is **not** skipped in favor of a lower-priority source, so a typo in the project file disables background subagents rather than silently handing the decision to the global file. The command reports that case as a warning instead of an ordinary `off`. - -Because the project file outranks the global one, `enable` still writes the global file but reports plainly when a project file keeps the effective policy unchanged. The resolved capability (`ready` or `absent`) reports whether `subagent_run` is actually callable in this session; a policy of `on` with capability `absent` means Gentle Agents is disabled or the retired subagents package is still installed. - -Startup banner settings remain global in `banner.json` under `GENTLE_PI_CONFIG_HOME` (default `~/.pi/gentle-ai`). Existing `showRose` and `showTextLogo` opt-outs independently control the main startup artwork; both default to enabled. Changes apply on the next session or `/reload`. Color presets are `pink` (default), `cyan`, `yellow`, and `green`. The static sidebar heading is independent of these preferences and follows the active theme. - -Startup flag: +See the [v3.5.1 release notes](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v3.5.1) for version-specific changes. ```text -pi --no-skill-registry -``` - -Use it when you want skills available normally but do not want Gentle AI to refresh/watch `.atl/skill-registry.md` on startup. `pi -ns` / `pi --no-skills` also skip the registry startup work because Pi is already disabling skill loading. - -## Included skills - -- `gentle-ai` — harness discipline for controlled Pi work. -- `gentle-ai-branch-pr` — issue-first PR preparation. -- `gentle-ai-chained-pr` — split oversized changes into reviewable PR chains. -- `work-unit-commits` — commits as reviewable work units. -- `gentle-ai-judgment-day` — blind dual review, fixes, and re-judgment. -- `cognitive-doc-design` — documentation that reduces cognitive load. -- `comment-writer` — concise, warm, postable collaboration comments. -- `gentle-ai-issue-creation` — issue workflow with checks before creation. -- `gentle-ai-skill-creator` — create LLM-first skills with valid frontmatter. -- `gentle-ai-skill-improver` — audit and upgrade existing LLM-first skills. - -## Memory - -`gentle-pi` does **not** provide persistent memory by itself. - -For memory, install the companion package: - -```bash -pi install npm:gentle-engram +/gentle:status +/gentle:doctor ``` -When memory tools are actually active, el Gentleman can save decisions, bug fixes, discoveries, user prompts, and session summaries across Pi sessions. +> **RDD is opt-in:** enable native receipt-driven development only through an explicit `/gentle:review-mode enable` decision. -Memory contract for SDD delegation: +> **Fullscreen installation note:** a recognized global installation persists Pi’s `"tuiMode": "fullscreen"` setting. Project-local and other install paths do not receive that change. -- parent/orchestrator owns memory retrieval and passes selected context into subagent prompts; -- subagents should not independently search memory during normal runtime unless explicitly instructed to retrieve a specific artifact or observation; -- subagents should save significant discoveries, decisions, bug fixes, and completed SDD phase artifacts before returning when memory tools are available; -- in memory/hybrid mode, SDD artifacts use stable topic keys such as `sdd//proposal`, `sdd//spec`, `sdd//design`, `sdd//tasks`, `sdd//apply-progress`, and `sdd//verify-report`. +> **Interactive RPC hosts:** the desktop app sets `GENTLE_SHELL_INTERACTIVE_HOST=1` automatically, without touching your Pi config — see the [installation reference](docs/readme-reference.md#interactive-rpc-hosts). -## Telemetry +For prerequisites, source-checkout instructions, full install behavior, and release policy, use the **[installation reference](docs/readme-reference.md#install)**. For everyday work, describe the outcome and follow [ODD](#odd--the-everyday-workflow). -`gentle-pi` does not collect anything itself. [gentle-ai](https://github.com/Gentleman-Programming/gentle-ai) owns anonymous usage telemetry end to end — install and heartbeat events, what fields are sent, rate limiting, and every opt-out. See its README/docs for the exact contract. +

Back to top ↑

-At session start, for a primary session only (never for a named or SDD sub-agent), Gentle Pi asks the local `gentle-ai` binary to send its own telemetry: it spawns `gentle-ai telemetry trigger --json` detached, with a 3 s deadline, discards its output, and never blocks session start or surfaces an error — an older binary without the verb is silently treated as nothing to do. This runs at most once per process. +

+ +

-Install counts for `gentle-pi` and `gentle-engram` come from npm download statistics; the package itself never emits an install event. +## Documentation -To opt out: +Start with the product-facing destination, then move into the operational reference only when you need the details. -- `/gentle:telemetry disable` — asks the local `gentle-ai` binary to disable telemetry (also `status` and `preview` to inspect it without leaving Pi). -- `DO_NOT_TRACK=1` — Gentle Pi itself will not spawn the trigger, and `gentle-ai` also honors this standard on its own. -- `GENTLE_AI_TELEMETRY=0` — same effect, `gentle-ai`'s own environment switch. - -`CI=true` also suppresses the trigger, since automated runs are not a real usage signal. - -## Package contents - -| Path | Purpose | -| ------------------------------ | ---------------------------------------------------------------------------------------------------------- | -| `extensions/gentle-ai.ts` | Injects identity, orchestrates native review authority, refreshes global SDD assets, registers commands, applies model/persona config, and enforces runtime safety. | -| `lib/native-review-cli.ts` | Strict package-local adapter for Gentle AI START, FINALIZE, VALIDATE, SDD binding, and status contracts. | -| `lib/review-integration-v2.ts` | Strict consumer decoder for negotiated capabilities, operations, target status, projections, repair, and failures against contract `review-integration/v2` (active today). | -| `lib/review-candidate-view.ts` | Builds immutable changed-scope actor views while preserving full-tree, path, mode, symlink, and index integrity. | -| `lib/review-canonical.ts` | Permanent Pi-owned canonical JSON and domain-hash primitives for consumer-side identities. | -| `lib/review-repository.ts` | Permanent Pi-owned Git common-directory identity, safe Git environment, and authority-root binding. | -| `lib/gentle-ai-binary.ts` | Resolves and verifies the confined package-local Gentle AI runtime without global or PATH fallback. | -| `scripts/gentle-ai-installer.mjs` | Installs signed Darwin/Linux archives or exact Go SumDB-verified Windows source builds into the package-local runtime. | -| `contracts/review-integration/v1/` | Byte-identical provider schemas and conformance fixtures for contract `review-integration/v1`, hash-checked before packaging; retained on disk permanently because `/v2`'s schemas `$ref` into these fragments. | -| `contracts/review-integration/v2/` | Byte-identical provider schemas and conformance fixtures for contract `review-integration/v2` (immutable `base_tree`/`candidate_tree`, ordered `changed_path_manifest`, no inline candidate diff), hash-checked before packaging. | -| `extensions/startup-banner.ts` | Shows and configures the startup intro, color presets, and compact runtime panel. | -| `extensions/sdd-init.ts` | Registers `/gentle-sdd-init` for OpenSpec initialization. | -| `extensions/skill-registry.ts` | Maintains `.atl/skill-registry.md` from project/user skills and closes file watchers on shutdown. | -| `assets/orchestrator.md` | Parent-session orchestration contract (always-on core). | -| `assets/orchestrator-delegation.md` | Lazy-loaded delegation/routing/review detail, including the mirrored gentle-ai canon. | -| `assets/orchestrator-memory.md` | Lazy-loaded SDD memory phase table, artifact keys, and lifecycle rule. | -| `assets/orchestrator-skills.md` | Lazy-loaded skill registry fallback semantics and intent-driven skill discovery. | -| `assets/sdd-orchestrator-workflow.md` | Lazy-loaded SDD workflow surface for the parent orchestrator. | -| `assets/agents/` | SDD agents installed as global Pi runtime assets. | -| `assets/chains/` | SDD chains installed as global Pi runtime assets. | -| `assets/support/` | Strict TDD support docs for apply/verify phases. | -| `skills/` | Gentle AI delivery and collaboration skills. | -| `prompts/` | The `/skill-creation` prompt template. | -| `docs/skill-style-guide.md` | Normative style guide used by the packaged skill creation/improvement skills. | -| `docs/native-authority-architecture.md` | Post-U8 ownership boundary, reproducible slimming metrics, Windows evidence, exact #191 seam, and the `review-integration/v1`→`v2` migration status, including the "compact-v2" naming disambiguation. | -| `docs/review-integration.md` | Negotiated provider/consumer contract and the current Gentle Pi adoption boundary. | -| `docs/prompt-history.md` | Prompt-history slice 1: opt-in capture switch, storage layout, readers, and disable/removal semantics. | - -## Development - -Install from this repo: +| Destination | Purpose | +| --- | --- | +| [gentle-shell reference](docs/gentle-shell.md) | Workspace layout, changes, usage, agents, and todo interactions. | +| [ODD workflow](docs/readme-reference.md#organic-driven-development) · [Technical reference](docs/readme-reference.md) | Everyday work and recovery, installation, configuration, commands, and contributor detail. | +| [Review integration](docs/review-integration.md) | The provider/consumer boundary for native review. | +| [Native authority architecture](docs/native-authority-architecture.md) | Ownership boundaries and review architecture. | +| [Telemetry](docs/telemetry.md) | Approved fields and source limitations. | +| [Delegated verification](docs/delegated-verification.md) | Practical verification guidance. | +| [Skill style guide](docs/skill-style-guide.md) | The package skill contract. | -```bash -pi install . -``` +

Back to top ↑

-Validate before publishing: +

+ +

-```bash -pnpm test -bun build extensions/skill-registry.ts --target=node --format=esm --outfile=/tmp/skill-registry.js -node --experimental-strip-types --check extensions/gentle-ai.ts -node --experimental-strip-types --check extensions/sdd-init.ts -node --experimental-strip-types --check extensions/startup-banner.ts -npm pack --dry-run -``` +## Community -### Running the cross-lane battery +This project is built in public. Bring a real workflow, a sharp question, a bug report, or a small improvement that makes the next person’s work clearer. -The cross-lane battery (`tests/crosslane/cross-lane.mjs`) validates the adapter against a real `gentle-ai` binary, end to end and out of CI on purpose. The pinned decoder lane only ever sees vendored fixtures, so new envelope schemas and full controller sequencing are never driven through a live lifecycle before merge; the battery closes that gap. +

+ GitHub issues + Contributors + Gentleman Programming Discord +

-```bash -pnpm test:cross-lane # requires the dev-binary override -pnpm test:cross-lane --with-model # adds the real Go-owned pi reviewer run (model spend) -``` +

+ gentle-shell contributors +

-What it checks, against live scratch repositories: +- Open an [issue](https://github.com/Gentleman-Programming/gentle-shell/issues) with the context needed to reproduce or understand the idea. +- See the people shaping the project in the [contributors graph](https://github.com/Gentleman-Programming/gentle-shell/graphs/contributors). +- Follow [Gentleman Programming](https://github.com/Gentleman-Programming) for the wider ecosystem. -- a low-risk lifecycle: START → native-approved FINALIZE → terminal burn; the `pre-commit` gate is informational and unmanaged, not an allow decision or retained receipt; -- the medium-risk `consent/v3` granted round-trip through the direct decoder lane; -- controller sequencing: each decoded offered next step equals the native transition; correction evidence precedes Go-owned targeted validation, then native approval and terminal burn leave no retained receipt; -- the active audited abandon end to end, asserting the adapter builds the exact nine-line `gentle-ai.review-abandon-authorization/v2` discarded-work binding and the native gate commits the quarantine record; -- after a scope change, a burned approved predecessor exposes no recoverable authority; recovered-successor hydration remains covered at unit level; -- forward-decoder freshness: every live envelope captured from the binary must decode without unknown-key rejection, the early warning that gentle-ai main grew a field gentle-pi lacks; -- the default no-model lane: 13 of 14 checks pass while the real-model check is intentionally skipped; Go-owned validation uses a deterministic scratch fake `pi`, and only `--with-model` runs the real locked-down reviewer with model spend. +

Back to top ↑

-Prerequisites: +

+ +

-- A real `gentle-ai` binary selected through the dev-binary override; there is no PATH or pinned-binary fallback, and the battery refuses to run without one. Either export `GENTLE_PI_GENTLE_AI_DEV_BINARY=` for the session, or register a persistent override with `/gentle:dev-binary ` (stored at `~/.pi/gentle-ai/dev-binary.json` with schema `gentle-pi.dev-binary/v1`; the environment variable takes precedence over the registration, and the binary is re-validated and re-hashed on every resolution). Any real build works: an installed release binary or a locally built gentle-ai main. -- A Git checkout or worktree of this repository. The battery is a contributor tool wired to the repository layout and is excluded from `pnpm test` and CI by construction; run it from the repo, not from an installed Pi package. +## About the author -The battery owns one throwaway scratch root under the OS temp directory and never touches the enclosing repository. Before any review lifecycle it creates private `HOME`, XDG config/cache/data/state, temporary, and RDD state directories inside that root; it proves RDD starts `off/default`, explicitly opts in with sandbox-global RDD, and removes the complete root after the run. It never requires or changes the user's ambient RDD mode. The default run spends no model tokens; `--with-model` launches one real reviewer model run and costs model spend. +`gentle-shell` is built by [Alan Buscaglia](https://github.com/Gentleman-Programming), the maker behind Gentleman Programming. It grew from a practical belief: capable agents are more useful when the human’s intent, review load, and delivery judgment stay visible all the way through the work. -It prints one PASS/FAIL/SKIP row per check plus a note, and exits non-zero when any check fails. A check blocked by a known upstream class is reported with a `known-red` prefix instead of being hidden; it remains a failure, not a success. +Startup intro collaboration: thanks to [@aporcelli](https://github.com/aporcelli) and [`pi-gentle-startup`](https://github.com/aporcelli/pi-gentle-startup), which inspired the clean-screen startup animation, compact runtime panel, and pink visual treatment. -Running this battery against new gentle-ai builds (release candidates or main) and reporting red checks is a valuable contribution. The sibling provider-side battery lives at `scripts/cross-lane-battery.sh` in [Gentleman-Programming/gentle-ai](https://github.com/Gentleman-Programming/gentle-ai). +

+ Gentleman Programming website + Gentleman Programming YouTube + Gentleman Programming GitHub +

-Publish npm through GitHub Actions only: +

Back to top ↑

-```bash -version="$(node -p "require('./package.json').version")" -tag="v${version}" -git fetch --no-tags origin "refs/tags/${tag}" -test "$(git rev-parse 'FETCH_HEAD^{commit}')" = "$(git rev-parse "${tag}^{commit}")" -gh workflow run publish.yml \ - --repo Gentleman-Programming/gentle-pi \ - --ref main \ - -f tag="${tag}" -gh run watch --repo Gentleman-Programming/gentle-pi --exit-status -npm view gentle-pi@ version --registry=https://registry.npmjs.org/ -npm dist-tag ls gentle-pi --registry=https://registry.npmjs.org/ -``` +

+ +

-Do not run `npm publish` locally for `gentle-pi`. Dispatch the trusted workflow definition only from protected default `main` and provide its sole `tag` input. The workflow requires an exact annotated `vSemVer` tag whose peeled commit, current remote `main`, dispatch/main workflow commit, checkout, and `package.json` version are identical. It rechecks remote tag and `main` immediately before publishing through OIDC with provenance and environment protection; an advanced `main` requires a new release version, never a moved tag. +

Built with the workflow it brings to Pi.

-## Principles +

+ MIT License +

-- Human control over agent momentum. -- Concepts before code. -- Artifacts over floating chat context. -- SDD when risk justifies it. -- Strict TDD when tests exist. -- One parent orchestrator, focused subagents. -- Reviewable changes over giant diffs. +> **Trademark notice:** The gentle-shell™ and gentle-pi™ names and associated logos are trademarks of Alan Buscaglia. The MIT License applies to the code; it does not permit implying endorsement or official affiliation. See [TRADEMARKS.md](TRADEMARKS.md). From e2cca1f9bd328695b3deb7ec6c216446b585ddbb Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:44:50 -0300 Subject: [PATCH 11/13] fix(history): scope the gc slice to lifecycle work Review follow-up on the slice-06 PR: the branch mixed the GC/lifecycle rewrite with unrelated selector header-layout, overlay-margin, and scope-radio changes, so reviewers could not assess the destructive store rewrite as one bounded unit. - Remove the layout batch: planHeaderLayout/HeaderLayoutMode, editorOverlayMargin, SIDEBAR_RAIL_OVERLAY_MARGIN/SIDEBAR_OVERLAY_PADDING, SCOPE_RADIO_* constants, and scopeRadioText from selector-helpers; drop their index.ts usage (header mode fields, OptionalRow, headerCountsText, listWheelFirstRow) and delete the header-layout and overlay-margin test files. - Restore preview-layout and wheel-mouse suites byte-exact to slice-5 and revert the constructor child-count pins the layout batch introduced. - Keep the GC/lifecycle work intact: gcProjectDir/compactProjectFile(s), thresholds, the session_shutdown GC hook, and the gc test suite; keep all slice-5 delete-confirm behavior and the fail-closed tombstone contract. - Relocate a merge-misplaced doc comment above openHistorySelector. The layout/formatting work moves to a follow-up PR so the destructive GC rewrite and its failure tests are reviewed as one unit. --- extensions/history/index.ts | 194 +++++---------------- extensions/history/selector-helpers.ts | 97 ----------- tests/history-header-layout.test.ts | 52 ------ tests/history-lazy-windowing.test.ts | 2 +- tests/history-openflow-integration.test.ts | 2 +- tests/history-overlay-margin.test.ts | 95 ---------- tests/history-preview-layout.test.ts | 7 +- tests/history-wheel-mouse.test.ts | 23 ++- 8 files changed, 58 insertions(+), 414 deletions(-) delete mode 100644 tests/history-header-layout.test.ts delete mode 100644 tests/history-overlay-margin.test.ts diff --git a/extensions/history/index.ts b/extensions/history/index.ts index c81e93cf9..a0fbb750b 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -22,6 +22,7 @@ import { Input, matchesKey, stripTerminalSequences, + Text, type TUI, type TuiMouseEvent, truncateToWidth, @@ -36,10 +37,8 @@ import { dedupePromptEntries, deletionActionsFor, EDITOR_HIDE_FAILED_TEXT, - editorOverlayMargin, filterPrompts, getVisiblePromptRecords, - type HeaderLayoutMode, initialLoadedCount, loadedCountAfterDelete, loadedCountForQuery, @@ -50,8 +49,6 @@ import { type PromptEntry, type PromptRecord, pageSelectedIndex, - planHeaderLayout, - scopeRadioText, shouldGrowWindow, STORE_DELETE_FAILED_TEXT, withExpandedHistoryGlobals, @@ -83,16 +80,12 @@ const INITIAL_BATCH = 10; const BATCH_SIZE = 10; const PRELOAD_BUFFER = 3; // Wheel regions over the fixed 30-row overlay geometry (design §D6): the -// preview container always renders at rows 17-26. The list region is -// mode-dependent (see listWheelFirstRow): the responsive header reclaims -// rows without changing the 30-row total, and only the compact mode both -// shifts the list start (border at row 5) and paints one list row fewer. +// list container renders at rows 5-14 and the preview container at rows +// 17-26; every other row is a consumed no-op. const LIST_WHEEL_Y_FIRST = 5; const LIST_WHEEL_Y_LAST = 14; const PREVIEW_WHEEL_Y_FIRST = 17; const PREVIEW_WHEEL_Y_LAST = 26; -/** Minimum columns between the counts text and a right-flushed radio before shrinking deletes the spacer and stacks the header (user-directed). */ -const HEADER_INLINE_MIN_GAP = 4; // Default selector footer line (PR #1393): shown whenever a delete is not // armed; the armed state swaps it for the scope-aware confirmation copy. @@ -220,22 +213,6 @@ class FixedRowText { } } -/** A row that renders as ZERO lines when its text is empty, letting the fixed 30-row overlay reclaim the row instead of pushing content out the bottom. */ -class OptionalRow { - private text = ""; - - setText(next: string): void { - this.text = next; - } - - invalidate(): void {} - - render(width: number): string[] { - if (this.text.length === 0) return []; - return [truncateToWidth(this.text, width, "…")]; - } -} - /** Word-wrap plain text so each line fits within maxWidth characters. */ function wordWrapText(text: string, maxWidth: number): string[] { if (maxWidth <= 0) return [text || " "]; @@ -274,12 +251,6 @@ class PromptHistorySelector extends Container implements Focusable { private readonly previewContainer: Container; private readonly listContainer: Container; private readonly headerRow: FixedRowText; - private readonly headerLine2: OptionalRow; - private readonly headerLine3: OptionalRow; - private readonly hintRow: OptionalRow; - private readonly hintText: string; - /** Current responsive header mode; drives the list wheel region. */ - private headerMode: HeaderLayoutMode = "inline"; private readonly previewLabelRow: FixedRowText; private readonly footerRow: FixedRowText; private records: PromptRecord[]; @@ -387,15 +358,13 @@ class PromptHistorySelector extends Container implements Focusable { theme.fg("accent", theme.bold(" History Search ")), ); this.addChild(this.headerRow); - this.headerLine2 = new OptionalRow(); - this.headerLine3 = new OptionalRow(); - this.addChild(this.headerLine2); - this.addChild(this.headerLine3); - this.hintText = - "Type to filter (multi-word AND substring, case-insensitive)"; - this.hintRow = new OptionalRow(); - this.hintRow.setText(this.theme.fg("dim", this.hintText)); - this.addChild(this.hintRow); + this.addChild( + new Text( + theme.fg("dim", "Type to filter (multi-word AND substring, case-insensitive)"), + 0, + 0, + ), + ); this.searchInput = new Input(); this.searchInput.onSubmit = () => this.selectCurrent(); this.searchInput.onEscape = () => this.onCancel(); @@ -453,78 +422,33 @@ class PromptHistorySelector extends Container implements Focusable { this.rebuildListWithWidth(this.lastWidth); } - /** Styled title + position + loaded-counts prefix shared by the inline and stacked header layouts. */ - private headerCountsText( - titleText: string, - positionText: string, - loadedText: string, - ): string { - return ( - this.theme.fg("accent", this.theme.bold(titleText)) + - this.theme.fg("dim", positionText) + - this.theme.fg("dim", loadedText) - ); - } - - /** Rebuild list rows: header counter + entries. Always MAX_VISIBLE rows (MAX_VISIBLE - 1 in compact mode). */ + /** Rebuild list rows: header counter + entries. Always MAX_VISIBLE rows. */ private rebuildListWithWidth(width: number): void { const count = this.filteredRecords.length; const position = count === 0 ? 0 : this.selectedIndex + 1; - const titleText = " History Search "; - const positionText = ` · ${position} of ${count} `; - const loadedText = ` · loaded ${this.loadedCount} of ${this.records.length} `; - const leftWidth = - titleText.length + positionText.length + loadedText.length; - const radioFull = scopeRadioText(this.scope, false); - // Radio label compaction is fit-driven too: abbreviate only when the - // full radio cannot fit the row it would occupy (user-directed paste). - const radioText = - width >= radioFull.length ? radioFull : scopeRadioText(this.scope, true); - const mode = planHeaderLayout( - width, - leftWidth, - radioFull.length, - HEADER_INLINE_MIN_GAP, - ); - this.headerMode = mode; - if (mode === "inline") { - this.headerRow.setText( - this.headerCountsText(titleText, positionText, loadedText) + - // Right-aligned scope radio: pad from plain-text lengths so the - // radio ends flush at the header's last column at any width. - " ".repeat(Math.max(1, width - leftWidth - radioText.length)) + - this.theme.fg("dim", radioText), - ); - this.headerLine2.setText(""); - this.headerLine3.setText(""); - } else if (mode === "stacked") { - // Tablet: the spacer is deleted — the radio wraps to its own row - // under the full counts line (user-directed paste, leading space). - this.headerRow.setText( - this.headerCountsText(titleText, positionText, loadedText), - ); - this.headerLine2.setText(` ${this.theme.fg("dim", radioText)}`); - this.headerLine3.setText(""); - } else { - // Compact (mobile): three rows — counts split off, radio abbreviated - // (user-directed paste). - this.headerRow.setText( - this.theme.fg("accent", this.theme.bold(titleText)) + - this.theme.fg("dim", ` · ${position} of ${count}`), - ); - // Leading space aligns both rows with the title's own left padding - // space (user-directed compact paste). - this.headerLine2.setText( + this.headerRow.setText( + this.theme.fg("accent", this.theme.bold(" History Search ")) + + this.theme.fg("dim", ` · ${position} of ${count} `) + this.theme.fg( "dim", - ` loaded ${this.loadedCount} of ${this.records.length}`, - ), - ); - this.headerLine3.setText(` ${this.theme.fg("dim", radioText)}`); - } - // Stacked modes reclaim the hint row so the overlay stays 30 rows. - this.hintRow.setText( - mode === "inline" ? this.theme.fg("dim", this.hintText) : "", + ` · loaded ${this.loadedCount} of ${this.records.length} `, + ) + + // Right-aligned scope radio: pad from plain-text lengths so the + // radio ends flush at the header's last column at any width. + (() => { + const scopeRadio = + this.scope === "project" + ? "◉ Current project | ○ All projects" + : "○ Current project | ◉ All projects"; + const leftWidth = + " History Search ".length + + ` · ${position} of ${count} `.length + + ` · loaded ${this.loadedCount} of ${this.records.length} `.length; + return ( + " ".repeat(Math.max(1, width - leftWidth - scopeRadio.length)) + + this.theme.fg("dim", scopeRadio) + ); + })(), ); this.listContainer.clear(); @@ -532,24 +456,18 @@ class PromptHistorySelector extends Container implements Focusable { this.listContainer.addChild( new FixedRowText(this.theme.fg("warning", "No matching prompts")), ); - // Compact still paints one list row fewer in the empty state, or the - // 3-row header would push the fixed 30-row overlay to 31 rows. - const listRows = mode === "compact" ? MAX_VISIBLE - 1 : MAX_VISIBLE; - for (let i = 1; i < listRows; i++) { + for (let i = 1; i < MAX_VISIBLE; i++) { this.listContainer.addChild(new FixedRowText()); } return; } - // Compact paints one list row fewer (reclaimed by the 3-row header); - // the preview block keeps PREVIEW_ROWS so the 30-row total holds. - const listRows = mode === "compact" ? MAX_VISIBLE - 1 : MAX_VISIBLE; const entryMax = Math.floor(width * 0.95) - ENTRY_PREFIX_WIDTH; const visible = getVisiblePromptRecords( this.filteredRecords, this.selectedIndex, - listRows, + MAX_VISIBLE, ); for (const { record, isSelected } of visible) { @@ -569,18 +487,11 @@ class PromptHistorySelector extends Container implements Focusable { this.listContainer.addChild(new FixedRowText(line)); } - for (let i = visible.length; i < listRows; i++) { + for (let i = visible.length; i < MAX_VISIBLE; i++) { this.listContainer.addChild(new FixedRowText()); } } - /** List wheel region start: compact shifts the list down one row. */ - private get listWheelFirstRow(): number { - return this.headerMode === "compact" - ? LIST_WHEEL_Y_FIRST + 1 - : LIST_WHEEL_Y_FIRST; - } - /** * Rebuild preview: word-wrap the full selected prompt text and show * a PREVIEW_ROWS-tall viewport starting at previewScrollOffset. @@ -934,7 +845,7 @@ class PromptHistorySelector extends Container implements Focusable { // the next delete press re-arms for the NEW row first (PR #1393). if (this.confirmArmed) this.disarmDeleteConfirm(); const delta = event.wheelDelta ?? 0; - if (event.y >= this.listWheelFirstRow && event.y <= LIST_WHEEL_Y_LAST) { + if (event.y >= LIST_WHEEL_Y_FIRST && event.y <= LIST_WHEEL_Y_LAST) { const steps = Math.min(Math.abs(delta), this.filteredRecords.length); for (let i = 0; i < steps; i++) { if (delta > 0) this.moveDown(); @@ -1020,7 +931,7 @@ function createPromptHistorySelectorFactory( onNotify?: SelectorNotify, ): SelectorFactory { return (tui, theme, _keybindings, done) => { - selectorTui = tui as { requestRender(): void; terminal?: unknown }; + selectorTui = tui as { requestRender(): void }; const finish = (result: PromptRecord | null) => { activeOverlayClose = null; done(result); @@ -1055,44 +966,20 @@ async function runPromptHistorySelection( ), { overlay: true, - // pi-tui freezes the options object at showOverlay time, but calls - // visible() on EVERY render pass before resolving the overlay layout - // (compositeOverlays filters visible entries first), and re-reads - // margin per layout resolution — the getter below therefore stays - // live: resizing across the sidebar breakpoint re-seats the picker - // while it stays open. While the gentle-shell fullscreen sidebar - // paints, the margin confines width "100%" (and the bottom-center - // anchor) to the editor column plus 3 columns of padding; 0 keeps - // the native full-window behavior. - overlayOptions: () => { - let rightMargin = editorOverlayMargin(selectorTui?.terminal); - return { - anchor: "bottom-center" as const, - width: "100%" as const, - offsetY: 5, - get margin() { - return rightMargin > 0 ? { right: rightMargin } : undefined; - }, - visible: () => { - rightMargin = editorOverlayMargin(selectorTui?.terminal); - return true; - }, - }; - }, + overlayOptions: { anchor: "bottom-center", width: "100%", offsetY: 5 }, }, ), ); } -/** Shared entry point for the ctrl+shift+r shortcut and the /history command. */ // --------------------------------------------------------------------------- // Multi-concurrency store (v2): per-session writes, scope drains // --------------------------------------------------------------------------- type HistoryScope = "project" | "global"; -/** TUI handle captured when the selector overlay mounts. `terminal` feeds the sidebar overlay margin. */ -let selectorTui: { requestRender(): void; terminal?: unknown } | null = null; +/** TUI handle captured when the selector overlay mounts. */ +let selectorTui: { requestRender(): void } | null = null; let writerState: SessionWriterState | null = null; @@ -1153,6 +1040,7 @@ function drainForScope(scope: HistoryScope): ScopeDrain { return drain.status === "ok" ? drain.prompts : drain; } +/** Shared entry point for the ctrl+shift+r shortcut and the /history command. */ async function openHistorySelector( ctx: Pick, ): Promise { diff --git a/extensions/history/selector-helpers.ts b/extensions/history/selector-helpers.ts index 7a68bb325..29500d90c 100644 --- a/extensions/history/selector-helpers.ts +++ b/extensions/history/selector-helpers.ts @@ -370,100 +370,3 @@ export function filterPrompts( return filtered.slice(0, MAX_RESULTS); } - -/** - * Cross-extension fullscreen-sidebar state contract (gentle-shell): stored on - * the shared ProcessTerminal under a global-registry symbol so any extension - * can read it without importing gentle-shell. Shape per its lib/shell-sidebar.ts: - * `{ active: boolean; ownsHost?: () => boolean; parts: Map }`. - */ -const SIDEBAR_STATE_SYMBOL = Symbol.for("gentle-pi.experimental-sidebar.state"); - -/** - * Geometry overlay right margin that confines a full-width overlay to the - * editor column while the gentle-shell fullscreen sidebar paints: its layout - * hstack reserves 50 columns (RAIL_WIDTH) for the rail plus a 3-column gap - * (GAP) before it, and it only activates at >= 140 columns. pi-tui resolves - * overlay width "100%" and the bottom-center anchor inside - * `[0, columns - margin)`, which is then exactly the editor column. - */ -export const SIDEBAR_RAIL_OVERLAY_MARGIN = 53; - -/** - * Visual breathing room between the picker and the sidebar rail, added on top - * of the geometry margin (user-directed: 1 column, 2026-09-21). - */ -export const SIDEBAR_OVERLAY_PADDING = 1; - -interface SidebarStateShape { - active?: unknown; - ownsHost?: () => unknown; -} - -/** - * Overlay right margin for the current terminal: the geometry margin plus - * padding while the gentle-shell sidebar rail is painting, else 0 (native - * full-window overlay). Reads the terminal-owned state contract defensively — - * any absent, malformed, or non-owning state degrades to 0 so the picker - * keeps opening. Purity note: this returns the CURRENT margin per call; live - * refresh while an overlay stays open is the caller's job (the picker wires - * visible() plus a getter margin — pi-tui re-reads both every render). - */ -export function editorOverlayMargin(terminal: unknown): number { - if (typeof terminal !== "object" || terminal === null) return 0; - const state = (terminal as Record)[SIDEBAR_STATE_SYMBOL] as - | SidebarStateShape - | undefined; - if (typeof state !== "object" || state === null) return 0; - if (state.active !== true || typeof state.ownsHost !== "function") return 0; - try { - return state.ownsHost() === true - ? SIDEBAR_RAIL_OVERLAY_MARGIN + SIDEBAR_OVERLAY_PADDING - : 0; - } catch { - return 0; - } -} - -/** Responsive picker-header mode at the current render width. */ -export type HeaderLayoutMode = "inline" | "stacked" | "compact"; - -/** - * Fit-driven header plan (user-directed responsive header): "inline" keeps - * title + counts + right-flushed radio on one row; "stacked" (tablet) deletes - * the spacer — the radio wraps to its own row under the full counts line; - * "compact" (mobile) further splits the counts off and abbreviates the radio. - * Thresholds derive from the ACTUAL text widths, so any count size flips the - * mode at the exact column where the previous layout stops fitting. - */ -export function planHeaderLayout( - width: number, - leftWidth: number, - radioWidth: number, - minGap: number, -): HeaderLayoutMode { - if (width >= leftWidth + minGap + radioWidth) return "inline"; - if (width >= leftWidth) return "stacked"; - return "compact"; -} - -/** Full scope radio: both scope labels spelled out. */ -export const SCOPE_RADIO_FULL_PROJECT = "◉ Current project | ○ All projects"; -export const SCOPE_RADIO_FULL_GLOBAL = "○ Current project | ◉ All projects"; -/** Abbreviated radio: the ACTIVE scope keeps its full label, the other shortens. */ -export const SCOPE_RADIO_COMPACT_PROJECT = "◉ Current project | ○ All"; -export const SCOPE_RADIO_COMPACT_GLOBAL = "○ Current | ◉ All projects"; - -/** - * Scope radio text for the current width: abbreviated only when the full - * radio cannot fit the row it would occupy (compact widths). - */ -export function scopeRadioText( - scope: "project" | "global", - compact: boolean, -): string { - if (scope === "project") { - return compact ? SCOPE_RADIO_COMPACT_PROJECT : SCOPE_RADIO_FULL_PROJECT; - } - return compact ? SCOPE_RADIO_COMPACT_GLOBAL : SCOPE_RADIO_FULL_GLOBAL; -} diff --git a/tests/history-header-layout.test.ts b/tests/history-header-layout.test.ts deleted file mode 100644 index 4734967b4..000000000 --- a/tests/history-header-layout.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { - planHeaderLayout, - SCOPE_RADIO_COMPACT_GLOBAL, - SCOPE_RADIO_COMPACT_PROJECT, - SCOPE_RADIO_FULL_GLOBAL, - SCOPE_RADIO_FULL_PROJECT, - scopeRadioText, -} from "../extensions/history/selector-helpers.ts"; - -const LEFT = - " History Search ".length + - " · 1 of 10 ".length + - " · loaded 10 of 27 ".length; -const RADIO = SCOPE_RADIO_FULL_PROJECT.length; -const GAP = 4; - -test("inline while counts plus radio plus minimum gap fit the width", () => { - assert.equal( - planHeaderLayout(LEFT + GAP + RADIO, LEFT, RADIO, GAP), - "inline", - ); - assert.equal(planHeaderLayout(200, LEFT, RADIO, GAP), "inline"); -}); - -test("stacked (tablet) once the spacer would drop below the minimum gap", () => { - assert.equal( - planHeaderLayout(LEFT + GAP + RADIO - 1, LEFT, RADIO, GAP), - "stacked", - ); - assert.equal(planHeaderLayout(LEFT, LEFT, RADIO, GAP), "stacked"); -}); - -test("compact (mobile) when even the counts line no longer fits", () => { - assert.equal(planHeaderLayout(LEFT - 1, LEFT, RADIO, GAP), "compact"); - assert.equal(planHeaderLayout(30, LEFT, RADIO, GAP), "compact"); -}); - -test("radio pins the user-directed labels", () => { - assert.equal(SCOPE_RADIO_FULL_PROJECT, "◉ Current project | ○ All projects"); - assert.equal(SCOPE_RADIO_FULL_GLOBAL, "○ Current project | ◉ All projects"); - assert.equal(SCOPE_RADIO_COMPACT_PROJECT, "◉ Current project | ○ All"); - assert.equal(SCOPE_RADIO_COMPACT_GLOBAL, "○ Current | ◉ All projects"); -}); - -test("scopeRadioText abbreviates only in compact mode", () => { - assert.equal(scopeRadioText("project", false), SCOPE_RADIO_FULL_PROJECT); - assert.equal(scopeRadioText("global", false), SCOPE_RADIO_FULL_GLOBAL); - assert.equal(scopeRadioText("project", true), SCOPE_RADIO_COMPACT_PROJECT); - assert.equal(scopeRadioText("global", true), SCOPE_RADIO_COMPACT_GLOBAL); -}); diff --git a/tests/history-lazy-windowing.test.ts b/tests/history-lazy-windowing.test.ts index f804da454..d53c8ef81 100644 --- a/tests/history-lazy-windowing.test.ts +++ b/tests/history-lazy-windowing.test.ts @@ -496,7 +496,7 @@ test("the header keeps the position segment plus the loaded suffix on the existi const ctorEnd = selectorSource.indexOf('this.applyFilter("")', ctorAt); const ctorAddChild = selectorSource.slice(ctorAt, ctorEnd).split("this.addChild(").length - 1; - assert.equal(ctorAddChild, 14, "the constructor child sequence is unchanged"); + assert.equal(ctorAddChild, 12, "the constructor child sequence is unchanged"); }); // T14 — AC-L2-3 revision (user-directed 2026-09-08): a non-empty query diff --git a/tests/history-openflow-integration.test.ts b/tests/history-openflow-integration.test.ts index 37c654672..cb1895fad 100644 --- a/tests/history-openflow-integration.test.ts +++ b/tests/history-openflow-integration.test.ts @@ -143,5 +143,5 @@ test("T33 (AC-S6-3): Change 2 structural pins still hold beside the third segmen const ctorEnd = indexSource.indexOf('this.applyFilter("")', ctorAt); const ctorAddChild = indexSource.slice(ctorAt, ctorEnd).split("this.addChild(").length - 1; - assert.equal(ctorAddChild, 14, "the constructor child sequence is unchanged"); + assert.equal(ctorAddChild, 12, "the constructor child sequence is unchanged"); }); diff --git a/tests/history-overlay-margin.test.ts b/tests/history-overlay-margin.test.ts deleted file mode 100644 index 816229f32..000000000 --- a/tests/history-overlay-margin.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { - editorOverlayMargin, - SIDEBAR_OVERLAY_PADDING, - SIDEBAR_RAIL_OVERLAY_MARGIN, -} from "../extensions/history/selector-helpers.ts"; - -function terminalWithState(state: unknown): object { - return { - [Symbol.for("gentle-pi.experimental-sidebar.state")]: state, - } as object; -} - -test("margin constant pins the gentle-shell rail geometry (RAIL_WIDTH 50 + GAP 3)", () => { - assert.equal(SIDEBAR_RAIL_OVERLAY_MARGIN, 53); -}); - -test("padding constant pins the user-directed 1-column breathing room", () => { - assert.equal(SIDEBAR_OVERLAY_PADDING, 1); -}); - -test("returns 0 for absent, primitive, or null terminals", () => { - assert.equal(editorOverlayMargin(undefined), 0); - assert.equal(editorOverlayMargin(null), 0); - assert.equal(editorOverlayMargin(42), 0); - assert.equal(editorOverlayMargin("terminal"), 0); -}); - -test("returns 0 when no sidebar state is stored on the terminal", () => { - assert.equal(editorOverlayMargin({}), 0); -}); - -test("returns 0 for malformed state shapes", () => { - assert.equal(editorOverlayMargin(terminalWithState(undefined)), 0); - assert.equal(editorOverlayMargin(terminalWithState(null)), 0); - assert.equal(editorOverlayMargin(terminalWithState("active")), 0); -}); - -test("returns 0 unless active is exactly true AND ownsHost is a function", () => { - assert.equal( - editorOverlayMargin(terminalWithState({ active: true })), - 0, - "active without ownsHost", - ); - assert.equal( - editorOverlayMargin( - terminalWithState({ active: false, ownsHost: () => true }), - ), - 0, - "inactive", - ); - assert.equal( - editorOverlayMargin(terminalWithState({ active: 1, ownsHost: () => true })), - 0, - "non-boolean truthy active", - ); - assert.equal( - editorOverlayMargin( - terminalWithState({ active: true, ownsHost: "not-a-function" }), - ), - 0, - "non-function ownsHost", - ); -}); - -test("returns the geometry margin plus padding only while the sidebar owns the host", () => { - assert.equal( - editorOverlayMargin( - terminalWithState({ active: true, ownsHost: () => true }), - ), - 54, - ); - assert.equal( - editorOverlayMargin( - terminalWithState({ active: true, ownsHost: () => false }), - ), - 0, - "state present but host not owned (regular mode / unpatched root)", - ); -}); - -test("a throwing ownsHost degrades to 0 instead of breaking the picker", () => { - assert.equal( - editorOverlayMargin( - terminalWithState({ - active: true, - ownsHost: () => { - throw new Error("boom"); - }, - }), - ), - 0, - ); -}); diff --git a/tests/history-preview-layout.test.ts b/tests/history-preview-layout.test.ts index 56abd381f..02509725c 100644 --- a/tests/history-preview-layout.test.ts +++ b/tests/history-preview-layout.test.ts @@ -1,10 +1,11 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { fileURLToPath } from "node:url"; import fs from "node:fs"; -import path from "node:path"; +import { fileURLToPath } from "node:url"; -const sourcePath = fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)); +const sourcePath = fileURLToPath( + new URL("../extensions/history/index.ts", import.meta.url), +); const source = fs.readFileSync(sourcePath, "utf8"); test("preview rows are bottom-padded so the panel shrinks from the bottom", () => { diff --git a/tests/history-wheel-mouse.test.ts b/tests/history-wheel-mouse.test.ts index 5bae67d79..fbb4a33b7 100644 --- a/tests/history-wheel-mouse.test.ts +++ b/tests/history-wheel-mouse.test.ts @@ -1,24 +1,23 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { fileURLToPath } from "node:url"; import fs from "node:fs"; -import path from "node:path"; +import { fileURLToPath } from "node:url"; // Unit 4 — L6 wheel slice (spec C5, design §D6). // -// Source-parse structural pins on src/index.ts (no pi-tui runtime graph — -// the same discipline as the other source-parse suites). The overlay renders -// only through pi-tui, so the unit-level contract is the SHAPE of the -// handleMouse override: +// Source-parse structural pins on extensions/history/index.ts (no pi-tui +// runtime graph — the same discipline as the other source-parse suites). +// The overlay renders only through pi-tui, so the unit-level contract is the +// SHAPE of the handleMouse override: // // - wheel-only: every non-wheel event type returns undefined (press/click/ -// drag stay host-owned) and the 12-entry dispatch table gains no 13th entry -// (wheel is not a keybinding — dispatch.test.ts remains the authoritative +// drag stay host-owned) and the dispatch table gains no extra entry (wheel +// is not a keybinding — dispatch.test.ts remains the authoritative // untouched pin); // - ONE consumed wheel return: `handled: true` plus the synthetic target // enrichment, reached by every wheel path including the no-op regions — // this closes the pre-existing fullscreen SGR-fallthrough hazard by -// construction (see tmp/c2u4-qa-prechange-record.md); +// construction; // - fixed 30-row geometry routing: list region y 5–14, preview region y 17–26, // all other rows consumed no-ops; // - list wheel: sign × |wheelDelta| steps through moveDown (the arrow grow @@ -33,7 +32,7 @@ const selectorSource = fs.readFileSync( "utf8", ); -// T13 — AC-L6-1: wheel-only override + no 13th dispatch entry. +// T13 — AC-L6-1: wheel-only override + no extra dispatch entry. test("handleMouse override is wheel-only and the dispatch table keeps 12 entries (AC-L6-1)", () => { const decl = selectorSource.indexOf("override handleMouse("); @@ -143,7 +142,7 @@ test("region constants 5-14 / 17-26 route the y comparisons (AC-L6-3)", () => { const body = selectorSource.slice(decl, end); assert.ok( - body.includes("event.y >= this.listWheelFirstRow") && + body.includes("event.y >= LIST_WHEEL_Y_FIRST") && body.includes("event.y <= LIST_WHEEL_Y_LAST"), "the list branch must compare y against the list band", ); @@ -170,7 +169,7 @@ test("list wheel routes sign-clamped steps through moveDown/moveUp (AC-L6-4)", ( "delta must default an absent wheelDelta to 0", ); - const listStart = body.indexOf("if (event.y >= this.listWheelFirstRow"); + const listStart = body.indexOf("if (event.y >= LIST_WHEEL_Y_FIRST"); const listEnd = body.indexOf("} else if (", listStart); assert.ok( listStart >= 0 && listEnd > listStart, From a225102f219d8c7d12864502fdfe80c302182579 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:45:06 -0300 Subject: [PATCH 12/13] docs(history): compaction is not a retention limit Clarify in the prompt-history docs that compaction is housekeeping for performance: it consolidates capture files and drops the oldest entries to bound file/line counts, but it does not remove prompts from the resulting store and is not a data-retention or automatic-deletion policy. Prompts leave the store only through the delete flow (or manual removal), and compaction honors tombstones so deleted prompts stay deleted. --- docs/prompt-history.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/prompt-history.md b/docs/prompt-history.md index 6a62d81e8..e8b630d34 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -102,3 +102,15 @@ trusted (unreadable, corrupt, wrong shape), history is blocked with a recovery warning instead of resurfacing hidden prompts, and deletes refuse to silently rewrite it. Recovery is explicit — restore the file or delete it yourself (hidden prompts may then reappear). + +## Compaction is not a retention limit + +When a project's store grows past the GC thresholds, compaction merges the +small capture files into fewer, larger ones and drops the oldest entries to +bound the file count and line count. This is housekeeping for performance: +it consolidates history but does not remove prompts from the resulting +store, and it is not a data-retention or automatic-deletion policy. + +Prompts leave the store only through the delete flow above (or by removing +the files manually). Compaction honors tombstones and never resurrects a +deleted prompt: deleted content stays deleted across compactions. From 59811531f56e7cab97e8426a7c41390556991300 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:11:02 -0300 Subject: [PATCH 13/13] fix(history): strip-types-safe constructors for the node test runner --- extensions/history/index.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index c07b7130b..5a79c4483 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -182,10 +182,12 @@ type SelectorNotify = ( /** Single rendered row; always occupies exactly one terminal row. */ class FixedRowText { - constructor( - private text: string = "", - private readonly centered = false, - ) {} + private text: string = ""; + private readonly centered: boolean; + constructor(text: string = "", centered = false) { + this.text = text; + this.centered = centered; + } /** Replace the row content in place; padding contract comes from render(). */ setText(next: string): void { @@ -283,6 +285,7 @@ class PromptHistorySelector extends Container implements Focusable { * and every other key is swallowed. Nothing is deleted on the arming * press. */ + private readonly onNotify?: SelectorNotify; private confirmArmed = false; /** Dispatch table: first match wins, fallthrough last. */ @@ -349,7 +352,7 @@ class PromptHistorySelector extends Container implements Focusable { records: PromptRecord[], onSelect: (record: PromptRecord) => void, onCancel: () => void, - private readonly onNotify?: SelectorNotify, + onNotify?: SelectorNotify, ) { super(); @@ -359,6 +362,7 @@ class PromptHistorySelector extends Container implements Focusable { this.loadedCount = initialLoadedCount(records.length, INITIAL_BATCH); this.onSelect = onSelect; this.onCancel = onCancel; + this.onNotify = onNotify; // ── Search panel (top) ── this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));