@springbrand/agent-runtime 0.2.0-alpha.67 → 0.2.0-alpha.69
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/package.json +3 -3
- package/src/adapter/cloudflare/subagent/runner.ts +23 -22
- package/src/adapter/cloudflare/subagent/tools.ts +1 -1
- package/src/adapter/cloudflare/universal-agent/preparation.ts +0 -7
- package/src/db/schema.ts +0 -12
- package/src/db/submission-admission.repo.ts +5 -20
- package/src/db/submission.repo.ts +5 -21
- package/src/kernel/bindings.ts +8 -7
- package/src/kernel/durable-lifecycle.ts +0 -9
- package/src/kernel/profile.ts +3 -0
- package/src/kernel/public-contracts.ts +0 -1
- package/src/kernel/subagent-runtime.ts +2 -12
- package/src/kernel/submission-authority.ts +4 -24
- package/src/model-usage.ts +52 -0
- package/src/pi/assembly/context.ts +26 -60
- package/src/pi/runtime-adapter/assembly.ts +6 -2
- package/src/pi/runtime-adapter/execution.ts +78 -114
- package/src/pi/runtime-adapter/index.ts +3 -4
- package/src/pi/runtime-adapter/transcript.ts +31 -46
- package/src/pi/session/index.ts +22 -22
- package/src/pi/session/migration.ts +646 -0
- package/src/pi/session/storage.ts +523 -311
- package/src/pi/tool/compiler.ts +2 -2
- package/src/pi/tool/core-host.ts +29 -7
- package/src/pi/tool/mcp.ts +23 -15
- package/src/pi/tool/subagent.ts +0 -9
- package/src/pi/tool/workspace-state-connector.ts +23 -20
- package/src/runtime-agent.ts +11 -0
- package/src/runtime-assembler.ts +13 -2
- package/src/runtime-definition.ts +2 -0
- package/src/runtime.ts +24 -83
|
@@ -1,353 +1,565 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
1
2
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
prepareStorageCommit,
|
|
4
|
+
validateCommittedWrites,
|
|
5
|
+
resolveListReadOptions,
|
|
6
|
+
value,
|
|
7
|
+
branchTip,
|
|
8
|
+
setValue,
|
|
9
|
+
insertEntry,
|
|
10
|
+
insertUsage,
|
|
5
11
|
type CompactionEntry,
|
|
12
|
+
type Storage,
|
|
13
|
+
type Write,
|
|
14
|
+
type CommitResult,
|
|
15
|
+
type Entry,
|
|
16
|
+
type EntryStructure,
|
|
17
|
+
type EntryScan,
|
|
18
|
+
type StorageBranchScan,
|
|
19
|
+
type UsageRow,
|
|
20
|
+
type UsageScan,
|
|
21
|
+
type Value,
|
|
22
|
+
type ValueList,
|
|
23
|
+
type StoredValue,
|
|
24
|
+
type ListElement,
|
|
25
|
+
type ListReadOptions,
|
|
26
|
+
type SessionStats,
|
|
6
27
|
type SessionMetadata,
|
|
7
|
-
|
|
8
|
-
} from "@earendil-works/pi-
|
|
9
|
-
import type {
|
|
28
|
+
} from "@earendil-works/pi-agent-core/harness/session";
|
|
29
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
30
|
+
import type { Context } from "@earendil-works/pi-agent-core/harness/context";
|
|
10
31
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
32
|
+
/** Pi's Storage contract, with every physical commit confined to one DO transaction. */
|
|
33
|
+
export class CloudflareSessionStorage implements Storage {
|
|
34
|
+
private closed = false;
|
|
35
|
+
private readonly sql: SqlStorage;
|
|
36
|
+
readonly metadata: SessionMetadata;
|
|
15
37
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
38
|
+
constructor(private readonly storage: DurableObjectStorage) {
|
|
39
|
+
this.sql = storage.sql;
|
|
40
|
+
const hasVersion =
|
|
41
|
+
this.sql
|
|
42
|
+
.exec(
|
|
43
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'pi_storage_state'",
|
|
44
|
+
)
|
|
45
|
+
.toArray().length > 0;
|
|
46
|
+
const hasLegacy =
|
|
47
|
+
this.sql
|
|
48
|
+
.exec(
|
|
49
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'pi_session_entries'",
|
|
50
|
+
)
|
|
51
|
+
.toArray().length > 0;
|
|
52
|
+
if (!hasVersion && hasLegacy) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
"Pi session requires an explicit legacy-to-0.85 migration",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (hasVersion) {
|
|
58
|
+
const version = this.sql
|
|
59
|
+
.exec<{
|
|
60
|
+
storage_version: number;
|
|
61
|
+
}>("SELECT storage_version FROM pi_storage_state WHERE singleton = 1")
|
|
62
|
+
.one().storage_version;
|
|
63
|
+
if (version !== 1)
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Unsupported Cloudflare Pi storage version: ${version}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
this.sql.exec(`
|
|
69
|
+
CREATE TABLE IF NOT EXISTS pi_session_message_submission (message_id TEXT PRIMARY KEY, submission_id TEXT NOT NULL);
|
|
70
|
+
CREATE TABLE IF NOT EXISTS pi_entries (
|
|
71
|
+
id TEXT PRIMARY KEY, seq INTEGER NOT NULL UNIQUE, parent_id TEXT,
|
|
72
|
+
type TEXT NOT NULL, custom_type TEXT, timestamp INTEGER NOT NULL, body TEXT NOT NULL
|
|
73
|
+
);
|
|
74
|
+
CREATE TABLE IF NOT EXISTS pi_values (
|
|
75
|
+
namespace TEXT NOT NULL, key TEXT NOT NULL, seq INTEGER NOT NULL, body TEXT NOT NULL,
|
|
76
|
+
PRIMARY KEY(namespace, key)
|
|
77
|
+
);
|
|
78
|
+
CREATE TABLE IF NOT EXISTS pi_lists (
|
|
79
|
+
namespace TEXT NOT NULL, key TEXT NOT NULL, seq INTEGER PRIMARY KEY, body TEXT NOT NULL
|
|
80
|
+
);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS pi_lists_address ON pi_lists(namespace, key, seq);
|
|
82
|
+
CREATE TABLE IF NOT EXISTS pi_usage (id TEXT PRIMARY KEY, seq INTEGER NOT NULL UNIQUE, body TEXT NOT NULL);
|
|
83
|
+
CREATE TABLE IF NOT EXISTS pi_storage_state (singleton INTEGER PRIMARY KEY CHECK(singleton = 1), next_seq INTEGER NOT NULL, storage_version INTEGER NOT NULL, session_id TEXT NOT NULL, created_at INTEGER NOT NULL, stats TEXT NOT NULL);
|
|
84
|
+
`);
|
|
85
|
+
this.sql.exec(
|
|
86
|
+
"INSERT OR IGNORE INTO pi_storage_state VALUES (1, 1, 1, ?, ?, ?)",
|
|
87
|
+
crypto.randomUUID(),
|
|
88
|
+
Date.now(),
|
|
89
|
+
JSON.stringify({
|
|
90
|
+
messageCount: 0,
|
|
91
|
+
usage: {
|
|
92
|
+
input: 0,
|
|
93
|
+
output: 0,
|
|
94
|
+
cacheRead: 0,
|
|
95
|
+
cacheWrite: 0,
|
|
96
|
+
totalTokens: 0,
|
|
97
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
34
100
|
);
|
|
101
|
+
const row = this.sql
|
|
102
|
+
.exec<{
|
|
103
|
+
storage_version: number;
|
|
104
|
+
session_id: string;
|
|
105
|
+
created_at: number;
|
|
106
|
+
}>(
|
|
107
|
+
"SELECT storage_version, session_id, created_at FROM pi_storage_state WHERE singleton = 1",
|
|
108
|
+
)
|
|
109
|
+
.one();
|
|
110
|
+
this.metadata = {
|
|
111
|
+
id: row.session_id,
|
|
112
|
+
createdAt: row.created_at,
|
|
113
|
+
storageVersion: row.storage_version,
|
|
114
|
+
};
|
|
35
115
|
}
|
|
36
|
-
}
|
|
37
116
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
117
|
+
private assertOpen(context?: Context): void {
|
|
118
|
+
if (this.closed) throw new Error("Session storage is closed");
|
|
119
|
+
context?.abortSignal?.throwIfAborted();
|
|
120
|
+
}
|
|
43
121
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
122
|
+
/** Synchronous boundary also used by the host's admission/settlement transaction. */
|
|
123
|
+
commitSync(writes: Write[], context?: Context): CommitResult {
|
|
124
|
+
this.assertOpen(context);
|
|
125
|
+
return this.storage.transactionSync(() => {
|
|
126
|
+
const firstSeq = this.sql
|
|
127
|
+
.exec<{
|
|
128
|
+
next_seq: number;
|
|
129
|
+
}>("SELECT next_seq FROM pi_storage_state WHERE singleton = 1")
|
|
130
|
+
.one().next_seq;
|
|
131
|
+
const prepared = prepareStorageCommit(writes, firstSeq, Date.now());
|
|
132
|
+
validateCommittedWrites(prepared.writes, firstSeq, {
|
|
133
|
+
hasEntryId: (id) =>
|
|
134
|
+
this.sql.exec("SELECT 1 FROM pi_entries WHERE id = ?", id).toArray()
|
|
135
|
+
.length > 0,
|
|
136
|
+
hasEntryOrUsageId: (id) =>
|
|
137
|
+
this.sql
|
|
138
|
+
.exec(
|
|
139
|
+
"SELECT 1 FROM pi_entries WHERE id = ? UNION ALL SELECT 1 FROM pi_usage WHERE id = ?",
|
|
140
|
+
id,
|
|
141
|
+
id,
|
|
142
|
+
)
|
|
143
|
+
.toArray().length > 0,
|
|
144
|
+
});
|
|
145
|
+
const stats = this.statsSync();
|
|
146
|
+
for (const write of prepared.writes) {
|
|
147
|
+
switch (write.kind) {
|
|
148
|
+
case "entry": {
|
|
149
|
+
const { kind: _kind, ...entry } = write;
|
|
150
|
+
if (entry.type === "message") stats.messageCount += 1;
|
|
151
|
+
this.sql.exec(
|
|
152
|
+
"INSERT INTO pi_entries VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
153
|
+
entry.id,
|
|
154
|
+
entry.seq,
|
|
155
|
+
entry.parentId,
|
|
156
|
+
entry.type,
|
|
157
|
+
entry.customType ?? null,
|
|
158
|
+
entry.timestamp,
|
|
159
|
+
JSON.stringify(entry),
|
|
160
|
+
);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
case "usage": {
|
|
164
|
+
const { kind: _kind, ...row } = write;
|
|
165
|
+
for (const key of [
|
|
166
|
+
"input",
|
|
167
|
+
"output",
|
|
168
|
+
"cacheRead",
|
|
169
|
+
"cacheWrite",
|
|
170
|
+
"totalTokens",
|
|
171
|
+
"cacheWrite1h",
|
|
172
|
+
"reasoning",
|
|
173
|
+
] as const) {
|
|
174
|
+
if (row.usage[key] !== undefined)
|
|
175
|
+
stats.usage[key] = (stats.usage[key] ?? 0) + row.usage[key];
|
|
176
|
+
}
|
|
177
|
+
for (const key of [
|
|
178
|
+
"input",
|
|
179
|
+
"output",
|
|
180
|
+
"cacheRead",
|
|
181
|
+
"cacheWrite",
|
|
182
|
+
"total",
|
|
183
|
+
] as const)
|
|
184
|
+
stats.usage.cost[key] += row.usage.cost[key];
|
|
185
|
+
this.sql.exec(
|
|
186
|
+
"INSERT INTO pi_usage VALUES (?, ?, ?)",
|
|
187
|
+
row.id,
|
|
188
|
+
row.seq,
|
|
189
|
+
JSON.stringify(row),
|
|
190
|
+
);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case "value":
|
|
194
|
+
if (write.op === "delete")
|
|
195
|
+
this.sql.exec(
|
|
196
|
+
"DELETE FROM pi_values WHERE namespace = ? AND key = ?",
|
|
197
|
+
write.namespace,
|
|
198
|
+
write.key,
|
|
199
|
+
);
|
|
200
|
+
else
|
|
201
|
+
this.sql.exec(
|
|
202
|
+
"INSERT INTO pi_values VALUES (?, ?, ?, ?) ON CONFLICT(namespace, key) DO UPDATE SET seq = excluded.seq, body = excluded.body",
|
|
203
|
+
write.namespace,
|
|
204
|
+
write.key,
|
|
205
|
+
write.seq,
|
|
206
|
+
JSON.stringify(write.value),
|
|
207
|
+
);
|
|
208
|
+
break;
|
|
209
|
+
case "list":
|
|
210
|
+
if (write.op === "delete")
|
|
211
|
+
this.sql.exec(
|
|
212
|
+
"DELETE FROM pi_lists WHERE namespace = ? AND key = ?",
|
|
213
|
+
write.namespace,
|
|
214
|
+
write.key,
|
|
215
|
+
);
|
|
216
|
+
else
|
|
217
|
+
this.sql.exec(
|
|
218
|
+
"INSERT INTO pi_lists VALUES (?, ?, ?, ?)",
|
|
219
|
+
write.namespace,
|
|
220
|
+
write.key,
|
|
221
|
+
write.seq,
|
|
222
|
+
JSON.stringify(write.value),
|
|
223
|
+
);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
this.sql.exec(
|
|
228
|
+
"UPDATE pi_storage_state SET next_seq = ?, stats = ? WHERE singleton = 1",
|
|
229
|
+
firstSeq + writes.length,
|
|
230
|
+
JSON.stringify(stats),
|
|
231
|
+
);
|
|
232
|
+
return { ...prepared.result, stats };
|
|
233
|
+
});
|
|
234
|
+
}
|
|
55
235
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
FROM pi_session_entries
|
|
60
|
-
ORDER BY seq DESC
|
|
61
|
-
LIMIT 1
|
|
62
|
-
`[0];
|
|
63
|
-
if (!row) return null;
|
|
236
|
+
async commit(writes: Write[], context: Context): Promise<CommitResult> {
|
|
237
|
+
return this.commitSync(writes, context);
|
|
238
|
+
}
|
|
64
239
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
);
|
|
240
|
+
async getEntries(
|
|
241
|
+
ids: string[],
|
|
242
|
+
context: Context,
|
|
243
|
+
): Promise<Map<string, Entry>> {
|
|
244
|
+
return this.getEntriesSync(ids, context);
|
|
71
245
|
}
|
|
72
|
-
return leafId;
|
|
73
|
-
}
|
|
74
246
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
247
|
+
getEntriesSync(ids: string[], context?: Context): Map<string, Entry> {
|
|
248
|
+
this.assertOpen(context);
|
|
249
|
+
const entries = new Map<string, Entry>();
|
|
250
|
+
for (const id of ids) {
|
|
251
|
+
const row = this.sql
|
|
252
|
+
.exec<{ body: string }>("SELECT body FROM pi_entries WHERE id = ?", id)
|
|
253
|
+
.toArray()[0];
|
|
254
|
+
if (row) entries.set(id, JSON.parse(row.body));
|
|
255
|
+
}
|
|
256
|
+
return entries;
|
|
257
|
+
}
|
|
85
258
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
${entry.parentId},
|
|
93
|
-
${sessionEntryTargetId(entry)},
|
|
94
|
-
${JSON.stringify(entry)}
|
|
95
|
-
)
|
|
96
|
-
`;
|
|
97
|
-
return true;
|
|
98
|
-
}
|
|
259
|
+
async getValue<T>(
|
|
260
|
+
address: Value<T>,
|
|
261
|
+
context: Context,
|
|
262
|
+
): Promise<StoredValue<T> | undefined> {
|
|
263
|
+
return this.getValueSync(address, context);
|
|
264
|
+
}
|
|
99
265
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
): boolean {
|
|
114
|
-
const timestamp = new Date(
|
|
115
|
-
input.createdAt ??
|
|
116
|
-
("timestamp" in input.message &&
|
|
117
|
-
typeof input.message.timestamp === "number"
|
|
118
|
-
? input.message.timestamp
|
|
119
|
-
: Date.now()),
|
|
120
|
-
).toISOString();
|
|
121
|
-
const inserted = appendEntrySync(sql, {
|
|
122
|
-
type: "message",
|
|
123
|
-
id: input.id,
|
|
124
|
-
parentId: getLeafIdSync(sql),
|
|
125
|
-
timestamp,
|
|
126
|
-
message: input.message,
|
|
127
|
-
});
|
|
128
|
-
if (inserted && input.submissionId) {
|
|
129
|
-
sql`
|
|
130
|
-
INSERT INTO pi_session_message_submission (
|
|
131
|
-
message_id, submission_id
|
|
132
|
-
) VALUES (
|
|
133
|
-
${input.id}, ${input.submissionId}
|
|
266
|
+
getValueSync<T>(
|
|
267
|
+
address: Value<T>,
|
|
268
|
+
context?: Context,
|
|
269
|
+
): StoredValue<T> | undefined {
|
|
270
|
+
this.assertOpen(context);
|
|
271
|
+
const row = this.sql
|
|
272
|
+
.exec<{
|
|
273
|
+
seq: number;
|
|
274
|
+
body: string;
|
|
275
|
+
}>(
|
|
276
|
+
"SELECT seq, body FROM pi_values WHERE namespace = ? AND key = ?",
|
|
277
|
+
address.namespace,
|
|
278
|
+
address.key,
|
|
134
279
|
)
|
|
135
|
-
|
|
280
|
+
.toArray()[0];
|
|
281
|
+
return row
|
|
282
|
+
? { address, seq: row.seq, value: JSON.parse(row.body) }
|
|
283
|
+
: undefined;
|
|
136
284
|
}
|
|
137
|
-
return inserted;
|
|
138
|
-
}
|
|
139
285
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
):
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
);
|
|
286
|
+
async scanValues<T>(
|
|
287
|
+
prefix: Value<T>,
|
|
288
|
+
context: Context,
|
|
289
|
+
): Promise<StoredValue<T>[]> {
|
|
290
|
+
this.assertOpen(context);
|
|
291
|
+
return this.sql
|
|
292
|
+
.exec<{ key: string; seq: number; body: string }>(
|
|
293
|
+
"SELECT key, seq, body FROM pi_values WHERE namespace = ? AND substr(key, 1, length(?)) = ? ORDER BY key COLLATE BINARY",
|
|
294
|
+
prefix.namespace,
|
|
295
|
+
prefix.key,
|
|
296
|
+
prefix.key,
|
|
297
|
+
)
|
|
298
|
+
.toArray()
|
|
299
|
+
.map((row) => ({
|
|
300
|
+
address: value<T>(prefix.namespace, row.key),
|
|
301
|
+
seq: row.seq,
|
|
302
|
+
value: JSON.parse(row.body),
|
|
303
|
+
}));
|
|
159
304
|
}
|
|
160
|
-
}
|
|
161
305
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
306
|
+
async readList<T>(
|
|
307
|
+
address: ValueList<T>,
|
|
308
|
+
options: ListReadOptions | undefined,
|
|
309
|
+
context: Context,
|
|
310
|
+
): Promise<ListElement<T>[]> {
|
|
311
|
+
this.assertOpen(context);
|
|
312
|
+
const resolved = resolveListReadOptions(options);
|
|
313
|
+
const descending = resolved.order === "desc";
|
|
314
|
+
const comparison = descending ? "<" : ">";
|
|
315
|
+
return this.sql
|
|
316
|
+
.exec<{ seq: number; body: string }>(
|
|
317
|
+
`SELECT seq, body FROM pi_lists WHERE namespace = ? AND key = ? AND (? IS NULL OR seq ${comparison} ?) ORDER BY seq ${descending ? "DESC" : "ASC"} LIMIT ?`,
|
|
318
|
+
address.namespace,
|
|
319
|
+
address.key,
|
|
320
|
+
resolved.cursor?.seq ?? null,
|
|
321
|
+
resolved.cursor?.seq ?? null,
|
|
322
|
+
resolved.limit,
|
|
323
|
+
)
|
|
324
|
+
.toArray()
|
|
325
|
+
.map((row) => ({ seq: row.seq, value: JSON.parse(row.body) }));
|
|
326
|
+
}
|
|
166
327
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
* 3. **`leaf` 标记永远不会成为任何条目的 parent**:新条目的 parentId 取自
|
|
191
|
-
* `getLeafIdSync()`,那已经是解析过标记之后的真实条目 id。所以按 parentId 上溯时
|
|
192
|
-
* 不会走进标记里。
|
|
193
|
-
*/
|
|
194
|
-
export class PiTranscriptStore {
|
|
195
|
-
constructor(
|
|
196
|
-
private readonly sql: SqlTaggedTemplate,
|
|
197
|
-
metadata?: SessionMetadata,
|
|
198
|
-
) {
|
|
199
|
-
this.sql`
|
|
200
|
-
CREATE TABLE IF NOT EXISTS pi_session_metadata (
|
|
201
|
-
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
202
|
-
id TEXT NOT NULL,
|
|
203
|
-
created_at TEXT NOT NULL
|
|
328
|
+
private branchRows(query: StorageBranchScan, structure: boolean) {
|
|
329
|
+
// Stops follow the requested traversal direction; filters must not hide a stop entry.
|
|
330
|
+
const oldestFirst = query.order === "oldestFirst";
|
|
331
|
+
const rows = this.sql
|
|
332
|
+
.exec<{
|
|
333
|
+
id: string;
|
|
334
|
+
parentId: string | null;
|
|
335
|
+
seq: number;
|
|
336
|
+
timestamp: number;
|
|
337
|
+
type: Entry["type"];
|
|
338
|
+
customType: string | null;
|
|
339
|
+
body?: string;
|
|
340
|
+
}>(
|
|
341
|
+
`
|
|
342
|
+
WITH RECURSIVE path AS (
|
|
343
|
+
SELECT id, parent_id, seq, timestamp, type, custom_type${structure ? "" : ", body"} FROM pi_entries WHERE id = ?
|
|
344
|
+
UNION ALL
|
|
345
|
+
SELECT e.id, e.parent_id, e.seq, e.timestamp, e.type, e.custom_type${structure ? "" : ", e.body"}
|
|
346
|
+
FROM pi_entries e JOIN path p ON e.id = p.parent_id
|
|
347
|
+
) SELECT id, parent_id AS parentId, seq, timestamp, type, custom_type AS customType${structure ? "" : ", body"}
|
|
348
|
+
FROM path ORDER BY seq ${oldestFirst ? "ASC" : "DESC"}
|
|
349
|
+
`,
|
|
350
|
+
query.start,
|
|
204
351
|
)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
352
|
+
.toArray();
|
|
353
|
+
if (!rows.length) throw new Error(`Unknown branch start: ${query.start}`);
|
|
354
|
+
const root = oldestFirst ? rows[0]! : rows.at(-1)!;
|
|
355
|
+
if (root.parentId !== null)
|
|
356
|
+
throw new Error("Corrupt branch: missing parent");
|
|
357
|
+
const stop = rows.findIndex(
|
|
358
|
+
(row) => row.id === query.stopAtId || row.type === query.stopAtType,
|
|
359
|
+
);
|
|
360
|
+
return (stop < 0 ? rows : rows.slice(0, stop + 1))
|
|
361
|
+
.filter(
|
|
362
|
+
(row) =>
|
|
363
|
+
(query.type === undefined || query.type === row.type) &&
|
|
364
|
+
(query.customType === undefined ||
|
|
365
|
+
query.customType === row.customType) &&
|
|
366
|
+
(query.cursor === undefined ||
|
|
367
|
+
(oldestFirst
|
|
368
|
+
? row.seq > query.cursor.seq
|
|
369
|
+
: row.seq < query.cursor.seq)),
|
|
214
370
|
)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
371
|
+
.slice(
|
|
372
|
+
0,
|
|
373
|
+
query.limit === undefined ? undefined : Math.max(0, query.limit),
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async scanBranch(
|
|
378
|
+
query: StorageBranchScan,
|
|
379
|
+
context: Context,
|
|
380
|
+
): Promise<Entry[]> {
|
|
381
|
+
return this.scanBranchSync(query, context);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
scanBranchSync(query: StorageBranchScan, context?: Context): Entry[] {
|
|
385
|
+
this.assertOpen(context);
|
|
386
|
+
return this.branchRows(query, false).map((row) => JSON.parse(row.body!));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async scanBranchStructure(
|
|
390
|
+
query: StorageBranchScan,
|
|
391
|
+
context: Context,
|
|
392
|
+
): Promise<EntryStructure[]> {
|
|
393
|
+
this.assertOpen(context);
|
|
394
|
+
return this.branchRows(query, true).map(
|
|
395
|
+
({ customType, body: _body, ...row }) => ({
|
|
396
|
+
...row,
|
|
397
|
+
...(customType === null ? {} : { customType }),
|
|
398
|
+
}),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async scanEntries(query: EntryScan, context: Context): Promise<Entry[]> {
|
|
403
|
+
this.assertOpen(context);
|
|
404
|
+
return this.sql
|
|
405
|
+
.exec<{ body: string }>(
|
|
406
|
+
`
|
|
407
|
+
SELECT body FROM pi_entries WHERE (? IS NULL OR type = ?) AND (? IS NULL OR custom_type = ?)
|
|
408
|
+
AND (? IS NULL OR seq >= ?) AND (? IS NULL OR seq <= ?) ORDER BY seq ${query.order === "desc" ? "DESC" : "ASC"} LIMIT ?`,
|
|
409
|
+
query.type ?? null,
|
|
410
|
+
query.type ?? null,
|
|
411
|
+
query.customType ?? null,
|
|
412
|
+
query.customType ?? null,
|
|
413
|
+
query.fromSeq ?? null,
|
|
414
|
+
query.fromSeq ?? null,
|
|
415
|
+
query.toSeq ?? null,
|
|
416
|
+
query.toSeq ?? null,
|
|
417
|
+
query.limit === undefined ? -1 : Math.max(0, Math.trunc(query.limit)),
|
|
228
418
|
)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
419
|
+
.toArray()
|
|
420
|
+
.map((row) => JSON.parse(row.body));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async scanUsage(query: UsageScan, context: Context): Promise<UsageRow[]> {
|
|
424
|
+
this.assertOpen(context);
|
|
425
|
+
return this.sql
|
|
426
|
+
.exec<{ body: string }>(
|
|
427
|
+
`
|
|
428
|
+
SELECT body FROM pi_usage WHERE (? IS NULL OR seq >= ?) AND (? IS NULL OR seq <= ?)
|
|
429
|
+
ORDER BY seq ${query.order === "desc" ? "DESC" : "ASC"} LIMIT ?`,
|
|
430
|
+
query.fromSeq ?? null,
|
|
431
|
+
query.fromSeq ?? null,
|
|
432
|
+
query.toSeq ?? null,
|
|
433
|
+
query.toSeq ?? null,
|
|
434
|
+
query.limit === undefined ? -1 : Math.max(0, Math.trunc(query.limit)),
|
|
243
435
|
)
|
|
244
|
-
|
|
436
|
+
.toArray()
|
|
437
|
+
.map((row) => JSON.parse(row.body));
|
|
245
438
|
}
|
|
246
439
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (leafId === null) return [];
|
|
440
|
+
private statsSync(): SessionStats {
|
|
441
|
+
return JSON.parse(
|
|
442
|
+
this.sql
|
|
443
|
+
.exec<{
|
|
444
|
+
stats: string;
|
|
445
|
+
}>("SELECT stats FROM pi_storage_state WHERE singleton = 1")
|
|
446
|
+
.one().stats,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
257
449
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
FROM pi_session_entries
|
|
262
|
-
WHERE id = ${leafId}
|
|
263
|
-
UNION ALL
|
|
264
|
-
SELECT e.id, e.body, e.parent_id
|
|
265
|
-
FROM pi_session_entries e
|
|
266
|
-
JOIN path p ON e.id = p.parent_id
|
|
267
|
-
)
|
|
268
|
-
SELECT id, body FROM path
|
|
269
|
-
`;
|
|
270
|
-
if (rows.length === 0) {
|
|
271
|
-
throw new SessionError("not_found", `Entry ${leafId} not found`);
|
|
272
|
-
}
|
|
273
|
-
// CTE 从 leaf 往上产出,这里翻成 root→leaf 与旧的 unshift 顺序一致。
|
|
274
|
-
const path = rows.map(parseEntry).reverse();
|
|
275
|
-
// 断链检测:旧实现在父条目缺失时抛 invalid_session,而 CTE 遇到断点只会静默停止
|
|
276
|
-
// 递归。顶端条目的 parentId 必须为空,否则说明中间有一条祖先不见了。
|
|
277
|
-
const root = path[0];
|
|
278
|
-
if (root?.parentId) {
|
|
279
|
-
throw new SessionError(
|
|
280
|
-
"invalid_session",
|
|
281
|
-
`Entry ${root.parentId} not found`,
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
return path;
|
|
450
|
+
async getStats(context: Context): Promise<SessionStats> {
|
|
451
|
+
this.assertOpen(context);
|
|
452
|
+
return this.statsSync();
|
|
285
453
|
}
|
|
286
454
|
|
|
287
|
-
|
|
288
|
-
|
|
455
|
+
/** Host commits use this single branch; off-branch entries remain available by ID. */
|
|
456
|
+
branch(): Entry[] {
|
|
457
|
+
const tip = this.getValueSync(branchTip("main"))?.value;
|
|
458
|
+
return tip ? this.scanBranchSync({ start: tip, order: "oldestFirst" }) : [];
|
|
289
459
|
}
|
|
290
460
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
461
|
+
entry(id: string): Entry | undefined {
|
|
462
|
+
return this.getEntriesSync([id]).get(id);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
appendMessage(input: {
|
|
466
|
+
id: string;
|
|
467
|
+
message: AgentMessage;
|
|
468
|
+
submissionId?: string;
|
|
469
|
+
}): boolean {
|
|
470
|
+
return this.storage.transactionSync(() => {
|
|
471
|
+
if (this.entry(input.id)) return false;
|
|
472
|
+
const usage =
|
|
473
|
+
input.message.role === "assistant" ||
|
|
474
|
+
input.message.role === "toolResult"
|
|
475
|
+
? input.message.usage
|
|
476
|
+
: undefined;
|
|
477
|
+
this.commitSync([
|
|
478
|
+
insertEntry({
|
|
479
|
+
id: input.id,
|
|
480
|
+
parentId: this.getValueSync(branchTip("main"))?.value ?? null,
|
|
481
|
+
type: "message",
|
|
482
|
+
message: input.message,
|
|
483
|
+
}),
|
|
484
|
+
setValue(branchTip("main"), input.id),
|
|
485
|
+
...(usage
|
|
486
|
+
? [
|
|
487
|
+
insertUsage({
|
|
488
|
+
id: crypto.randomUUID(),
|
|
489
|
+
entryId: input.id,
|
|
490
|
+
usage,
|
|
491
|
+
adjustment: false,
|
|
492
|
+
}),
|
|
493
|
+
]
|
|
494
|
+
: []),
|
|
495
|
+
]);
|
|
496
|
+
if (input.submissionId)
|
|
497
|
+
this.sql.exec(
|
|
498
|
+
"INSERT INTO pi_session_message_submission VALUES (?, ?)",
|
|
499
|
+
input.id,
|
|
500
|
+
input.submissionId,
|
|
501
|
+
);
|
|
502
|
+
return true;
|
|
311
503
|
});
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
moveBranchTip(id: string | null): void {
|
|
507
|
+
if (id !== null && !this.entry(id))
|
|
508
|
+
throw new Error(`Unknown branch target: ${id}`);
|
|
509
|
+
this.commitSync([setValue(branchTip("main"), id)]);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
recordCompaction(
|
|
513
|
+
entry: Omit<CompactionEntry, "id" | "parentId" | "seq" | "timestamp"> | undefined,
|
|
514
|
+
usage: Usage | undefined = entry?.usage,
|
|
515
|
+
): string {
|
|
516
|
+
const id = crypto.randomUUID();
|
|
517
|
+
const writes: Write[] = [];
|
|
518
|
+
if (entry) writes.push(
|
|
519
|
+
insertEntry({
|
|
520
|
+
...entry, id,
|
|
521
|
+
parentId: this.getValueSync(branchTip("main"))?.value ?? null,
|
|
522
|
+
}),
|
|
523
|
+
setValue(branchTip("main"), id),
|
|
524
|
+
);
|
|
525
|
+
if (usage) writes.push(insertUsage({
|
|
526
|
+
id: entry ? crypto.randomUUID() : id,
|
|
527
|
+
...(entry ? { entryId: id } : {}),
|
|
528
|
+
usage,
|
|
529
|
+
adjustment: false,
|
|
530
|
+
}));
|
|
531
|
+
this.commitSync(writes);
|
|
315
532
|
return id;
|
|
316
533
|
}
|
|
317
534
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
type: TType,
|
|
324
|
-
): Array<Extract<SessionTreeEntry, { type: TType }>> {
|
|
325
|
-
return this.sql<EntryRow>`
|
|
326
|
-
SELECT id, body
|
|
327
|
-
FROM pi_session_entries
|
|
328
|
-
WHERE type = ${type}
|
|
329
|
-
ORDER BY seq ASC
|
|
330
|
-
`.map(parseEntry) as Array<Extract<SessionTreeEntry, { type: TType }>>;
|
|
535
|
+
entries(): Entry[] {
|
|
536
|
+
return this.sql
|
|
537
|
+
.exec<{ body: string }>("SELECT body FROM pi_entries ORDER BY seq")
|
|
538
|
+
.toArray()
|
|
539
|
+
.map((row) => JSON.parse(row.body));
|
|
331
540
|
}
|
|
332
541
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
542
|
+
clear(): void {
|
|
543
|
+
this.storage.transactionSync(() => {
|
|
544
|
+
this.sql.exec(
|
|
545
|
+
"DELETE FROM pi_session_message_submission; DELETE FROM pi_entries; DELETE FROM pi_values; DELETE FROM pi_lists; DELETE FROM pi_usage;",
|
|
546
|
+
);
|
|
547
|
+
const usage = {
|
|
548
|
+
input: 0,
|
|
549
|
+
output: 0,
|
|
550
|
+
cacheRead: 0,
|
|
551
|
+
cacheWrite: 0,
|
|
552
|
+
totalTokens: 0,
|
|
553
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
554
|
+
};
|
|
555
|
+
this.sql.exec(
|
|
556
|
+
"UPDATE pi_storage_state SET stats = ? WHERE singleton = 1",
|
|
557
|
+
JSON.stringify({ messageCount: 0, usage }),
|
|
558
|
+
);
|
|
559
|
+
});
|
|
340
560
|
}
|
|
341
561
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
* 必须保持一致:压缩条目的 firstKeptEntryId 引用的就是这种 id。
|
|
345
|
-
*/
|
|
346
|
-
private createEntryId(): string {
|
|
347
|
-
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
348
|
-
const id = uuidv7().slice(-8);
|
|
349
|
-
if (!getEntrySync(this.sql, id)) return id;
|
|
350
|
-
}
|
|
351
|
-
return uuidv7();
|
|
562
|
+
async close(_context?: Context): Promise<void> {
|
|
563
|
+
this.closed = true;
|
|
352
564
|
}
|
|
353
565
|
}
|