pi-delta-db 0.0.0-stage → 0.1.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 +36 -0
- package/LICENSE +21 -0
- package/README.md +83 -2
- package/index.ts +420 -0
- package/package.json +57 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
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.1.0] - 2026-11-01
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `delta_store` tool: append text to this session's private Delta thread, with optional
|
|
15
|
+
`title` and `role`.
|
|
16
|
+
- `delta_read` tool: list threads, search thread text, or show one transcript.
|
|
17
|
+
- `delta_db` tool: read-only inspection of Delta's local store (`stats`, `root <id>`).
|
|
18
|
+
- Automatic recording: every finalized user and assistant message is appended to the
|
|
19
|
+
session's Delta thread in order, with the thread created on first use and titled from
|
|
20
|
+
the first prompt.
|
|
21
|
+
- `/delta` command: show recording state, thread id, title, and share URL; `/delta on|off`
|
|
22
|
+
toggles recording for the session.
|
|
23
|
+
- `PI_DELTA_RECORD=0` to start Pi with recording disabled.
|
|
24
|
+
- Writes are serialized through one queue and take their sequence from `delta thread status`,
|
|
25
|
+
so concurrent appends cannot reuse a sequence.
|
|
26
|
+
- Recording failures warn once per session and never interrupt a Pi turn.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- A repeated `messageId` made Delta replace earlier records, silently erasing previous turns
|
|
31
|
+
in a continued session. Message ids are now unique per message.
|
|
32
|
+
- `session_info_update` re-titled the thread on every continuation. The title is now written
|
|
33
|
+
once and remembered in the session binding.
|
|
34
|
+
|
|
35
|
+
[Unreleased]: https://github.com/morizkay/pi-delta-db/compare/v0.1.0...HEAD
|
|
36
|
+
[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,84 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-delta-db
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/pi-delta-db)
|
|
4
|
+
[](./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 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.
|
|
18
|
+
|
|
19
|
+
| Control | Effect |
|
|
20
|
+
| --- | --- |
|
|
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 |
|
|
24
|
+
|
|
25
|
+
Only text is recorded — thinking blocks and tool calls are skipped.
|
|
26
|
+
|
|
27
|
+
## Tools
|
|
28
|
+
|
|
29
|
+
| Tool | What it does |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `delta_store` | Append text to this session's thread with an explicit `title`/`role`. |
|
|
32
|
+
| `delta_read` | `list` threads, `search` by keywords, or `show` one transcript. |
|
|
33
|
+
| `delta_db` | Read-only storage inspection: `stats` (schema version, node/root counts) or `root <id>`. |
|
|
34
|
+
|
|
35
|
+
## How it binds
|
|
36
|
+
|
|
37
|
+
One thread per Pi session:
|
|
38
|
+
|
|
39
|
+
- Producer: `--source pi --external-id <pi-session-id>`
|
|
40
|
+
- Created with `--visibility private --organization <current org>`, which is what makes
|
|
41
|
+
`thread append` allowed (Delta rejects appends to unshared threads).
|
|
42
|
+
- The thread id and title state live in `~/.pi/agent/delta-db/<session-id>.json`.
|
|
43
|
+
- Each write asks `delta thread status` for the next sequence, and writes are serialized through
|
|
44
|
+
one queue, so records stay ordered.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# from npm
|
|
50
|
+
pi install npm:pi-delta-db
|
|
51
|
+
|
|
52
|
+
# from git, or a local checkout in development
|
|
53
|
+
pi install git:github.com/morizkay/pi-delta-db
|
|
54
|
+
pi install ./pi-delta-db
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Restart Pi (or run `/reload`). Requirements: Pi `>= 1.4.0`, Node `>= 22.19.0`, the `delta` CLI
|
|
58
|
+
on `PATH`, and a signed-in account (`delta auth login`).
|
|
59
|
+
|
|
60
|
+
For local development of this repository, link it into the agent directory instead:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
ln -sfn "$(pwd)" ~/.pi/agent/extensions/pi-delta-db
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Development
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm install # host packages are peer dependencies, dev-only here
|
|
70
|
+
npm run check # tsc --noEmit
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Gotchas found against delta 0.23.0
|
|
74
|
+
|
|
75
|
+
- `messageId` must be globally unique: a record whose `messageId` matches an earlier one
|
|
76
|
+
**replaces** it, so a repeated id silently erases earlier messages.
|
|
77
|
+
- `session_info_update` re-titles the thread on every append, so the title is written once and
|
|
78
|
+
remembered in the binding file.
|
|
79
|
+
- Appends send records to Delta's cloud (private visibility) — not purely local storage.
|
|
80
|
+
- Records are append-only: nothing can be overwritten or deleted individually.
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
[MIT](./LICENSE) © 2026 morizkay. Changes are documented in the [changelog](./CHANGELOG.md).
|
package/index.ts
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
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
|
+
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
|
|
18
|
+
const SOURCE = "pi";
|
|
19
|
+
const STATE_DIR = path.join(os.homedir(), ".pi", "agent", "delta-db");
|
|
20
|
+
const MAX_CHARS = 12_000;
|
|
21
|
+
const TIMEOUT_MS = 120_000;
|
|
22
|
+
|
|
23
|
+
interface Org {
|
|
24
|
+
id: string;
|
|
25
|
+
current?: boolean;
|
|
26
|
+
}
|
|
27
|
+
interface ThreadListItem {
|
|
28
|
+
index?: number;
|
|
29
|
+
id: string;
|
|
30
|
+
name?: string;
|
|
31
|
+
summary?: string;
|
|
32
|
+
}
|
|
33
|
+
interface ThreadStatus {
|
|
34
|
+
producer?: { next_sequence?: number };
|
|
35
|
+
url?: string;
|
|
36
|
+
title?: string;
|
|
37
|
+
}
|
|
38
|
+
interface ThreadShow {
|
|
39
|
+
id?: string;
|
|
40
|
+
name?: string;
|
|
41
|
+
transcript?: string;
|
|
42
|
+
buffer?: string;
|
|
43
|
+
}
|
|
44
|
+
interface Stats {
|
|
45
|
+
schema_version?: number;
|
|
46
|
+
node_count?: number;
|
|
47
|
+
node_compressed_bytes?: number;
|
|
48
|
+
node_types?: { type: string; count: number; compressed_bytes: number }[];
|
|
49
|
+
roots?: { id: string; type: string }[];
|
|
50
|
+
}
|
|
51
|
+
interface ContentBlock {
|
|
52
|
+
type?: string;
|
|
53
|
+
text?: string;
|
|
54
|
+
}
|
|
55
|
+
interface FinalMessage {
|
|
56
|
+
role?: string;
|
|
57
|
+
content?: string | ContentBlock[];
|
|
58
|
+
}
|
|
59
|
+
interface Update {
|
|
60
|
+
sessionUpdate: string;
|
|
61
|
+
messageId?: string;
|
|
62
|
+
title?: string;
|
|
63
|
+
content?: { type: string; text: string }[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A session's Delta thread binding. `titled` keeps a continued session from renaming it. */
|
|
67
|
+
interface Binding {
|
|
68
|
+
threadId: string;
|
|
69
|
+
titled?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
73
|
+
|
|
74
|
+
/** Parse CLI output at its boundary; unparseable text becomes undefined. */
|
|
75
|
+
function parseJson(text: string): JsonValue | undefined {
|
|
76
|
+
if (!text.trim()) return undefined;
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(text) as JsonValue;
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Run `delta`, returning stdout, and turn a failed exit into an Error. */
|
|
85
|
+
function run(args: string[], input?: string): Promise<string> {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const child = spawn("delta", args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
88
|
+
let out = "";
|
|
89
|
+
let err = "";
|
|
90
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
|
|
91
|
+
child.stdout.on("data", (chunk) => (out += chunk));
|
|
92
|
+
child.stderr.on("data", (chunk) => (err += chunk));
|
|
93
|
+
child.on("error", (error) => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
reject(new Error(error.message.includes("ENOENT") ? "`delta` was not found on PATH" : error.message));
|
|
96
|
+
});
|
|
97
|
+
child.on("close", (code) => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
if (code === 0) {
|
|
100
|
+
resolve(out);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// With --json the CLI prints {error:{message}} on stdout; prefer that over stderr noise.
|
|
104
|
+
const reported = (parseJson(out) as { error?: { message?: string } } | undefined)?.error?.message;
|
|
105
|
+
reject(new Error(reported ?? err.trim() ?? `delta ${args.slice(0, 2).join(" ")} exited ${code}`));
|
|
106
|
+
});
|
|
107
|
+
child.stdin.end(input);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function json<T>(args: string[], input?: string): Promise<T> {
|
|
112
|
+
const out = await run(args, input);
|
|
113
|
+
const value = parseJson(out);
|
|
114
|
+
if (value === undefined) throw new Error(`delta ${args.slice(0, 2).join(" ")} returned no JSON`);
|
|
115
|
+
return value as T;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function clip(text: string, limit = MAX_CHARS): string {
|
|
119
|
+
return text.length > limit ? `${text.slice(0, limit)}\n…truncated (${text.length} chars)` : text;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A stable producer key for this Pi session; also the on-disk binding name. */
|
|
123
|
+
function sessionKey(ctx: ExtensionContext): string {
|
|
124
|
+
const raw = ctx.sessionManager.getSessionId() || ctx.sessionManager.getSessionFile() || `cwd-${ctx.cwd}`;
|
|
125
|
+
const tail = String(raw).split(/[\\/]/).pop() ?? "session";
|
|
126
|
+
return tail.replace(/[^A-Za-z0-9_-]/g, "-");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function bindingFile(key: string): string {
|
|
130
|
+
return path.join(STATE_DIR, `${key}.json`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function readBinding(key: string): Binding | undefined {
|
|
134
|
+
try {
|
|
135
|
+
const saved = parseJson(fs.readFileSync(bindingFile(key), "utf8")) as Binding | undefined;
|
|
136
|
+
return saved?.threadId ? saved : undefined;
|
|
137
|
+
} catch {
|
|
138
|
+
// No binding file yet: this session has never written to Delta.
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function writeBinding(key: string, binding: Binding): void {
|
|
144
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
145
|
+
fs.writeFileSync(bindingFile(key), JSON.stringify(binding, null, 2));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function ensureThread(key: string): Promise<Binding> {
|
|
149
|
+
const bound = readBinding(key);
|
|
150
|
+
if (bound) return bound;
|
|
151
|
+
const { organizations } = await json<{ organizations?: Org[] }>(["org", "list", "--json"]);
|
|
152
|
+
const org = organizations?.find((item) => item.current) ?? organizations?.[0];
|
|
153
|
+
if (!org) throw new Error("No Delta organization. Run `delta auth login`, then retry.");
|
|
154
|
+
const created = await json<{ thread_id: string }>([
|
|
155
|
+
"thread",
|
|
156
|
+
"create",
|
|
157
|
+
"--source",
|
|
158
|
+
SOURCE,
|
|
159
|
+
"--external-id",
|
|
160
|
+
key,
|
|
161
|
+
"--visibility",
|
|
162
|
+
"private",
|
|
163
|
+
"--organization",
|
|
164
|
+
org.id,
|
|
165
|
+
"--json",
|
|
166
|
+
]);
|
|
167
|
+
const binding: Binding = { threadId: created.thread_id };
|
|
168
|
+
writeBinding(key, binding);
|
|
169
|
+
return binding;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function statusOf(key: string, threadId: string): Promise<ThreadStatus> {
|
|
173
|
+
return json<ThreadStatus>(["thread", "status", "--source", SOURCE, "--external-id", key, "--json", threadId]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Append schema-1 records, continuing this producer's sequence. */
|
|
177
|
+
async function append(key: string, threadId: string, updates: Update[]) {
|
|
178
|
+
const status = await statusOf(key, threadId);
|
|
179
|
+
const first = status.producer?.next_sequence;
|
|
180
|
+
if (first === undefined || !Number.isSafeInteger(first))
|
|
181
|
+
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
|
+
await run(
|
|
187
|
+
["thread", "append", "--source", SOURCE, "--external-id", key, "--acknowledge", "local", "--json", threadId],
|
|
188
|
+
`${lines}\n`,
|
|
189
|
+
);
|
|
190
|
+
return { count: updates.length, nextSequence: sequence, url: status.url };
|
|
191
|
+
}
|
|
192
|
+
|
|
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;
|
|
197
|
+
if (typeof content === "string") return content.trim();
|
|
198
|
+
if (!Array.isArray(content)) return "";
|
|
199
|
+
return content
|
|
200
|
+
.filter((block) => block?.type === "text" && typeof block.text === "string")
|
|
201
|
+
.map((block) => String(block.text))
|
|
202
|
+
.join("\n")
|
|
203
|
+
.trim();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function firstLine(text: string): string {
|
|
207
|
+
const line = text.split("\n").find((part) => part.trim()) ?? "";
|
|
208
|
+
return line.trim().slice(0, 60);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function summarizeList(threads: ThreadListItem[]): string {
|
|
212
|
+
if (!threads.length) return "No Delta threads yet.";
|
|
213
|
+
return threads
|
|
214
|
+
.map((thread) => {
|
|
215
|
+
const bits = [`${thread.index ?? "?"}. ${thread.name ?? "(unnamed)"}`, thread.id];
|
|
216
|
+
if (thread.summary) bits.push(clip(String(thread.summary), 160));
|
|
217
|
+
return bits.join(" — ");
|
|
218
|
+
})
|
|
219
|
+
.join("\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export default function (pi: ExtensionAPI) {
|
|
223
|
+
// One writer at a time: sequence numbers come from `thread status`, so overlapping
|
|
224
|
+
// appends would reuse a sequence and Delta would reject them.
|
|
225
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
226
|
+
function enqueue<T>(task: () => Promise<T>): Promise<T> {
|
|
227
|
+
const queued = queue.then(task, task);
|
|
228
|
+
queue = queued.catch(() => {});
|
|
229
|
+
return queued;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let rec: { key: string; enabled: boolean; warned: boolean } | null = null;
|
|
233
|
+
|
|
234
|
+
const sessionOf = (ctx: ExtensionContext) =>
|
|
235
|
+
(rec ??= { key: sessionKey(ctx), enabled: process.env.PI_DELTA_RECORD !== "0", warned: false });
|
|
236
|
+
|
|
237
|
+
pi.on("session_start", (_event, ctx) => {
|
|
238
|
+
rec = { key: sessionKey(ctx), enabled: process.env.PI_DELTA_RECORD !== "0", warned: false };
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Auto-record every finalized user and assistant message into this session's thread.
|
|
242
|
+
pi.on("message_end", (event, ctx) => {
|
|
243
|
+
const current = sessionOf(ctx);
|
|
244
|
+
if (!current.enabled) return;
|
|
245
|
+
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;
|
|
250
|
+
enqueue(async () => {
|
|
251
|
+
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);
|
|
259
|
+
}
|
|
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
|
+
} catch (error) {
|
|
267
|
+
if (current.warned) return;
|
|
268
|
+
current.warned = true;
|
|
269
|
+
ctx.ui.notify(`Delta recording stopped: ${(error as Error).message}`, "warning");
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
pi.on("session_shutdown", async () => {
|
|
275
|
+
// Let the tail of the queue reach Delta before the process exits.
|
|
276
|
+
await Promise.race([queue, new Promise((resolve) => setTimeout(resolve, 8000))]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
pi.registerTool({
|
|
280
|
+
name: "delta_store",
|
|
281
|
+
label: "Delta Store",
|
|
282
|
+
description:
|
|
283
|
+
"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.",
|
|
284
|
+
parameters: Type.Object({
|
|
285
|
+
text: Type.String({ description: "Text to store" }),
|
|
286
|
+
role: Type.Optional(
|
|
287
|
+
StringEnum(["assistant", "user"] as const, { description: "Message author (default assistant)" }),
|
|
288
|
+
),
|
|
289
|
+
title: Type.Optional(Type.String({ description: "Set the thread title when given" })),
|
|
290
|
+
}),
|
|
291
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
292
|
+
const key = sessionOf(ctx).key;
|
|
293
|
+
const result = await enqueue(async () => {
|
|
294
|
+
const binding = await ensureThread(key);
|
|
295
|
+
const updates: Update[] = [];
|
|
296
|
+
if (params.title && !binding.titled) {
|
|
297
|
+
updates.push({ sessionUpdate: "session_info_update", title: params.title });
|
|
298
|
+
binding.titled = true;
|
|
299
|
+
writeBinding(key, binding);
|
|
300
|
+
}
|
|
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 };
|
|
307
|
+
});
|
|
308
|
+
const where = result.url ? ` ${result.url}` : "";
|
|
309
|
+
return {
|
|
310
|
+
content: [
|
|
311
|
+
{
|
|
312
|
+
type: "text",
|
|
313
|
+
text: `Stored ${result.count} record(s) in Delta thread ${result.threadId} (next sequence ${result.nextSequence}).${where}`,
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
details: { ...result },
|
|
317
|
+
};
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
pi.registerTool({
|
|
322
|
+
name: "delta_read",
|
|
323
|
+
label: "Delta Read",
|
|
324
|
+
description:
|
|
325
|
+
"Read Delta data: list threads, search thread text, or show one thread's transcript as text. Actions: list, search (needs query), show (needs thread).",
|
|
326
|
+
parameters: Type.Object({
|
|
327
|
+
action: StringEnum(["list", "search", "show"] as const),
|
|
328
|
+
query: Type.Optional(Type.String({ description: "Keywords for search" })),
|
|
329
|
+
thread: Type.Optional(Type.String({ description: "Thread id or list index for show" })),
|
|
330
|
+
}),
|
|
331
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
332
|
+
if (params.action === "list") {
|
|
333
|
+
const threads = await json<ThreadListItem[]>(["thread", "list", "--json"]);
|
|
334
|
+
return {
|
|
335
|
+
content: [{ type: "text", text: summarizeList(threads) }],
|
|
336
|
+
details: { action: "list", count: threads.length },
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (params.action === "search") {
|
|
340
|
+
if (!params.query?.trim()) throw new Error("query is required for search");
|
|
341
|
+
const words = params.query.trim().split(/\s+/);
|
|
342
|
+
const threads = await json<ThreadListItem[]>(["thread", "search", "-n", "20", "--json", ...words]);
|
|
343
|
+
return {
|
|
344
|
+
content: [{ type: "text", text: summarizeList(threads) }],
|
|
345
|
+
details: { action: "search", query: params.query, count: threads.length },
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
const bound = readBinding(sessionOf(ctx).key);
|
|
349
|
+
const thread = params.thread?.trim() || bound?.threadId;
|
|
350
|
+
if (!thread) throw new Error("thread is required for show (this session has no Delta thread yet)");
|
|
351
|
+
const [shown] = await json<ThreadShow[]>(["thread", "show", thread, "--format", "summarize", "--json"]);
|
|
352
|
+
const body = shown?.transcript ?? shown?.buffer ?? "(empty)";
|
|
353
|
+
return {
|
|
354
|
+
content: [{ type: "text", text: clip(`# ${shown?.name ?? thread}\n\n${body}`) }],
|
|
355
|
+
details: { action: "show", threadId: shown?.id ?? thread },
|
|
356
|
+
};
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
pi.registerTool({
|
|
361
|
+
name: "delta_db",
|
|
362
|
+
label: "Delta DB",
|
|
363
|
+
description:
|
|
364
|
+
"Inspect Delta's local SQLite store (read-only): storage stats, or decode one stored root by id. Actions: stats, root (needs id).",
|
|
365
|
+
parameters: Type.Object({
|
|
366
|
+
action: StringEnum(["stats", "root"] as const),
|
|
367
|
+
id: Type.Optional(Type.String({ description: "Root id from stats, for root" })),
|
|
368
|
+
}),
|
|
369
|
+
async execute(_toolCallId, params, _signal, _onUpdate) {
|
|
370
|
+
if (params.action === "stats") {
|
|
371
|
+
const stats = await json<Stats>(["db", "stats", "--json"]);
|
|
372
|
+
const types = (stats.node_types ?? [])
|
|
373
|
+
.map((item) => `${item.type}: ${item.count} nodes, ${item.compressed_bytes} B`)
|
|
374
|
+
.join("\n");
|
|
375
|
+
const roots = (stats.roots ?? []).map((item) => `${item.id} ${item.type}`).join("\n");
|
|
376
|
+
return {
|
|
377
|
+
content: [
|
|
378
|
+
{
|
|
379
|
+
type: "text",
|
|
380
|
+
text: clip(
|
|
381
|
+
`schema ${stats.schema_version}, ${stats.node_count} nodes, ${stats.node_compressed_bytes} B\n${types}\n\nroots:\n${roots}`,
|
|
382
|
+
),
|
|
383
|
+
},
|
|
384
|
+
],
|
|
385
|
+
details: { action: "stats", schemaVersion: stats.schema_version, nodes: stats.node_count },
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
if (!params.id) throw new Error("id is required for root");
|
|
389
|
+
const decoded = await json<{ type?: string }>(["db", "root", params.id, "--json"]);
|
|
390
|
+
return {
|
|
391
|
+
content: [{ type: "text", text: clip(JSON.stringify(decoded, null, 2)) }],
|
|
392
|
+
details: { action: "root", id: params.id, type: decoded?.type },
|
|
393
|
+
};
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
pi.registerCommand("delta", {
|
|
398
|
+
description: "Show or toggle this session's Delta recording",
|
|
399
|
+
handler: async (args, ctx) => {
|
|
400
|
+
const current = sessionOf(ctx);
|
|
401
|
+
const arg = args.trim().toLowerCase();
|
|
402
|
+
if (arg === "on" || arg === "off") {
|
|
403
|
+
current.enabled = arg === "on";
|
|
404
|
+
ctx.ui.notify(`Delta recording ${arg}`, "info");
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const binding = readBinding(current.key);
|
|
408
|
+
const lines = [
|
|
409
|
+
`Recording: ${current.enabled ? "on" : "off"} (/delta on|off)`,
|
|
410
|
+
binding ? `Thread: ${binding.threadId}` : "Thread: none yet",
|
|
411
|
+
];
|
|
412
|
+
if (binding) {
|
|
413
|
+
const status = await statusOf(current.key, binding.threadId);
|
|
414
|
+
if (status.title) lines.push(`Title: ${status.title}`);
|
|
415
|
+
if (status.url) lines.push(status.url);
|
|
416
|
+
}
|
|
417
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-delta-db",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.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
|
+
},
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "git+https://github.com/morizkay/pi-delta-db.git"
|
|
53
|
+
},
|
|
54
|
+
"homepage": "https://github.com/morizkay/pi-delta-db#readme",
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/morizkay/pi-delta-db/issues"
|
|
57
|
+
},
|
|
58
|
+
"author": "morizkay"
|
|
59
|
+
}
|