Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
a22588f
fix(history): satisfy upstream typecheck gate
carolitascl Sep 24, 2026
353a99f
fix(history): satisfy upstream typecheck gate
carolitascl Sep 24, 2026
25a1dd1
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
e81e194
chore(readme): remove README delta from history slice
carolitascl Sep 24, 2026
6786efd
chore(ci): re-trigger checks
carolitascl Sep 24, 2026
674a160
Merge remote-tracking branch 'upstream/main' into feat/history-slice-…
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
f4b7a33
fix(history): gate legacy seeding/migration behind capture opt-in
carolitascl Sep 24, 2026
417b9fd
fix(history): port fail-closed tombstones to the seed slice
carolitascl Sep 25, 2026
649711c
Merge commit '417b9fd' into feat/history-slice-04-seed
carolitascl Sep 25, 2026
f6a832f
fix(history): preserve migration retry and tombstones
Alan-TheGentleman Sep 26, 2026
15249c5
Merge remote-tracking branch 'origin/main' into fix/history-pr1391-ad…
Alan-TheGentleman Sep 26, 2026
cc6ecca
fix(history): preserve concurrent legacy migration data
Alan-TheGentleman Sep 26, 2026
6b38444
docs(odd): record history migration verification evidence
Alan-TheGentleman Sep 26, 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
28 changes: 22 additions & 6 deletions docs/prompt-history.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
# Prompt history

Slice 1 of the prompt-history extension (#819 split) ships the storage layer only:
a per-instance JSONL capture store, project identity, and the read/write
primitives later slices build on. The selector UI, deletion/scope drains, and GC
arrive in later slices of the chain.
Prompt history stores captured prompts per pi instance and can import older
history and project session transcripts. Deletion and compaction arrive in
later slices of the chain.

## Capture is opt-in

Expand All @@ -21,6 +20,20 @@ GENTLE_PI_HISTORY_CAPTURE=1 pi
- With capture off the extension is inert: no registry entry, no files, and
prompts are never written.

## Legacy migration and seeding are opt-in

Importing past prompts is part of capture: the first delivered prompt in an
opted-in session attempts legacy migration and one-time bootstrap from project
session transcripts. The selector reads the store but does not initiate import.
With capture off, both capture and the selector leave the store untouched.
Failed migration reads can be retried on a later session; untrusted deletion
records defer transcript bootstrap until they can be read safely.

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

## Where the files live

Everything sits under `~/.pi/agent/history/`:
Expand All @@ -29,6 +42,8 @@ Everything sits under `~/.pi/agent/history/`:
labels.
- `projects/<hash>/<instance>.jsonl` — one append-only capture file per pi
process.
- `projects/<hash>/seed.jsonl` — one-time transcript import for this project.
- `history-global.jsonl` — imported legacy editor-history prompts.

`<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:
Expand All @@ -37,8 +52,9 @@ cwd; `<instance>` is a per-process UUID. Each line is one delivered prompt:
{"v":1,"text":"the prompt as delivered","ts":1700000000000}
```

UI command-like prompts (`/name ...`) and empty lines are never stored. Later
slices add the rebuildable `seed.jsonl`, scope drains/deletes, and GC.
UI command-like prompts (`/name ...`) and empty lines are never captured.
Imported copies remain on disk when capture is turned off; the seed is not
regenerated if it already exists, to avoid resurrecting deleted prompts.

## Who can read them

Expand Down
31 changes: 27 additions & 4 deletions extensions/history/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ import {
} from "@earendil-works/pi-tui";
import {
appendSessionCapture,
bootstrapProjectSeed,
type DrainResult,
drainGlobal,
drainProject,
ensureRegistryEntry,
migrateLegacyStores,
openSessionWriter,
type SessionWriterState,
} from "./store.ts";
Expand Down Expand Up @@ -87,14 +89,18 @@ const PREVIEW_WHEEL_Y_LAST = 26;
const ENTRY_PREFIX_WIDTH = 2;

// v2 multi-concurrency store root (design: tmp/multi-concurrency-design.md).
const PI_HISTORY_ROOT = join(homedir(), ".pi", "agent", "history");
const AGENT_DIR = join(homedir(), ".pi", "agent");
const PI_HISTORY_ROOT = join(AGENT_DIR, "history");
const SESSIONS_ROOT = join(homedir(), ".pi", "agent", "sessions");

export interface HistoryDeps {
env?: NodeJS.ProcessEnv;
root?: string;
cwd?: string;
instanceId?: string;
now?: () => number;
agentDir?: string;
sessionsRoot?: string;
}

/**
Expand Down Expand Up @@ -969,20 +975,37 @@ export default function promptHistoryExtension(
const cwd = deps.cwd ?? process.cwd();
const instanceId = deps.instanceId ?? randomUUID();
const now = deps.now ?? Date.now;
const agentDir = deps.agentDir ?? AGENT_DIR;
const sessionsRoot = deps.sessionsRoot ?? SESSIONS_ROOT;
let writerState: SessionWriterState | null = null;

/**
* One-time init per extension load: register the project in the advisory
* registry, then open this instance's exclusive capture file. Legacy
* migration and seed bootstrap join this init order in a later slice.
* One-time init per extension load: migrate legacy stores, register the
* project, bootstrap the seed, then open this instance's exclusive file.
*/
const getWriter = (): SessionWriterState => {
if (!writerState) {
try {
migrateLegacyStores(root, agentDir);
} catch {
// migration is best-effort; the gate keeps it one-shot
}
try {
ensureRegistryEntry(root, cwd);
} catch {
// registry is advisory
}
try {
bootstrapProjectSeed(
root,
cwd,
sessionsRoot,
500,
root,
);
} catch {
// bootstrap is a rebuildable cache
}
writerState = openSessionWriter(root, cwd, instanceId);
}
return writerState;
Expand Down
39 changes: 39 additions & 0 deletions extensions/history/load-shared-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history
// SPDX-License-Identifier: MIT

import fs from "node:fs";

interface SharedHistoryEntry {
text: string;
}

function isSharedHistoryEntry(value: unknown): value is SharedHistoryEntry {
if (!value || typeof value !== "object") return false;
return typeof (value as { text?: unknown }).text === "string";
}

function toSharedHistoryValue(value: unknown): string | null {
if (typeof value === "string") return value.length > 0 ? value : null;
if (isSharedHistoryEntry(value))
return value.text.length > 0 ? value.text : null;
return null;
}

function isNonEmptyString(value: string | null): value is string {
return typeof value === "string" && value.length > 0;
}

export function loadSharedHistory(historyFile: string): string[] {
if (!fs.existsSync(historyFile)) return [];

try {
const raw = fs.readFileSync(historyFile, "utf8");
const parsed: unknown = JSON.parse(raw);

if (!Array.isArray(parsed)) return [];

return parsed.map(toSharedHistoryValue).filter(isNonEmptyString);
} catch {
return [];
}
}
Loading
Loading