pi-delta-db 0.1.0 → 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.
Files changed (4) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +30 -7
  3. package/index.ts +258 -58
  4. package/package.json +3 -2
package/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
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
+
10
26
  ## [0.1.0] - 2026-11-01
11
27
 
12
28
  ### Added
@@ -32,5 +48,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
32
48
  - `session_info_update` re-titled the thread on every continuation. The title is now written
33
49
  once and remembered in the session binding.
34
50
 
35
- [Unreleased]: https://github.com/morizkay/pi-delta-db/compare/v0.1.0...HEAD
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
36
53
  [0.1.0]: https://github.com/morizkay/pi-delta-db/releases/tag/v0.1.0
package/README.md CHANGED
@@ -12,17 +12,23 @@ in the Delta app and can be read back with or without Pi.
12
12
 
13
13
  ## Auto-recording
14
14
 
15
- Every finalized user and assistant message is appended to this session's Delta thread
16
- automatically, in order. The thread is created on the first message and titled with the first
17
- prompt; continuing a session (`pi -c`) keeps appending to the same thread.
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.
18
23
 
19
24
  | Control | Effect |
20
25
  | --- | --- |
21
- | `/delta` | Show recording state, thread id, title, share URL |
22
- | `/delta off` / `/delta on` | Toggle recording for this session |
23
- | `PI_DELTA_RECORD=0` | Start Pi with recording off |
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 |
24
29
 
25
- Only text is recorded — thinking blocks and tool calls are skipped.
30
+ Bindings whose Pi session file is gone and that have not been touched for a week are deleted
31
+ on session start.
26
32
 
27
33
  ## Tools
28
34
 
@@ -63,13 +69,30 @@ For local development of this repository, link it into the agent directory inste
63
69
  ln -sfn "$(pwd)" ~/.pi/agent/extensions/pi-delta-db
64
70
  ```
65
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
+
66
84
  ## Development
67
85
 
68
86
  ```bash
69
87
  npm install # host packages are peer dependencies, dev-only here
70
88
  npm run check # tsc --noEmit
