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.
- package/CHANGELOG.md +18 -1
- package/README.md +30 -7
- package/index.ts +258 -58
- 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.
|
|
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
|
|
16
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
`${
|
|
224
|
+
`${assignSequences(first, updates)}\n`,
|
|
189
225
|
);
|
|
190
|
-
return { count: updates.length, nextSequence:
|
|
226
|
+
return { count: updates.length, nextSequence: first + updates.length, url: status.url };
|
|
191
227
|
}
|
|
192
228
|
|
|
193
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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 =
|
|
494
|
+
const binding = readBinding(key);
|
|
295
495
|
const updates: Update[] = [];
|
|
296
|
-
if (params.title && !binding
|
|
496
|
+
if (params.title && !binding?.titled) {
|
|
297
497
|
updates.push({ sessionUpdate: "session_info_update", title: params.title });
|
|
298
|
-
binding
|
|
299
|
-
writeBinding(key, binding);
|
|
498
|
+
writeBinding(key, { ...binding, titled: true });
|
|
300
499
|
}
|
|
301
|
-
updates.push({
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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.
|
|
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",
|