pi-delta-db 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.0] - 2026-11-01
11
+
12
+ ### Added
13
+
14
+ - Durable outbox: records are fsynced under `~/.pi/agent/delta-db/outbox/` before Delta is
15
+ contacted, and a failed append is retried on the next message and the next session start.
16
+ - Thoughts, tool calls, and tool results are recorded, not only message text.
17
+ - `/delta off` and `/delta on` are remembered for the session.
18
+ - Bindings whose session file is gone and that have not been touched for a week are pruned.
19
+ - Tests for record shapes, sequence assignment, outbox retry, and pruning (`npm test`).
20
+ - CI runs `npm run check` and `npm test` on every push.
21
+ - npm publish workflow: a published release publishes to npmjs.org when `NPM_TOKEN` is set.
22
+ - GitHub Packages mirror: `.github/workflows/publish-github-packages.yml` publishes
23
+ `@morizkay/pi-delta-db` on every published release, populating the repository's Packages tab.
24
+ npmjs.org (`pi-delta-db`) remains the installable channel.
25
+
26
+ ## [0.1.0] - 2026-11-01
27
+
28
+ ### Added
29
+
30
+ - `delta_store` tool: append text to this session's private Delta thread, with optional
31
+ `title` and `role`.
32
+ - `delta_read` tool: list threads, search thread text, or show one transcript.
33
+ - `delta_db` tool: read-only inspection of Delta's local store (`stats`, `root <id>`).
34
+ - Automatic recording: every finalized user and assistant message is appended to the
35
+ session's Delta thread in order, with the thread created on first use and titled from
36
+ the first prompt.
37
+ - `/delta` command: show recording state, thread id, title, and share URL; `/delta on|off`
38
+ toggles recording for the session.
39
+ - `PI_DELTA_RECORD=0` to start Pi with recording disabled.
40
+ - Writes are serialized through one queue and take their sequence from `delta thread status`,
41
+ so concurrent appends cannot reuse a sequence.
42
+ - Recording failures warn once per session and never interrupt a Pi turn.
43
+
44
+ ### Fixed
45
+
46
+ - A repeated `messageId` made Delta replace earlier records, silently erasing previous turns
47
+ in a continued session. Message ids are now unique per message.
48
+ - `session_info_update` re-titled the thread on every continuation. The title is now written
49
+ once and remembered in the session binding.
50
+
51
+ [Unreleased]: https://github.com/morizkay/pi-delta-db/compare/v0.2.0...HEAD
52
+ [0.2.0]: https://github.com/morizkay/pi-delta-db/releases/tag/v0.2.0
53
+ [0.1.0]: https://github.com/morizkay/pi-delta-db/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 morizkay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,107 @@
1
- # Temporary Holding Version
1
+ # pi-delta-db
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/pi-delta-db.svg)](https://www.npmjs.com/package/pi-delta-db)
4
+ [![license](https://img.shields.io/npm/l/pi-delta-db.svg)](./LICENSE)
5
+
6
+ Pi extension that lets Pi store and read data through the [Delta](https://delta.dev) CLI (`delta`).
7
+
8
+ Delta's SQLite store behind `delta db` is internal and read-only from outside, so writes go
9
+ through Delta's supported path: a private Delta thread bound to the Pi session, recorded with
10
+ schema-1 JSONL (`delta thread create` + `delta thread append`). Everything Pi stores shows up
11
+ in the Delta app and can be read back with or without Pi.
12
+
13
+ ## Auto-recording
14
+
15
+ Every finalized user message, assistant message, thought, tool call, and tool result is
16
+ appended to this session's Delta thread, in order. The thread is created on the first message
17
+ and titled with the first prompt; continuing a session (`pi -c`) keeps appending to the same thread.
18
+
19
+ Records are written to `~/.pi/agent/delta-db/outbox/` and fsynced before Delta is contacted.
20
+ If Delta is closed, offline, or rejects the write, the turn stays queued and is retried on the
21
+ next message and the next session start. A retry cannot duplicate or erase earlier turns:
22
+ each message id is unique, and Delta replaces a record that reuses one.
23
+
24
+ | Control | Effect |
25
+ | --- | --- |
26
+ | `/delta` | Show recording state, thread id, queued count, title, share URL |
27
+ | `/delta off` / `/delta on` | Toggle recording for this session, remembered next start |
28
+ | `PI_DELTA_RECORD=0` | Start Pi with recording off for this process |
29
+
30
+ Bindings whose Pi session file is gone and that have not been touched for a week are deleted
31
+ on session start.
32
+
33
+ ## Tools
34
+
35
+ | Tool | What it does |
36
+ | --- | --- |
37
+ | `delta_store` | Append text to this session's thread with an explicit `title`/`role`. |
38
+ | `delta_read` | `list` threads, `search` by keywords, or `show` one transcript. |
39
+ | `delta_db` | Read-only storage inspection: `stats` (schema version, node/root counts) or `root <id>`. |
40
+
41
+ ## How it binds
42
+
43
+ One thread per Pi session:
44
+
45
+ - Producer: `--source pi --external-id <pi-session-id>`
46
+ - Created with `--visibility private --organization <current org>`, which is what makes
47
+ `thread append` allowed (Delta rejects appends to unshared threads).
48
+ - The thread id and title state live in `~/.pi/agent/delta-db/<session-id>.json`.
49
+ - Each write asks `delta thread status` for the next sequence, and writes are serialized through
50
+ one queue, so records stay ordered.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ # from npm
56
+ pi install npm:pi-delta-db
57
+
58
+ # from git, or a local checkout in development
59
+ pi install git:github.com/morizkay/pi-delta-db
60
+ pi install ./pi-delta-db
61
+ ```
62
+
63
+ Restart Pi (or run `/reload`). Requirements: Pi `>= 1.4.0`, Node `>= 22.19.0`, the `delta` CLI
64
+ on `PATH`, and a signed-in account (`delta auth login`).
65
+
66
+ For local development of this repository, link it into the agent directory instead:
67
+
68
+ ```bash
69
+ ln -sfn "$(pwd)" ~/.pi/agent/extensions/pi-delta-db
70
+ ```
71
+
72
+ ### GitHub Packages mirror
73
+
74
+ Every release is also mirrored to GitHub Packages as `@morizkay/pi-delta-db` by
75
+ [`.github/workflows/publish-github-packages.yml`](./.github/workflows/publish-github-packages.yml),
76
+ which is why the repository's Packages tab lists it. Prefer npm: GitHub Packages requires a token
77
+ with `read:packages` even for public packages.
78
+
79
+ ```bash
80
+ # ~/.npmrc: //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
81
+ pi install npm:@morizkay/pi-delta-db
82
+ ```
83
+
84
+ ## Development
85
+
86
+ ```bash
87
+ npm install # host packages are peer dependencies, dev-only here
88
+ npm run check # tsc --noEmit
89
+ npm test # record shapes, outbox retry, and prune, against a fake delta
90
+ ```
91
+
92
+ CI runs both on every push. Publishing a GitHub release also mirrors the package to GitHub
93
+ Packages, and publishes to npmjs.org when the `NPM_TOKEN` secret is set
94
+ (`gh secret set NPM_TOKEN`; use an automation token so OTP is not required).
95
+
96
+ ## Gotchas found against delta 0.23.0
97
+
98
+ - `messageId` must be globally unique: a record whose `messageId` matches an earlier one
99
+ **replaces** it, so a repeated id silently erases earlier messages.
100
+ - `session_info_update` re-titles the thread on every append, so the title is written once and
101
+ remembered in the binding file.
102
+ - Appends send records to Delta's cloud (private visibility) — not purely local storage.
103
+ - Records are append-only: nothing can be overwritten or deleted individually.
104
+
105
+ ## License
106
+
107
+ [MIT](./LICENSE) © 2026 morizkay. Changes are documented in the [changelog](./CHANGELOG.md).
package/index.ts ADDED
@@ -0,0 +1,620 @@
1
+ /**
2
+ * pi-delta-db — store and read Pi data through the Delta CLI (`delta`).
3
+ *
4
+ * Delta's SQLite store (`delta db ...`) is internal and read-only from outside,
5
+ * so writes go through Delta's supported path: a private thread bound to this
6
+ * session (`--source pi --external-id <session>`) recorded with schema-1 JSONL.
7
+ *
8
+ * Records are staged to a local outbox before any network call. A failed append
9
+ * stays on disk and is retried; resending is safe because each messageId is unique
10
+ * and Delta replaces a record that reuses one.
11
+ */
12
+
13
+ import { spawn } from "node:child_process";
14
+ import { randomUUID } from "node:crypto";
15
+ import * as fs from "node:fs";
16
+ import * as os from "node:os";
17
+ import * as path from "node:path";
18
+ import { StringEnum } from "@earendil-works/pi-ai";
19
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
+ import { Type } from "typebox";
21
+
22
+ const SOURCE = "pi";
23
+ const STATE_DIR = process.env.PI_DELTA_STATE || path.join(os.homedir(), ".pi", "agent", "delta-db");
24
+ const SESSIONS_DIR = path.join(os.homedir(), ".pi", "agent", "sessions");
25
+ const MAX_CHARS = 12_000;
26
+ const TIMEOUT_MS = 120_000;
27
+ const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
28
+
29
+ export interface Update {
30
+ sessionUpdate: string;
31
+ messageId?: string;
32
+ title?: string;
33
+ content?: { type: string; text: string }[];
34
+ toolCallId?: string;
35
+ name?: string;
36
+ status?: string;
37
+ rawInput?: unknown;
38
+ rawOutput?: unknown;
39
+ }
40
+
41
+ interface Org {
42
+ id: string;
43
+ current?: boolean;
44
+ }
45
+ interface ThreadListItem {
46
+ index?: number;
47
+ id: string;
48
+ name?: string;
49
+ summary?: string;
50
+ }
51
+ interface ThreadStatus {
52
+ producer?: { next_sequence?: number };
53
+ url?: string;
54
+ title?: string;
55
+ }
56
+ interface ThreadShow {
57
+ id?: string;
58
+ name?: string;
59
+ transcript?: string;
60
+ buffer?: string;
61
+ }
62
+ interface Stats {
63
+ schema_version?: number;
64
+ node_count?: number;
65
+ node_compressed_bytes?: number;
66
+ node_types?: { type: string; count: number; compressed_bytes: number }[];
67
+ roots?: { id: string; type: string }[];
68
+ }
69
+ interface ContentBlock {
70
+ type?: string;
71
+ text?: string;
72
+ thinking?: string;
73
+ id?: string;
74
+ name?: string;
75
+ arguments?: unknown;
76
+ }
77
+ interface FinalMessage {
78
+ role?: string;
79
+ content?: string | ContentBlock[];
80
+ toolCallId?: string;
81
+ toolName?: string;
82
+ isError?: boolean;
83
+ }
84
+ /** A session's Delta thread binding. `titled` keeps a continued session from renaming it. */
85
+ interface Binding {
86
+ threadId?: string;
87
+ titled?: boolean;
88
+ enabled?: boolean;
89
+ }
90
+
91
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
92
+
93
+ /** Parse CLI output at its boundary; unparseable text becomes undefined. */
94
+ function parseJson(text: string): JsonValue | undefined {
95
+ if (!text.trim()) return undefined;
96
+ try {
97
+ return JSON.parse(text) as JsonValue;
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ }
102
+
103
+ /** Run `delta`, returning stdout, and turn a failed exit into an Error. */
104
+ function run(args: string[], input?: string): Promise<string> {
105
+ return new Promise((resolve, reject) => {
106
+ const child = spawn("delta", args, { stdio: ["pipe", "pipe", "pipe"] });
107
+ let out = "";
108
+ let err = "";
109
+ const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
110
+ child.stdout.on("data", (chunk) => (out += chunk));
111
+ child.stderr.on("data", (chunk) => (err += chunk));
112
+ child.on("error", (error) => {
113
+ clearTimeout(timer);
114
+ reject(new Error(error.message.includes("ENOENT") ? "`delta` was not found on PATH" : error.message));
115
+ });
116
+ child.on("close", (code) => {
117
+ clearTimeout(timer);
118
+ if (code === 0) {
119
+ resolve(out);
120
+ return;
121
+ }
122
+ // With --json the CLI prints {error:{message}} on stdout; prefer that over stderr noise.
123
+ const reported = (parseJson(out) as { error?: { message?: string } } | undefined)?.error?.message;
124
+ reject(new Error(reported ?? err.trim() ?? `delta ${args.slice(0, 2).join(" ")} exited ${code}`));
125
+ });
126
+ child.stdin.end(input);
127
+ });
128
+ }
129
+
130
+ async function json<T>(args: string[], input?: string): Promise<T> {
131
+ const out = await run(args, input);
132
+ const value = parseJson(out);
133
+ if (value === undefined) throw new Error(`delta ${args.slice(0, 2).join(" ")} returned no JSON`);
134
+ return value as T;
135
+ }
136
+
137
+ function clip(text: string, limit = MAX_CHARS): string {
138
+ return text.length > limit ? `${text.slice(0, limit)}\n…truncated (${text.length} chars)` : text;
139
+ }
140
+
141
+ /** A stable producer key for this Pi session; also the on-disk binding name. */
142
+ function sessionKey(ctx: ExtensionContext): string {
143
+ const raw = ctx.sessionManager.getSessionId() || ctx.sessionManager.getSessionFile() || `cwd-${ctx.cwd}`;
144
+ const tail = String(raw).split(/[\\/]/).pop() ?? "session";
145
+ return tail.replace(/[^A-Za-z0-9_-]/g, "-");
146
+ }
147
+
148
+ function bindingFile(key: string): string {
149
+ return path.join(STATE_DIR, `${key}.json`);
150
+ }
151
+
152
+ function outboxFile(key: string): string {
153
+ return path.join(STATE_DIR, "outbox", `${key}.jsonl`);
154
+ }
155
+
156
+ function readBinding(key: string): Binding | undefined {
157
+ try {
158
+ const saved = parseJson(fs.readFileSync(bindingFile(key), "utf8")) as Binding | undefined;
159
+ return saved && typeof saved === "object" && !Array.isArray(saved) ? saved : undefined;
160
+ } catch {
161
+ // No binding file yet: this session has never written to Delta.
162
+ return undefined;
163
+ }
164
+ }
165
+
166
+ function writeBinding(key: string, binding: Binding): void {
167
+ fs.mkdirSync(STATE_DIR, { recursive: true });
168
+ fs.writeFileSync(bindingFile(key), JSON.stringify(binding, null, 2));
169
+ }
170
+
171
+ /** `PI_DELTA_RECORD=0` forces off for this process; `/delta off` persists in the binding. */
172
+ export function recordingEnabled(key: string): boolean {
173
+ if (process.env.PI_DELTA_RECORD === "0") return false;
174
+ return readBinding(key)?.enabled !== false;
175
+ }
176
+
177
+ export function setRecording(key: string, enabled: boolean): void {
178
+ writeBinding(key, { ...readBinding(key), enabled });
179
+ }
180
+
181
+ async function ensureThread(key: string): Promise<Binding> {
182
+ const bound = readBinding(key);
183
+ if (bound?.threadId) return bound;
184
+ const { organizations } = await json<{ organizations?: Org[] }>(["org", "list", "--json"]);
185
+ const org = organizations?.find((item) => item.current) ?? organizations?.[0];
186
+ if (!org) throw new Error("No Delta organization. Run `delta auth login`, then retry.");
187
+ const created = await json<{ thread_id: string }>([
188
+ "thread",
189
+ "create",
190
+ "--source",
191
+ SOURCE,
192
+ "--external-id",
193
+ key,
194
+ "--visibility",
195
+ "private",
196
+ "--organization",
197
+ org.id,
198
+ "--json",
199
+ ]);
200
+ const binding: Binding = { ...bound, threadId: created.thread_id };
201
+ writeBinding(key, binding);
202
+ return binding;
203
+ }
204
+
205
+ function statusOf(key: string, threadId: string): Promise<ThreadStatus> {
206
+ return json<ThreadStatus>(["thread", "status", "--source", SOURCE, "--external-id", key, "--json", threadId]);
207
+ }
208
+
209
+ /** Schema-1 JSONL continuing at `first`. Exported so tests can lock the envelope. */
210
+ export function assignSequences(first: number, updates: Update[]): string {
211
+ return updates
212
+ .map((update, index) => JSON.stringify({ schemaVersion: 1, sequence: first + index, update }))
213
+ .join("\n");
214
+ }
215
+
216
+ /** Append schema-1 records, continuing this producer's sequence. */
217
+ async function append(key: string, threadId: string, updates: Update[]) {
218
+ const status = await statusOf(key, threadId);
219
+ const first = status.producer?.next_sequence;
220
+ if (first === undefined || !Number.isSafeInteger(first))
221
+ throw new Error("Delta did not report the next sequence for this thread");
222
+ await run(
223
+ ["thread", "append", "--source", SOURCE, "--external-id", key, "--acknowledge", "local", "--json", threadId],
224
+ `${assignSequences(first, updates)}\n`,
225
+ );
226
+ return { count: updates.length, nextSequence: first + updates.length, url: status.url };
227
+ }
228
+
229
+ function textOf(content: string | ContentBlock[] | undefined): string {
230
+ if (typeof content === "string") return content.trim();
231
+ if (!Array.isArray(content)) return "";
232
+ return content
233
+ .filter((block) => block?.type === "text" && typeof block.text === "string")
234
+ .map((block) => String(block.text))
235
+ .join("\n")
236
+ .trim();
237
+ }
238
+
239
+ function textUpdate(role: string | undefined, id: string, text: string): Update {
240
+ return {
241
+ sessionUpdate: role === "user" ? "user_message" : "agent_message",
242
+ messageId: id,
243
+ content: [{ type: "text", text: clip(text) }],
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Turn one finalized Pi message into Delta records.
249
+ * `id` must be unique per message: Delta replaces a record that reuses a messageId.
250
+ */
251
+ export function updatesFromMessage(message: FinalMessage, id: string): Update[] {
252
+ const updates: Update[] = [];
253
+ if (message.role === "toolResult" && message.toolCallId) {
254
+ updates.push({
255
+ sessionUpdate: "tool_call_update",
256
+ toolCallId: message.toolCallId,
257
+ name: message.toolName || "tool",
258
+ status: message.isError ? "failed" : "completed",
259
+ rawOutput: clip(textOf(message.content) || (message.isError ? "failed" : "")),
260
+ });
261
+ return updates;
262
+ }
263
+ const content = message.content;
264
+ if (typeof content === "string") {
265
+ if (content.trim()) updates.push(textUpdate(message.role, `${id}:t0`, content.trim()));
266
+ return updates;
267
+ }
268
+ if (!Array.isArray(content)) return updates;
269
+ const texts: string[] = [];
270
+ let part = 0;
271
+ const flushText = () => {
272
+ const text = texts.join("\n").trim();
273
+ texts.length = 0;
274
+ if (!text) return;
275
+ updates.push(textUpdate(message.role, `${id}:t${part++}`, text));
276
+ };
277
+ for (const [index, block] of content.entries()) {
278
+ if (block?.type === "thinking" && block.thinking?.trim()) {
279
+ flushText();
280
+ updates.push({
281
+ sessionUpdate: "agent_thought",
282
+ messageId: `${id}:thought:${index}`,
283
+ content: [{ type: "text", text: clip(block.thinking.trim()) }],
284
+ });
285
+ } else if (block?.type === "toolCall" && block.id) {
286
+ flushText();
287
+ updates.push({
288
+ sessionUpdate: "tool_call_update",
289
+ toolCallId: block.id,
290
+ name: block.name || "tool",
291
+ status: "in_progress",
292
+ rawInput: block.arguments ?? {},
293
+ });
294
+ } else if (block?.type === "text" && block.text?.trim()) {
295
+ texts.push(block.text);
296
+ }
297
+ }
298
+ flushText();
299
+ return updates;
300
+ }
301
+
302
+ function firstLine(text: string): string {
303
+ const line = text.split("\n").find((part) => part.trim()) ?? "";
304
+ return line.trim().slice(0, 60);
305
+ }
306
+
307
+ /** Append records to the outbox and fsync. A crash after this still retries. */
308
+ export function stageUpdates(key: string, updates: Update[]): void {
309
+ if (!updates.length) return;
310
+ const file = outboxFile(key);
311
+ fs.mkdirSync(path.dirname(file), { recursive: true });
312
+ const fd = fs.openSync(file, "a");
313
+ try {
314
+ fs.writeSync(fd, `${updates.map((update) => JSON.stringify(update)).join("\n")}\n`);
315
+ fs.fsyncSync(fd);
316
+ } finally {
317
+ fs.closeSync(fd);
318
+ }
319
+ }
320
+
321
+ export function pendingUpdates(key: string): Update[] {
322
+ let text: string;
323
+ try {
324
+ text = fs.readFileSync(outboxFile(key), "utf8");
325
+ } catch {
326
+ return [];
327
+ }
328
+ if (!text.trim()) return [];
329
+ return text
330
+ .split("\n")
331
+ .filter(Boolean)
332
+ .map((line) => {
333
+ try {
334
+ return JSON.parse(line) as Update;
335
+ } catch {
336
+ throw new Error(`corrupt outbox record for ${key}; sharing paused without dropping it`);
337
+ }
338
+ });
339
+ }
340
+
341
+ /** Send the outbox, then drop it. On failure the file stays for the next attempt. */
342
+ export async function flushPending(key: string): Promise<{ count: number; threadId?: string; url?: string; nextSequence?: number }> {
343
+ const updates = pendingUpdates(key);
344
+ if (!updates.length) return { count: 0 };
345
+ const binding = await ensureThread(key);
346
+ if (!binding.threadId) throw new Error("Delta thread was not created");
347
+ const result = await append(key, binding.threadId, updates);
348
+ fs.rmSync(outboxFile(key), { force: true });
349
+ return { count: result.count, threadId: binding.threadId, url: result.url, nextSequence: result.nextSequence };
350
+ }
351
+
352
+ function sessionFileExists(sessionsDir: string, key: string): boolean {
353
+ const stack = [sessionsDir];
354
+ while (stack.length) {
355
+ const dir = stack.pop();
356
+ if (!dir) break;
357
+ let entries: fs.Dirent[];
358
+ try {
359
+ entries = fs.readdirSync(dir, { withFileTypes: true });
360
+ } catch {
361
+ continue;
362
+ }
363
+ for (const entry of entries) {
364
+ if (entry.isDirectory()) stack.push(path.join(dir, entry.name));
365
+ else if (entry.name.endsWith(`_${key}.jsonl`)) return true;
366
+ }
367
+ }
368
+ return false;
369
+ }
370
+
371
+ /** Drop bindings whose session file is gone and that have not been touched for a week. */
372
+ export function pruneBindings(currentKey: string, sessionsDir: string, now = Date.now()): string[] {
373
+ if (!fs.existsSync(sessionsDir) || !fs.existsSync(STATE_DIR)) return [];
374
+ let names: string[];
375
+ try {
376
+ names = fs.readdirSync(STATE_DIR);
377
+ } catch {
378
+ return [];
379
+ }
380
+ const removed: string[] = [];
381
+ for (const name of names) {
382
+ if (!name.endsWith(".json")) continue;
383
+ const key = name.slice(0, -".json".length);
384
+ if (key === currentKey || !/^[\w-]+$/.test(key)) continue;
385
+ const file = path.join(STATE_DIR, name);
386
+ if (now - fs.statSync(file).mtimeMs < PRUNE_AFTER_MS) continue;
387
+ if (sessionFileExists(sessionsDir, key)) continue;
388
+ fs.rmSync(file, { force: true });
389
+ fs.rmSync(outboxFile(key), { force: true });
390
+ removed.push(key);
391
+ }
392
+ return removed;
393
+ }
394
+
395
+ function summarizeList(threads: ThreadListItem[]): string {
396
+ if (!threads.length) return "No Delta threads yet.";
397
+ return threads
398
+ .map((thread) => {
399
+ const bits = [`${thread.index ?? "?"}. ${thread.name ?? "(unnamed)"}`, thread.id];
400
+ if (thread.summary) bits.push(clip(String(thread.summary), 160));
401
+ return bits.join(" — ");
402
+ })
403
+ .join("\n");
404
+ }
405
+
406
+ /** Stage a message, including the thread title the first time a user prompt arrives. */
407
+ function stageMessage(key: string, message: FinalMessage, id: string): number {
408
+ const updates = updatesFromMessage(message, id);
409
+ if (!updates.length) return 0;
410
+ const binding = readBinding(key);
411
+ if (message.role === "user" && !binding?.titled) {
412
+ const title = firstLine(textOf(message.content));
413
+ if (title) {
414
+ updates.unshift({ sessionUpdate: "session_info_update", title });
415
+ writeBinding(key, { ...binding, titled: true });
416
+ }
417
+ }
418
+ stageUpdates(key, updates);
419
+ return updates.length;
420
+ }
421
+
422
+ export default function (pi: ExtensionAPI) {
423
+ // One writer at a time: sequence numbers come from `thread status`, so overlapping
424
+ // appends would reuse a sequence and Delta would reject them.
425
+ let queue: Promise<unknown> = Promise.resolve();
426
+ function enqueue<T>(task: () => Promise<T>): Promise<T> {
427
+ const queued = queue.then(task, task);
428
+ queue = queued.catch(() => {});
429
+ return queued;
430
+ }
431
+
432
+ let rec: { key: string; enabled: boolean; warned: boolean } | null = null;
433
+ const sessionOf = (ctx: ExtensionContext) =>
434
+ (rec ??= { key: sessionKey(ctx), enabled: recordingEnabled(sessionKey(ctx)), warned: false });
435
+
436
+ async function record(key: string, message: FinalMessage, id: string): Promise<void> {
437
+ stageMessage(key, message, id);
438
+ await flushPending(key);
439
+ }
440
+
441
+ pi.on("session_start", (_event, ctx) => {
442
+ const key = sessionKey(ctx);
443
+ rec = { key, enabled: recordingEnabled(key), warned: false };
444
+ pruneBindings(key, SESSIONS_DIR);
445
+ enqueue(() => flushPending(key)).catch((error: Error) => {
446
+ if (rec?.warned) return;
447
+ if (rec) rec.warned = true;
448
+ ctx.ui.notify(`Delta unreachable; queued records will retry. ${error.message}`, "warning");
449
+ });
450
+ });
451
+
452
+ pi.on("message_end", (event, ctx) => {
453
+ const current = sessionOf(ctx);
454
+ if (!current.enabled) return;
455
+ const message = event.message as FinalMessage;
456
+ if (message?.role !== "user" && message?.role !== "assistant" && message?.role !== "toolResult") return;
457
+ const id = randomUUID();
458
+ enqueue(async () => {
459
+ try {
460
+ await record(current.key, message, id);
461
+ if (current.warned) {
462
+ current.warned = false;
463
+ ctx.ui.notify("Delta recording resumed", "info");
464
+ }
465
+ } catch (error) {
466
+ if (current.warned) return;
467
+ current.warned = true;
468
+ ctx.ui.notify(`Delta unreachable; this turn is queued and will retry. ${(error as Error).message}`, "warning");
469
+ }
470
+ });
471
+ });
472
+
473
+ pi.on("session_shutdown", async () => {
474
+ // Let the tail of the queue reach Delta before the process exits. What doesn't
475
+ // make it stays in the outbox for the next start.
476
+ await Promise.race([queue, new Promise((resolve) => setTimeout(resolve, 8000))]);
477
+ });
478
+
479
+ pi.registerTool({
480
+ name: "delta_store",
481
+ label: "Delta Store",
482
+ description:
483
+ "Store text in this session's private Delta thread. Creates the thread on first use and appends a recorded message that the Delta app can open and share.",
484
+ parameters: Type.Object({
485
+ text: Type.String({ description: "Text to store" }),
486
+ role: Type.Optional(
487
+ StringEnum(["assistant", "user"] as const, { description: "Message author (default assistant)" }),
488
+ ),
489
+ title: Type.Optional(Type.String({ description: "Set the thread title when given" })),
490
+ }),
491
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
492
+ const key = sessionOf(ctx).key;
493
+ const result = await enqueue(async () => {
494
+ const binding = readBinding(key);
495
+ const updates: Update[] = [];
496
+ if (params.title && !binding?.titled) {
497
+ updates.push({ sessionUpdate: "session_info_update", title: params.title });
498
+ writeBinding(key, { ...binding, titled: true });
499
+ }
500
+ updates.push(textUpdate(params.role, `${key}:${randomUUID()}`, params.text));
501
+ stageUpdates(key, updates);
502
+ const flushed = await flushPending(key);
503
+ return { ...flushed, threadId: flushed.threadId };
504
+ });
505
+ const where = result.url ? ` ${result.url}` : "";
506
+ return {
507
+ content: [
508
+ {
509
+ type: "text",
510
+ text: `Stored ${result.count} record(s) in Delta thread ${result.threadId} (next sequence ${result.nextSequence}).${where}`,
511
+ },
512
+ ],
513
+ details: { ...result },
514
+ };
515
+ },
516
+ });
517
+
518
+ pi.registerTool({
519
+ name: "delta_read",
520
+ label: "Delta Read",
521
+ description:
522
+ "Read Delta data: list threads, search thread text, or show one thread's transcript as text. Actions: list, search (needs query), show (needs thread).",
523
+ parameters: Type.Object({
524
+ action: StringEnum(["list", "search", "show"] as const),
525
+ query: Type.Optional(Type.String({ description: "Keywords for search" })),
526
+ thread: Type.Optional(Type.String({ description: "Thread id or list index for show" })),
527
+ }),
528
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
529
+ if (params.action === "list") {
530
+ const threads = await json<ThreadListItem[]>(["thread", "list", "--json"]);
531
+ return {
532
+ content: [{ type: "text", text: summarizeList(threads) }],
533
+ details: { action: "list", count: threads.length },
534
+ };
535
+ }
536
+ if (params.action === "search") {
537
+ if (!params.query?.trim()) throw new Error("query is required for search");
538
+ const words = params.query.trim().split(/\s+/);
539
+ const threads = await json<ThreadListItem[]>(["thread", "search", "-n", "20", "--json", ...words]);
540
+ return {
541
+ content: [{ type: "text", text: summarizeList(threads) }],
542
+ details: { action: "search", query: params.query, count: threads.length },
543
+ };
544
+ }
545
+ const bound = readBinding(sessionOf(ctx).key);
546
+ const thread = params.thread?.trim() || bound?.threadId;
547
+ if (!thread) throw new Error("thread is required for show (this session has no Delta thread yet)");
548
+ const [shown] = await json<ThreadShow[]>(["thread", "show", thread, "--format", "summarize", "--json"]);
549
+ const body = shown?.transcript ?? shown?.buffer ?? "(empty)";
550
+ return {
551
+ content: [{ type: "text", text: clip(`# ${shown?.name ?? thread}\n\n${body}`) }],
552
+ details: { action: "show", threadId: shown?.id ?? thread },
553
+ };
554
+ },
555
+ });
556
+
557
+ pi.registerTool({
558
+ name: "delta_db",
559
+ label: "Delta DB",
560
+ description:
561
+ "Inspect Delta's local SQLite store (read-only): storage stats, or decode one stored root by id. Actions: stats, root (needs id).",
562
+ parameters: Type.Object({
563
+ action: StringEnum(["stats", "root"] as const),
564
+ id: Type.Optional(Type.String({ description: "Root id from stats, for root" })),
565
+ }),
566
+ async execute(_toolCallId, params, _signal, _onUpdate) {
567
+ if (params.action === "stats") {
568
+ const stats = await json<Stats>(["db", "stats", "--json"]);
569
+ const types = (stats.node_types ?? [])
570
+ .map((item) => `${item.type}: ${item.count} nodes, ${item.compressed_bytes} B`)
571
+ .join("\n");
572
+ const roots = (stats.roots ?? []).map((item) => `${item.id} ${item.type}`).join("\n");
573
+ return {
574
+ content: [
575
+ {
576
+ type: "text",
577
+ text: clip(
578
+ `schema ${stats.schema_version}, ${stats.node_count} nodes, ${stats.node_compressed_bytes} B\n${types}\n\nroots:\n${roots}`,
579
+ ),
580
+ },
581
+ ],
582
+ details: { action: "stats", schemaVersion: stats.schema_version, nodes: stats.node_count },
583
+ };
584
+ }
585
+ if (!params.id) throw new Error("id is required for root");
586
+ const decoded = await json<{ type?: string }>(["db", "root", params.id, "--json"]);
587
+ return {
588
+ content: [{ type: "text", text: clip(JSON.stringify(decoded, null, 2)) }],
589
+ details: { action: "root", id: params.id, type: decoded?.type },
590
+ };
591
+ },
592
+ });
593
+
594
+ pi.registerCommand("delta", {
595
+ description: "Show or toggle this session's Delta recording",
596
+ handler: async (args, ctx) => {
597
+ const current = sessionOf(ctx);
598
+ const arg = args.trim().toLowerCase();
599
+ if (arg === "on" || arg === "off") {
600
+ current.enabled = arg === "on";
601
+ setRecording(current.key, current.enabled);
602
+ ctx.ui.notify(`Delta recording ${arg}`, "info");
603
+ return;
604
+ }
605
+ const binding = readBinding(current.key);
606
+ const queued = pendingUpdates(current.key).length;
607
+ const lines = [
608
+ `Recording: ${current.enabled ? "on" : "off"} (/delta on|off, remembered)`,
609
+ binding?.threadId ? `Thread: ${binding.threadId}` : "Thread: none yet",
610
+ queued ? `Queued: ${queued}` : "Queued: 0",
611
+ ];
612
+ if (binding?.threadId) {
613
+ const status = await statusOf(current.key, binding.threadId);
614
+ if (status.title) lines.push(`Title: ${status.title}`);
615
+ if (status.url) lines.push(status.url);
616
+ }
617
+ ctx.ui.notify(lines.join("\n"), "info");
618
+ },
619
+ });
620
+ }
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "pi-delta-db",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Pi extension that stores and reads Pi session data through the Delta CLI: auto-recorded Delta threads plus read-only inspection of Delta's local store",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "index.ts",
8
+ "exports": {
9
+ ".": "./index.ts"
10
+ },
11
+ "files": [
12
+ "index.ts",
13
+ "README.md",
14
+ "CHANGELOG.md",
15
+ "LICENSE"
16
+ ],
17
+ "keywords": [
18
+ "pi",
19
+ "pi-extension",
20
+ "pi-package",
21
+ "delta",
22
+ "delta-cli",
23
+ "recording",
24
+ "transcript",
25
+ "threads"
26
+ ],
27
+ "engines": {
28
+ "node": ">=22.19.0"
29
+ },
30
+ "pi": {
31
+ "extensions": [
32
+ "./index.ts"
33
+ ]
34
+ },
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-ai": "*",
37
+ "@earendil-works/pi-coding-agent": "*",
38
+ "typebox": "*"
39
+ },
40
+ "devDependencies": {
41
+ "@earendil-works/pi-ai": "^1.4.0",
42
+ "@earendil-works/pi-coding-agent": "^1.4.0",
43
+ "@types/node": "^24.10.1",
44
+ "typebox": "^1.4.3",
45
+ "typescript": "^5.9.3"
46
+ },
47
+ "scripts": {
48
+ "check": "tsc --noEmit",
49
+ "test": "node --test test/*.test.mjs"
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/morizkay/pi-delta-db.git"
54
+ },
55
+ "homepage": "https://github.com/morizkay/pi-delta-db#readme",
56
+ "bugs": {
57
+ "url": "https://github.com/morizkay/pi-delta-db/issues"
58
+ },
59
+ "author": "morizkay"
60
+ }