Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
9b56e0b
feat(history): per-instance JSONL store, project identity, storage tests
carolitascl Sep 18, 2026
2b90751
fix(history): stable registry collision mappings
carolitascl Sep 18, 2026
a5ff13d
feat(history): read, ordering, deduplication, and project/global quer…
carolitascl Sep 18, 2026
73c55ff
docs(history): correct drainGlobal seed-ordering docblock
carolitascl Sep 18, 2026
366b179
feat(history): history selector TUI and command/shortcut wiring
carolitascl Sep 18, 2026
d354c8a
fix(history): intact astral characters, correct row padding, honest c…
carolitascl Sep 18, 2026
c63caa5
feat(history): transcript migration and seeding
carolitascl Sep 18, 2026
6b61c98
fix(history): migration renames only after the seed write; init off t…
carolitascl Sep 18, 2026
96443ee
feat(history): GC/compaction with active-writer and failure-path tests
carolitascl Sep 18, 2026
7cc79b2
feat(history): tombstones, deletion, and privacy semantics
carolitascl Sep 18, 2026
c9c1c51
fix(history): pid-scoped compact filename; accurate GC threshold comment
carolitascl Sep 18, 2026
72b5adf
feat(history): sync extension with pi-history latest
carolitascl Sep 21, 2026
e5746ec
refactor(history): extract shared header counts helper
carolitascl Sep 21, 2026
fdef2fc
fix(history): satisfy upstream typecheck gate
carolitascl Sep 21, 2026
26bd9b5
test(history): remove machine-specific fixed paths from test fixtures
carolitascl Sep 22, 2026
c18271f
fix(test): match node:test TestFn callback type in skip wrappers
carolitascl Sep 22, 2026
1c59b11
fix(history): restore review fixes clobbered by the upstream sync
carolitascl Sep 23, 2026
a22588f
fix(history): satisfy upstream typecheck gate
carolitascl Sep 24, 2026
353a99f
fix(history): satisfy upstream typecheck gate
carolitascl Sep 24, 2026
ea7e33b
fix(history): satisfy upstream typecheck gate
carolitascl Sep 24, 2026
3f182b8
GentlePromptEditor now owns text selection natively: shift+home /
carolitascl Sep 22, 2026
863a57c
fix: vendor decodePrintableKey so the packed runtime loads selection-…
carolitascl Sep 23, 2026
cd94577
test(selection): pin terminal-key decode, release filtering and one-u…
carolitascl Sep 24, 2026
d881e6a
Closing the /gentle:agents overlay restored the editor with an
carolitascl Sep 22, 2026
8cc401e
tui.invalidate() propagated to every component, so closing an overlay
carolitascl Sep 22, 2026
d1b6d85
Merge branch 'fix/overlay-close-repaint' into feat/selection-correctness
carolitascl Sep 24, 2026
a584d8b
revert: drop overlay repaint from the selection PR
carolitascl Sep 24, 2026
84c1232
fix(history): make prompt capture opt-in and document the store
carolitascl Sep 24, 2026
31e7d50
Merge upstream/main into feat/history-slice-01-store
carolitascl Sep 24, 2026
5501d12
fix(history): fail closed on untrustworthy hidden.json tombstones
carolitascl Sep 24, 2026
ecf148b
merge: sync slice-02 stack with slice-01 tip
carolitascl Sep 24, 2026
25a1dd1
fix(history): make prompt capture opt-in and document the store
carolitascl Sep 24, 2026
041973d
fix(history): make prompt capture opt-in and document the store
carolitascl Sep 24, 2026
5d85a8f
fix(history): make prompt capture opt-in and document the store
carolitascl Sep 24, 2026
831bbe9
fix(history): make prompt capture opt-in and document the store
carolitascl Sep 24, 2026
a6b4722
Merge remote-tracking branch 'upstream/main' into feat/history-slice-…
carolitascl Sep 24, 2026
e787ce4
chore(readme): remove README delta from history slice
carolitascl Sep 24, 2026
892da55
chore(readme): remove README delta from history slice
carolitascl Sep 24, 2026
aae37cb
chore(readme): remove README delta from history slice
carolitascl Sep 24, 2026
e81e194
chore(readme): remove README delta from history slice
carolitascl Sep 24, 2026
3cc57c7
chore(readme): remove README delta from history slice
carolitascl Sep 24, 2026
a663195
chore(readme): remove README delta from history slice
carolitascl Sep 24, 2026
6e27038
Merge remote-tracking branch 'upstream/main' into feat/history-slice-…
carolitascl Sep 24, 2026
6786efd
chore(ci): re-trigger checks
carolitascl Sep 24, 2026
f11fdba
Merge remote-tracking branch 'upstream/main' into feat/history-slice-…
carolitascl Sep 24, 2026
674a160
Merge remote-tracking branch 'upstream/main' into feat/history-slice-…
carolitascl Sep 24, 2026
9fb1bd5
Merge remote-tracking branch 'upstream/main' into feat/history-slice-…
carolitascl Sep 24, 2026
ccbd4f6
Merge remote-tracking branch 'upstream/main' into feat/history-slice-…
carolitascl Sep 24, 2026
d50702d
Merge branch 'feat/history-slice-01-store' into feat/history-slice-02…
carolitascl Sep 24, 2026
aae5d7d
Merge branch 'feat/history-slice-02-read' into feat/history-slice-03-…
carolitascl Sep 24, 2026
653c01d
Merge branch 'feat/history-slice-03-selector' into feat/history-slice…
carolitascl Sep 24, 2026
1f91268
Merge branch 'feat/history-slice-04-seed' into feat/history-slice-05-…
carolitascl Sep 24, 2026
2fcacf7
Merge branch 'feat/history-slice-05-delete' into feat/history-slice-0…
carolitascl Sep 24, 2026
f4b7a33
fix(history): gate legacy seeding/migration behind capture opt-in
carolitascl Sep 24, 2026
c4f20d5
Merge branch 'feat/history-slice-04-seed' into feat/history-slice-05-…
carolitascl Sep 24, 2026
89ac348
fix(history): restore fail-closed tombstones and honest delete UX
carolitascl Sep 25, 2026
417b9fd
fix(history): port fail-closed tombstones to the seed slice
carolitascl Sep 25, 2026
a500f39
Merge branch 'feat/history-slice-05-delete' into feat/history-slice-0…
carolitascl Sep 25, 2026
e2cca1f
fix(history): scope the gc slice to lifecycle work
carolitascl Sep 25, 2026
a225102
docs(history): compaction is not a retention limit
carolitascl Sep 25, 2026
7f3aed8
feat(history): add the selector open flow
carolitascl Sep 25, 2026
8745414
feat(history): add selector search and list windowing
carolitascl Sep 25, 2026
6ad6191
feat(history): add selector preview and mouse handling
carolitascl Sep 25, 2026
649711c
Merge commit '417b9fd' into feat/history-slice-04-seed
carolitascl Sep 25, 2026
146db16
Merge commit '89ac348' into feat/history-slice-05-delete
carolitascl Sep 25, 2026
e302337
fix(history): modal delete confirm, read-only session rows, GENTLE_PI…
carolitascl Sep 25, 2026
e29fb43
Merge commit 'a225102f' into feat/history-slice-06-gc
carolitascl Sep 25, 2026
5981153
fix(history): strip-types-safe constructors for the node test runner
carolitascl Sep 25, 2026
ec8d21c
Merge branch 'feat/selection-correctness' into tmp/merge10
carolitascl Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions docs/prompt-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Prompt history