89
+ npm test # record shapes, outbox retry, and prune, against a fake delta
71
90
  ```
72
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
+
73
96
  ## Gotchas found against delta 0.23.0
74
97
 
75
98
  - `messageId` must be globally unique: a record whose `messageId` matches an earlier one
package/index.ts CHANGED
@@ -4,6 +4,10 @@
4
4
  * Delta's SQLite store (`delta db ...`) is internal and read-only from outside,
5
5
  * so writes go through Delta's supported path: a private thread bound to this
6
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.
7
11
  */
8
12
 
9
13
  import { spawn } from "node:child_process";
@@ -16,9 +20,23 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
16
20
  import { Type } from "typebox";
17
21
 
18
22
  const SOURCE = "pi";
19
- const STATE_DIR = path.join(os.homedir(), ".pi", "agent", "delta-db");
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");
20
25
  const MAX_CHARS = 12_000;
21
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
+ }
22
40
 
23
41
  interface Org {
24
42
  id: string;
@@ -51,22 +69,23 @@ interface Stats {
51
69
  interface ContentBlock {
52
70
  type?: string;
53
71
  text?: string;
72
+ thinking?: string;
73
+ id?: string;
74
+ name?: string;
75
+ arguments?: unknown;
54
76
  }
55
77
  interface FinalMessage {
56
78
  role?: string;
57
79
  content?: string | ContentBlock[];
80
+ toolCallId?: string;
81
+ toolName?: string;
82
+ isError?: boolean;
58
83
  }
59
- interface Update {
60
- sessionUpdate: string;
61
- messageId?: string;
62
- title?: string;
63
- content?: { type: string; text: string }[];
64
- }
65
-
66
84
  /** A session's Delta thread binding. `titled` keeps a continued session from renaming it. */
67
85
  interface Binding {
68
- threadId: string;
86
+ threadId?: string;
69
87
  titled?: boolean;
88
+ enabled?: boolean;
70
89
  }
71
90
 
72
91
  type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
@@ -130,10 +149,14 @@ function bindingFile(key: string): string {
130
149
  return path.join(STATE_DIR, `${key}.json`);
131
150
  }
132
151
 
152
+ function outboxFile(key: string): string {
153
+ return path.join(STATE_DIR, "outbox", `${key}.jsonl`);
154
+ }
155
+
133
156
  function readBinding(key: string): Binding | undefined {
134
157
  try {
135
158
  const saved = parseJson(fs.readFileSync(bindingFile(key), "utf8")) as Binding | undefined;
136
- return saved?.threadId ? saved : undefined;
159
+ return saved && typeof saved === "object" && !Array.isArray(saved) ? saved : undefined;
137
160
  } catch {
138
161
  // No binding file yet: this session has never written to Delta.
139
162
  return undefined;
@@ -145,9 +168,19 @@ function writeBinding(key: string, binding: Binding): void {
145
168
  fs.writeFileSync(bindingFile(key), JSON.stringify(binding, null, 2));
146
169
  }
147
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
+
148
181
  async function ensureThread(key: string): Promise<Binding> {
149
182
  const bound = readBinding(key);
150
- if (bound) return bound;
183
+ if (bound?.threadId) return bound;
151
184
  const { organizations } = await json<{ organizations?: Org[] }>(["org", "list", "--json"]);
152
185
  const org = organizations?.find((item) => item.current) ?? organizations?.[0];
153
186
  if (!org) throw new Error("No Delta organization. Run `delta auth login`, then retry.");
@@ -164,7 +197,7 @@ async function ensureThread(key: string): Promise<Binding> {
164
197
  org.id,
165
198
  "--json",
166
199
  ]);
167
- const binding: Binding = { threadId: created.thread_id };
200
+ const binding: Binding = { ...bound, threadId: created.thread_id };
168
201
  writeBinding(key, binding);
169
202
  return binding;
170
203
  }
@@ -173,27 +206,27 @@ function statusOf(key: string, threadId: string): Promise<ThreadStatus> {
173
206
  return json<ThreadStatus>(["thread", "status", "--source", SOURCE, "--external-id", key, "--json", threadId]);
174
207
  }
175
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
+
176
216
  /** Append schema-1 records, continuing this producer's sequence. */
177
217
  async function append(key: string, threadId: string, updates: Update[]) {
178
218
  const status = await statusOf(key, threadId);
179
219
  const first = status.producer?.next_sequence;
180
220
  if (first === undefined || !Number.isSafeInteger(first))
181
221
  throw new Error("Delta did not report the next sequence for this thread");
182
- let sequence = first;
183
- const lines = updates
184
- .map((update) => JSON.stringify({ schemaVersion: 1, sequence: sequence++, update }))
185
- .join("\n");
186
222
  await run(
187
223
  ["thread", "append", "--source", SOURCE, "--external-id", key, "--acknowledge", "local", "--json", threadId],
188
- `${lines}\n`,
224
+ `${assignSequences(first, updates)}\n`,
189
225
  );
190
- return { count: updates.length, nextSequence: sequence, url: status.url };
226
+ return { count: updates.length, nextSequence: first + updates.length, url: status.url };
191
227
  }
192
228
 
193
- /** Text of a finalized message; only text blocks are recorded. */
194
- // shortcut: thinking and tool calls are not recorded, add them when the transcript needs them
195
- function messageText(message: FinalMessage): string {
196
- const content = message?.content;
229
+ function textOf(content: string | ContentBlock[] | undefined): string {
197
230
  if (typeof content === "string") return content.trim();
198
231
  if (!Array.isArray(content)) return "";
199
232
  return content
@@ -203,11 +236,162 @@ function messageText(message: FinalMessage): string {
203
236
  .trim();
204
237
  }
205
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
+
206
302
  function firstLine(text: string): string {
207
303
  const line = text.split("\n").find((part) => part.trim()) ?? "";
208
304
  return line.trim().slice(0, 60);
209
305
  }
210
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
+
211
395
  function summarizeList(threads: ThreadListItem[]): string {
212
396
  if (!threads.length) return "No Delta threads yet.";
213
397
  return threads
@@ -219,6 +403,22 @@ function summarizeList(threads: ThreadListItem[]): string {
219
403
  .join("\n");
220
404
  }
221
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
+
222
422
  export default function (pi: ExtensionAPI) {
223
423
  // One writer at a time: sequence numbers come from `thread status`, so overlapping
224
424
  // appends would reuse a sequence and Delta would reject them.
@@ -230,49 +430,49 @@ export default function (pi: ExtensionAPI) {
230
430
  }
231
431
 
232
432
  let rec: { key: string; enabled: boolean; warned: boolean } | null = null;
233
-
234
433
  const sessionOf = (ctx: ExtensionContext) =>
235
- (rec ??= { key: sessionKey(ctx), enabled: process.env.PI_DELTA_RECORD !== "0", warned: false });
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
+ }
236
440
 
237
441
  pi.on("session_start", (_event, ctx) => {
238
- rec = { key: sessionKey(ctx), enabled: process.env.PI_DELTA_RECORD !== "0", warned: false };
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
+ });
239
450
  });
240
451
 
241
- // Auto-record every finalized user and assistant message into this session's thread.
242
452
  pi.on("message_end", (event, ctx) => {
243
453
  const current = sessionOf(ctx);
244
454
  if (!current.enabled) return;
245
455
  const message = event.message as FinalMessage;
246
- if (message?.role !== "user" && message?.role !== "assistant") return;
247
- const text = messageText(message);
248
- if (!text) return;
249
- const wantsTitle = message.role === "user" ? firstLine(text) : undefined;
456
+ if (message?.role !== "user" && message?.role !== "assistant" && message?.role !== "toolResult") return;
457
+ const id = randomUUID();
250
458
  enqueue(async () => {
251
459
  try {
252
- const binding = await ensureThread(current.key);
253
- const updates: Update[] = [];
254
- // A messageId must be globally unique: Delta replaces an existing record with the same id.
255
- if (wantsTitle && !binding.titled) {
256
- updates.push({ sessionUpdate: "session_info_update", title: wantsTitle });
257
- binding.titled = true;
258
- writeBinding(current.key, binding);
460
+ await record(current.key, message, id);
461
+ if (current.warned) {
462
+ current.warned = false;
463
+ ctx.ui.notify("Delta recording resumed", "info");
259
464
  }
260
- updates.push({
261
- sessionUpdate: message.role === "user" ? "user_message" : "agent_message",
262
- messageId: `${current.key}:${randomUUID()}`,
263
- content: [{ type: "text", text }],
264
- });
265
- await append(current.key, binding.threadId, updates);
266
465
  } catch (error) {
267
466
  if (current.warned) return;
268
467
  current.warned = true;
269
- ctx.ui.notify(`Delta recording stopped: ${(error as Error).message}`, "warning");
468
+ ctx.ui.notify(`Delta unreachable; this turn is queued and will retry. ${(error as Error).message}`, "warning");
270
469
  }
271
470
  });
272
471
  });
273
472
 
274
473
  pi.on("session_shutdown", async () => {
275
- // Let the tail of the queue reach Delta before the process exits.
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.
276
476
  await Promise.race([queue, new Promise((resolve) => setTimeout(resolve, 8000))]);
277
477
  });
278
478
 
@@ -291,19 +491,16 @@ export default function (pi: ExtensionAPI) {
291
491
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
292
492
  const key = sessionOf(ctx).key;
293
493
  const result = await enqueue(async () => {
294
- const binding = await ensureThread(key);
494
+ const binding = readBinding(key);
295
495
  const updates: Update[] = [];
296
- if (params.title && !binding.titled) {
496
+ if (params.title && !binding?.titled) {
297
497
  updates.push({ sessionUpdate: "session_info_update", title: params.title });
298
- binding.titled = true;
299
- writeBinding(key, binding);
498
+ writeBinding(key, { ...binding, titled: true });
300
499
  }
301
- updates.push({
302
- sessionUpdate: params.role === "user" ? "user_message" : "agent_message",
303
- messageId: `${key}:${randomUUID()}`,
304
- content: [{ type: "text", text: params.text }],
305
- });
306
- return { ...(await append(key, binding.threadId, updates)), threadId: binding.threadId };
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 };
307
504
  });
308
505
  const where = result.url ? ` ${result.url}` : "";
309
506
  return {
@@ -401,15 +598,18 @@ export default function (pi: ExtensionAPI) {
401
598
  const arg = args.trim().toLowerCase();
402
599
  if (arg === "on" || arg === "off") {
403
600
  current.enabled = arg === "on";
601
+ setRecording(current.key, current.enabled);
404
602
  ctx.ui.notify(`Delta recording ${arg}`, "info");
405
603
  return;
406
604
  }
407
605
  const binding = readBinding(current.key);
606
+ const queued = pendingUpdates(current.key).length;
408
607
  const lines = [
409
- `Recording: ${current.enabled ? "on" : "off"} (/delta on|off)`,
410
- binding ? `Thread: ${binding.threadId}` : "Thread: none yet",
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",
411
611
  ];
412
- if (binding) {
612
+ if (binding?.threadId) {
413
613
  const status = await statusOf(current.key, binding.threadId);
414
614
  if (status.title) lines.push(`Title: ${status.title}`);
415
615
  if (status.url) lines.push(status.url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-delta-db",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,7 +45,8 @@
45
45
  "typescript": "^5.9.3"
46
46
  },
47
47
  "scripts": {
48
- "check": "tsc --noEmit"
48
+ "check": "tsc --noEmit",
49
+ "test": "node --test test/*.test.mjs"
49
50
  },
50
51
  "repository": {
51
52
  "type": "git",