Slice 1 of the prompt-history extension (#819 split) shipped the storage
layer: a per-instance JSONL capture store, project identity, and the
read/write primitives later slices build on. The selector UI and deletion
shipped in later slices; GC is the one part that still arrives later.

## Capture is opt-in

Recording is **off by default**. Delivered prompts can contain secrets, so
nothing is stored unless you explicitly opt in:

```bash
GENTLE_PI_HISTORY_ENABLE=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.

## Legacy migration and seeding are opt-in

Importing past prompts is part of capture: opening the history selector while
capture is enabled also migrates legacy editor-history stores and runs the
one-time seed bootstrap from past session transcripts. With capture off, the
selector warns and returns before any of that — no migration, no seed, no
store files.

An import creates **new searchable copies** under `~/.pi/agent/history`. The
source transcripts stay untouched and read-only. Turning capture off again
does not remove copies that were already imported: delete them manually as
described in "What disabling capture does" below.

## Where the files live

Everything sits under `~/.pi/agent/history/`:

- `registry.json` — advisory map of project hash → cwd, used for display
labels.
- `projects/<hash>/<instance>.jsonl` — one append-only capture file per pi
process.

`<hash>` is the first 16 hex chars of the SHA-256 of the canonicalized project
cwd; `<instance>` 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 added the rebuildable `seed.jsonl` and the scope drains/deletes behind
the selector; GC is still to come.

## 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.
Individual prompts can be deleted from the history selector while capture is
on (see "Delete" below); the store directory itself is removed by hand:

```bash
rm -rf ~/.pi/agent/history # whole store
rm -rf ~/.pi/agent/history/projects/<hash> # one project (see registry.json)
```

## Delete

The selector's delete key (`ctrl+shift+backspace`) is a two-step y/n
confirmation:

1. The first press **arms** the delete for the selected row: the footer
shows "Delete this prompt from history (y/n)? Prompt stays in session
log" and the row highlights in red.
2. While armed, the next key decides: `y` executes the delete, `n` or
`Esc` cancels, and any other key is ignored — nothing is typed into the
search box and the overlay stays open.

What a delete does depends on where the prompt came from:

- **Editor-stored prompts** (captured into the store's `.jsonl` files) are
deleted: every stored copy is removed from the store in one atomic
rewrite per affected file. The session transcript keeps the original.
- **Session-derived prompts** (seeded from past transcripts) are
read-only: a delete press on them does nothing. Session transcripts are
immutable and owned by Pi core — the extension never writes them.

Failures surface an error toast and never lie about state: a failed store
delete removes nothing and aborts ("Store delete failed; nothing was
removed."), while a failed tombstone write after a store delete leaves the
store row removed but the prompt may reappear from session transcripts
("Deleted from the store, but hiding failed — the prompt may reappear
from session transcripts.").

The tombstone file (`hidden.json`) is a bounded cache, not a retention
guarantee: it holds at most **1000 keys** in recency order (oldest first,
newest last); hiding a 1001st prompt drops the oldest key, and that prompt
may reappear in the list and can be deleted again. The file still fails
closed: if `hidden.json` exists but cannot be 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.
24 changes: 22 additions & 2 deletions extensions/gentle-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { sidebarHeader, sidebarPart } from "../lib/shell-sidebar.ts";
import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
import { SessionChanges, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
import { SelectionEngine } from "../lib/selection-engine.ts";

// Gentle Shell: the visual layer gentle-pi puts on top of pi. It installs the
// status bar, the petal prompt, the working-tree changes widget and overlay,
Expand Down Expand Up @@ -233,6 +234,11 @@ export class GentlePromptEditor extends CustomEditor {
private animationPolicy: AnimationPolicy = "quality";
private pulse: NodeJS.Timeout | undefined;
private readonly deps: PromptEditorDeps;
// Native selection engine (shift+home/end, alt+a, replace-on-key): ported
// from pi-select-del so the petal prompt owns the feature without factory
// composition. Constructed with `this`; the internals probe degrades to
// passthrough on pi drift, costing only the selection features.
private readonly selectionEngine: SelectionEngine;
// CustomEditor keeps its own `keybindings` private, so this class holds
// its own reference to run the same app.interrupt match before deciding
// whether to swallow the keystroke.
Expand All @@ -246,6 +252,7 @@ export class GentlePromptEditor extends CustomEditor {

constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, deps: PromptEditorDeps) {
super(tui, theme, keybindings);
this.selectionEngine = new SelectionEngine(this);
this.deps = deps;
this.keybindingsManager = keybindings;
}
Expand Down Expand Up @@ -294,6 +301,13 @@ export class GentlePromptEditor extends CustomEditor {
* and never reach this branch.
*/
override handleInput(data: string): void {
// Selection keys (shift+home/end, alt+a, replace-on-selection) are
// dispatched here, in front of the petal's own chain; everything else
// collapses any active selection and flows into handleInputNative.
this.selectionEngine.handleInput(data, (d) => this.handleInputNative(d));
}

private handleInputNative(data: string): void {
// Any keystroke that is not the confirming Esc ends the pending idle
// clear, even one that leaves the text identical (type, then delete).
if (this.pendingIdleClearDeadline !== undefined && !this.keybindingsManager.matches(data, "app.interrupt")) {
Expand Down Expand Up @@ -387,12 +401,16 @@ export class GentlePromptEditor extends CustomEditor {
}

render(width: number): string[] {
const lines = super.render(Math.max(1, width - 2));
const inner = Math.max(1, width - 2);
const lines = super.render(inner);
if (this.getText() === "" && lines.length === 3) lines[1] = withPromptHint(lines[1], PROMPT_HINT, this.deps.fg);
// Native selection highlight on the content rows (before the frame walls
// are added); the selection hint rides the petal's bottom rule below.
const decorated = this.selectionEngine.decorateRows(lines, inner, 0);
const state = this.promptState === PROMPT_STATE.WORKING && this.deps.pending() ? PROMPT_STATE.QUEUED : this.promptState;
// The frame keeps the theme's border color rather than pi's thinking-level
// color, so the prompt reads as one panel with the cards around it.
return framePromptLines(lines, width, {
const rows = framePromptLines(decorated, width, {
state,
tick: this.tick,
borderColor: (text) => this.deps.fg(PROMPT_FRAME_ROLE, text),
Expand All @@ -404,6 +422,8 @@ export class GentlePromptEditor extends CustomEditor {
? IDLE_ESC_CLEAR_HINT
: undefined,
});
rows[rows.length - 1] = this.selectionEngine.decorateBottomRule(rows[rows.length - 1] ?? "", width, "╯");
return rows;
}

dispose(): void {
Expand Down
38 changes: 38 additions & 0 deletions extensions/history/atomic-write.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import path from "node:path";
// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history
// SPDX-License-Identifier: MIT

import fs from "node:fs";

/**
* Shared atomic JSON writer (design §D3): serialize to a `.tmp` file in the
* SAME directory as the target, then renameSync it into place — a same-dir
* rename is atomic on POSIX/APFS, so readers see the old or the new file,
* never a partial write. Any error returns false and never throws.
*
* No fsync: both consumers treat lost writes as derived cache (a lost index
* rebuilds on the next open; a lost tombstone resurfaces a prompt the user
* can re-delete), so the per-write fsync cost is not justified — the crash
* window is documented, not fixed. The staging name is unique per write
* (`.tmp-<pid>-<ts>`, the same convention as the store.ts writers): the
* state dir is shared across concurrent pi instances, so a fixed
* `${filePath}.tmp` would let two writers clobber the same staging file
* (torn target JSON, spurious rename failures). A failed write unlinks its
* staging file, so orphaned `.tmp` files do not accumulate.
*/
export function writeJsonAtomic(filePath: string, value: unknown): boolean {
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(tmpPath, JSON.stringify(value), "utf8");
fs.renameSync(tmpPath, filePath);
return true;
} catch {
try {
fs.unlinkSync(tmpPath);
} catch {
// staging file never created or already renamed
}
return false;
}
}
145 changes: 145 additions & 0 deletions extensions/history/hide-prompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history
// SPDX-License-Identifier: MIT

import fs from "node:fs";
import path from "node:path";
import { writeJsonAtomic } from "./atomic-write.ts";
import { promptDedupKey } from "./selector-helpers.ts";

/** Name of the tombstone file inside the injected state dir (spec C4). */
const HIDE_FILE_NAME = "hidden.json";

/**
* Retention cap for hidden.json (slice-05 D5): the tombstone file is a
* rebuildable derived cache, not a retention guarantee, so it holds at
* most this many keys in recency order; hiding past the cap drops the
* OLDEST keys from the front.
*/
export const HIDE_FILE_MAX_ENTRIES = 1000;

/**
* Shared recovery warning for a file that exists but cannot be trusted
* (spec C4, fail-closed READ half): toast-suitable, names hidden.json, and
* gives the user the explicit restore-or-delete choice.
*/
const RECOVERY_MESSAGE =
"The prompt-history hide list (hidden.json) is corrupt or unreadable. History is blocked until you restore the file or delete it (hidden prompts may then reappear).";

/**
* Result of one tombstone write (spec C4): `written` on a successful atomic
* write, or an error object carrying a short, toast-suitable reason. Never
* throws.
*/
export type HideResult =
| { status: "written" }
| { status: "error"; message: string };

/**
* Result of one tombstone read (spec C4): `trusted` keys when the file is
* missing or holds a valid array, or `untrusted` when the file exists but
* cannot be trusted. History reads FAIL CLOSED on `untrusted`: callers must
* block the drain instead of emptying the tombstone set, because hidden
* prompts may contain secrets an empty set would resurface.
*/
export type HiddenRead =
| { status: "trusted"; keys: Set<string> }
| {
status: "untrusted";
reason: "unreadable" | "corrupt" | "malformed";
message: string;
};

/**
* Read the tombstone key set from `stateDir/hidden.json` — the READ half of
* the hide-file contract (spec C4). Fail-closed for history: a file that
* exists but is unreadable, corrupt, or wrong-shaped returns `untrusted`
* with the recovery warning so callers block the drain; it never degrades
* to an empty trusted set. A MISSING file — before any deletion — is the
* safe empty case and reads `trusted` with no keys. A valid array is
* trusted; junk items inside it are ignored, never trusted. Keys are
* `promptDedupKey` strings written by `hidePrompt`; the call never throws.
* A valid array's stored order is preserved (the recency order — oldest
* first — that `hidePrompt` maintains and caps).
*/
export function readHiddenPrompts(stateDir: string): HiddenRead {
let raw: string;
try {
raw = fs.readFileSync(path.join(stateDir, HIDE_FILE_NAME), "utf8");
} catch (error) {
const code = (error as { code?: unknown } | null | undefined)?.code;
if (code === "ENOENT") {
// Missing before any deletion: the safe empty tombstone set.
return { status: "trusted", keys: new Set<string>() };
}
return {
status: "untrusted",
reason: "unreadable",
message: RECOVERY_MESSAGE,
};
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { status: "untrusted", reason: "corrupt", message: RECOVERY_MESSAGE };
}
const keys = new Set<string>();
if (!Array.isArray(parsed)) {
return {
status: "untrusted",
reason: "malformed",
message: RECOVERY_MESSAGE,
};
}
for (const item of parsed) {
if (typeof item === "string" && item !== "") keys.add(item);
}
return { status: "trusted", keys };
}

/**
* Write the tombstone key for `text` into `stateDir/hidden.json` — the
* WRITE half of the hide-file contract (spec C4). The key is the shared
* `promptDedupKey` (byte-match normative with the merge filter — never a
* re-implementation). The file array is RECENCY-ordered — oldest key
* first, newest key appended last — and re-hiding an existing key
* refreshes it to the end (delete + add, since Set.add on a present
* member keeps its old position). The file is capped at
* `HIDE_FILE_MAX_ENTRIES` (1000): after the append, keys drop from the
* FRONT until the file fits, so hidden.json stays a bounded cache — a
* dropped (oldest) prompt may reappear in the list and can be deleted
* again. Keys persist in that insertion order — NO sort — via the shared
* atomic tmp+rename writer. An untrusted existing file is never silently
* reset (a clean rewrite would clear the blocked state one hide later):
* hidePrompt refuses with the recovery warning until the user restores or
* deletes the file. A missing file is the clean baseline; any write
* failure returns an error object for the delete-flow toast; the call
* never throws.
*/
export function hidePrompt(stateDir: string, text: string): HideResult {
const read = readHiddenPrompts(stateDir);
if (read.status === "untrusted") {
// Refuse without writing: never reset the untrusted state silently.
return { status: "error", message: read.message };
}
// Recency order (slice-05 D5): the set iterates in stored file order
// (oldest first); delete+add refreshes a re-hidden key to the END.
const key = promptDedupKey(text);
read.keys.delete(key);
read.keys.add(key);
// Cap: drop the OLDEST keys from the front once over the limit.
const ordered = [...read.keys];
if (ordered.length > HIDE_FILE_MAX_ENTRIES) {
ordered.splice(0, ordered.length - HIDE_FILE_MAX_ENTRIES);
}
const written = writeJsonAtomic(
path.join(stateDir, HIDE_FILE_NAME),
ordered,
);
return written
? { status: "written" }
: {
status: "error",
message: "Could not write the hide file; the prompt may reappear.",
};
}
Loading