@evandu/super-pi 0.1.69 → 0.1.71
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/README.md +12 -0
- package/dist/client/assets/index-CKdlMXJt.css +1 -0
- package/dist/client/assets/index-CpHVxuB1.js +54 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.js +83 -31
- package/dist/server/rpc-channel.d.ts +109 -0
- package/dist/server/rpc-channel.js +117 -0
- package/dist/server/runtime.d.ts +6 -227
- package/dist/server/runtime.js +66 -358
- package/dist/server/session-repository.d.ts +99 -0
- package/dist/server/session-repository.js +245 -0
- package/dist/server/store.d.ts +12 -0
- package/dist/server/store.js +39 -5
- package/dist/server/subagents.js +3 -2
- package/dist/server/types.d.ts +15 -1
- package/dist/server/ws-server.d.ts +5 -0
- package/dist/server/ws-server.js +49 -3
- package/package.json +5 -2
- package/dist/client/assets/index-BWh3K9qo.js +0 -54
- package/dist/client/assets/index-C8NHSPNK.css +0 -1
package/dist/server/runtime.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { RpcChannel } from "./rpc-channel.js";
|
|
3
|
+
export { RpcChannel } from "./rpc-channel.js";
|
|
2
4
|
import { randomUUID } from "node:crypto";
|
|
3
5
|
import { existsSync } from "node:fs";
|
|
4
|
-
import { mkdir,
|
|
6
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
7
|
+
import { forkSessionFile, truncateSessionFile } from "./session-repository.js";
|
|
8
|
+
export { forkSessionFile, truncateSessionFile } from "./session-repository.js";
|
|
5
9
|
import { resolve, join } from "node:path";
|
|
6
10
|
import { agentSpawnArgs, readAgents } from "./agents.js";
|
|
7
11
|
import { TASK_CAPABILITY_ENV, mintCapability, releaseCapability } from "./capability.js";
|
|
@@ -10,6 +14,11 @@ import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_EXEC_END
|
|
|
10
14
|
import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
|
|
11
15
|
import { loadConversationImages } from "./upload.js";
|
|
12
16
|
import { describeToolCall, describeToolCallArgs, describeToolCallInProgress, extractPartialResultText, extractToolResultText, readQuestionCall } from "./tool-calls.js";
|
|
17
|
+
const traceLatency = (conversationId, phase, startedAt, extra = "") => {
|
|
18
|
+
if (process.env.SUPER_PI_TRACE_LATENCY !== "1")
|
|
19
|
+
return;
|
|
20
|
+
console.warn(`[super-pi latency] conversation=${conversationId} phase=${phase} elapsedMs=${Math.round(performance.now() - startedAt)}${extra ? ` ${extra}` : ""}`);
|
|
21
|
+
};
|
|
13
22
|
/** A steer that provably never reached Pi; the caller may safely use the next turn instead. */
|
|
14
23
|
export class SteerUnavailableError extends Error {
|
|
15
24
|
}
|
|
@@ -33,6 +42,15 @@ export class WelcomeRuntime {
|
|
|
33
42
|
return { source: "unavailable", models: [], levels: [], fetchedAt: new Date().toISOString(), error: "Welcome runtime has no Pi session to ask" };
|
|
34
43
|
}
|
|
35
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* One record off Pi's JSONL stdout. Deliberately a loose shape with optional fields: Pi emits a wide,
|
|
47
|
+
* open-ended family of session events, and a type that enumerated all of them would break on the next
|
|
48
|
+
* Pi release rather than on the next behaviour change.
|
|
49
|
+
*
|
|
50
|
+
* Exported so another consumer of `RpcChannel` - the sub-agent supervisor - can read the same records
|
|
51
|
+
* through the same channel instead of writing a second JSONL reader. Every field below is optional, so
|
|
52
|
+
* a reader must check before using one.
|
|
53
|
+
*/
|
|
36
54
|
/**
|
|
37
55
|
* Reads Pi's `queue_update` payload, or returns undefined when the record is not one.
|
|
38
56
|
*
|
|
@@ -113,111 +131,6 @@ export function buildExtensionUiResponse(request, answer) {
|
|
|
113
131
|
return { type: "extension_ui_response", id: request.requestId, confirmed: answer.confirmed === true };
|
|
114
132
|
return { type: "extension_ui_response", id: request.requestId, value: answer.value ?? "" };
|
|
115
133
|
}
|
|
116
|
-
/** One Pi child, its stdout reader, and the single writer to its stdin. */
|
|
117
|
-
export class RpcChannel {
|
|
118
|
-
child;
|
|
119
|
-
records = [];
|
|
120
|
-
/**
|
|
121
|
-
* Responses a drain loop pulled off the queue before the waiter that wanted them looked.
|
|
122
|
-
*
|
|
123
|
-
* THE RACE THIS EXISTS TO SETTLE, and it is not a rare one - it cost this file an intermittent test
|
|
124
|
-
* failure. One queue serves two kinds of reader: `waitFor`, which is after a specific command's answer by
|
|
125
|
-
* id, and `next`, which is a turn's drain loop reading whatever arrives first. The loop does not know
|
|
126
|
-
* which ids someone is waiting for, so it used to take the first record of any kind - including the answer
|
|
127
|
-
* to a `get_entries` written moments earlier - and the waiter then waited out its whole timeout for a
|
|
128
|
-
* response that had already been consumed and discarded.
|
|
129
|
-
*
|
|
130
|
-
* So the drain loop no longer decides an unmatched response is garbage: it HANDS IT BACK here, and the
|
|
131
|
-
* waiter finds it whether it got there first or second. Nothing is dropped, and nothing is waited for
|
|
132
|
-
* twice.
|
|
133
|
-
*/
|
|
134
|
-
stashed = new Map();
|
|
135
|
-
buffer = "";
|
|
136
|
-
/** stderr is kept only to explain an early exit; a well-behaved Pi writes nothing here. */
|
|
137
|
-
stderr = "";
|
|
138
|
-
constructor(child) {
|
|
139
|
-
this.child = child;
|
|
140
|
-
child.stderr.on("data", (chunk) => { this.stderr += chunk.toString(); });
|
|
141
|
-
child.stdout.on("data", (chunk) => {
|
|
142
|
-
this.buffer += chunk.toString("utf8");
|
|
143
|
-
let newline;
|
|
144
|
-
while ((newline = this.buffer.indexOf("\n")) >= 0) {
|
|
145
|
-
const line = this.buffer.slice(0, newline).replace(/\r$/, "");
|
|
146
|
-
this.buffer = this.buffer.slice(newline + 1);
|
|
147
|
-
if (!line)
|
|
148
|
-
continue;
|
|
149
|
-
try {
|
|
150
|
-
this.records.push(JSON.parse(line));
|
|
151
|
-
}
|
|
152
|
-
catch { /* Pi stdout must be JSONL */ }
|
|
153
|
-
}
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
get exitCode() { return this.child.exitCode; }
|
|
157
|
-
/** Writes one JSONL command. Throws rather than silently dropping: a command Pi never sees is a lie the caller would act on. */
|
|
158
|
-
write(command) {
|
|
159
|
-
if (this.child.killed || this.child.stdin.destroyed)
|
|
160
|
-
throw new Error(`Pi RPC stdin is closed, so ${JSON.stringify(command)} was not sent`);
|
|
161
|
-
this.child.stdin.write(`${JSON.stringify(command)}\n`);
|
|
162
|
-
}
|
|
163
|
-
/**
|
|
164
|
-
* Returns one record to whoever is waiting for it, after a drain loop read it.
|
|
165
|
-
*
|
|
166
|
-
* Called for every response the loop does not consume itself. See `RpcChannel`'s note on `stashed` for why
|
|
167
|
-
* this is not the same as discarding it - discarding it silently cost a turn its revert boundary.
|
|
168
|
-
*/
|
|
169
|
-
stash(record) { if (record.type === "response" && typeof record.id === "string")
|
|
170
|
-
this.stashed.set(record.id, record); }
|
|
171
|
-
/** Removes and returns the next record, or undefined after `timeoutMs` with nothing to read. */
|
|
172
|
-
async next(timeoutMs = 100) {
|
|
173
|
-
const deadline = Date.now() + timeoutMs;
|
|
174
|
-
for (;;) {
|
|
175
|
-
if (this.records.length)
|
|
176
|
-
return this.records.shift();
|
|
177
|
-
if (Date.now() >= deadline)
|
|
178
|
-
return undefined;
|
|
179
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
/** Sends a command and waits for its response. For one command; use write()+waitFor() to overlap several. */
|
|
183
|
-
async request(command, timeoutMs) {
|
|
184
|
-
this.write(command);
|
|
185
|
-
return this.waitFor(command.id, timeoutMs, command.type);
|
|
186
|
-
}
|
|
187
|
-
/**
|
|
188
|
-
* Returns one response by id, from the queue if it is still there and from the stash if a drain loop got
|
|
189
|
-
* there first. Removing it from the stash is what keeps a single answer from being handed out twice.
|
|
190
|
-
*/
|
|
191
|
-
take(id) {
|
|
192
|
-
const index = this.records.findIndex((record) => record.type === "response" && record.id === id);
|
|
193
|
-
if (index >= 0)
|
|
194
|
-
return this.records.splice(index, 1)[0];
|
|
195
|
-
const stashed = this.stashed.get(id);
|
|
196
|
-
if (!stashed)
|
|
197
|
-
return undefined;
|
|
198
|
-
this.stashed.delete(id);
|
|
199
|
-
return stashed;
|
|
200
|
-
}
|
|
201
|
-
/**
|
|
202
|
-
* Waits for the response to one command id. Every record that is not that response stays QUEUED in
|
|
203
|
-
* order, so a caller can have three requests in flight and still read all three answers: whoever
|
|
204
|
-
* asks first takes its own record and leaves the rest for the drain loop.
|
|
205
|
-
*/
|
|
206
|
-
async waitFor(id, timeoutMs, what = id) {
|
|
207
|
-
const deadline = Date.now() + timeoutMs;
|
|
208
|
-
for (;;) {
|
|
209
|
-
const record = this.take(id);
|
|
210
|
-
if (record)
|
|
211
|
-
return record;
|
|
212
|
-
// A dead child will never answer, so waiting out the full deadline would only delay the failure.
|
|
213
|
-
if (this.child.exitCode !== null)
|
|
214
|
-
throw new Error(`Pi RPC exited (code ${this.child.exitCode}) while waiting for ${what}. ${this.stderr.trim()}`);
|
|
215
|
-
if (Date.now() >= deadline)
|
|
216
|
-
throw new Error(`Pi did not answer ${what} within ${timeoutMs}ms`);
|
|
217
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
134
|
/**
|
|
222
135
|
* The commands a freshly spawned Pi must be told before it runs a conversation's turn.
|
|
223
136
|
*
|
|
@@ -257,250 +170,7 @@ export function killTree(pid) {
|
|
|
257
170
|
killer.once("error", () => resolve());
|
|
258
171
|
});
|
|
259
172
|
}
|
|
260
|
-
|
|
261
|
-
* Cuts a Pi session file down to the header plus everything up to and including `boundaryEntryId`.
|
|
262
|
-
*
|
|
263
|
-
* ============================ WHAT THIS DESTROYS ============================
|
|
264
|
-
* **Every entry written after the boundary is gone from disk, permanently, and there is no way to
|
|
265
|
-
* reconstruct it from this file.** That includes not only the messages a person can see but also Pi's
|
|
266
|
-
* `tool_call` records, `compaction` summaries, `model_change` and `thinking_level_change` markers, usage
|
|
267
|
-
* accounting, and anything a future Pi version adds. This is the step the whole revert design is built
|
|
268
|
-
* around (research §4.2) and the step that cannot be undone by the application - Super Pi has no undo,
|
|
269
|
-
* and adding one was explicitly left out of scope.
|
|
270
|
-
*
|
|
271
|
-
* It does NOT touch the workspace. No file the agent read or wrote is read, written or reverted here.
|
|
272
|
-
* =============================================================================
|
|
273
|
-
*
|
|
274
|
-
* WHY TRUNCATE RATHER THAN ASK PI. Pi's `SessionManager` is append-only by design
|
|
275
|
-
* (`session-manager.ts:1516-1519`: "Entries cannot be modified or deleted"), and its in-session rewind -
|
|
276
|
-
* `branch()`, `navigateTree()` - is IN-MEMORY ONLY (`:1573-1584`): `_buildIndex()` re-reads the whole
|
|
277
|
-
* file on every `open()` and sets the leaf to the last line unconditionally (`:1103-1122`). Super Pi
|
|
278
|
-
* spawns one Pi per turn and kills it afterwards, so a leaf pointer set in one turn is gone before the
|
|
279
|
-
* next one starts - a "call `branch()` once" solution would look correct for a turn and then silently
|
|
280
|
-
* forget (research §5 pitfall 2). Truncating the file is the only version of this that is a durable state
|
|
281
|
-
* change rather than per-turn bookkeeping that can be skipped exactly once.
|
|
282
|
-
*
|
|
283
|
-
* WHY THE FORMAT IS ASSUMED RATHER THAN GUESSED. The shape this relies on - line 0 is the one and only
|
|
284
|
-
* `type: "session"` header, every other line is one self-contained JSON entry, every entry's `parentId`
|
|
285
|
-
* already appears earlier in the file, no blank lines, and a trailing newline - was measured against
|
|
286
|
-
* real Pi by scripts/probe-revert-session-format.mjs, which also proves the `parentId` property that
|
|
287
|
-
* makes a prefix a VALID session rather than a fragment. That probe is the reason this function may
|
|
288
|
-
* index by line at all; every one of those claims is re-checked here before anything is written, so a
|
|
289
|
-
* Pi that changed the format fails loudly instead of producing a file Pi cannot open.
|
|
290
|
-
*
|
|
291
|
-
* WHY THE WRITE IS TEMP-THEN-RENAME. Pi appends with a bare `appendFileSync`
|
|
292
|
-
* (`session-manager.ts:1187`) and makes no atomicity guarantee of its own. Writing the truncated copy
|
|
293
|
-
* under a different name and renaming it over the original means a reader never observes a half-written
|
|
294
|
-
* file, and a failure before the rename leaves the original untouched.
|
|
295
|
-
*
|
|
296
|
-
* WHY A BACKUP IS KEPT. `.super-pi-revert-backup` is written next to the session file, deliberately not
|
|
297
|
-
* named `*.jsonl` so Pi's own discovery (`readdirSync` filtered on that suffix,
|
|
298
|
-
* `session-manager.ts:1881`) never lists it, and deliberately NOT surfaced anywhere as a feature:
|
|
299
|
-
* Super Pi has no undo and does not pretend to. It exists so that an irreversible, unrecoverable-by-code
|
|
300
|
-
* operation on a person's conversation history is at least recoverable by hand.
|
|
301
|
-
*
|
|
302
|
-
* EXPORTED so it can be tested against a fabricated session file in a temp directory, without Pi and
|
|
303
|
-
* without touching anybody's real transcript.
|
|
304
|
-
*/
|
|
305
|
-
export async function truncateSessionFile(path, boundaryEntryId, expectedSessionId, beforeBoundary = false) {
|
|
306
|
-
const raw = await readFile(path, "utf8");
|
|
307
|
-
// Split, then drop the empty segment a trailing newline leaves behind. Probe A measured that the file
|
|
308
|
-
// DOES end with a newline, so `split` yields one empty tail element; keeping it would append a blank
|
|
309
|
-
// line to the rewritten file and undo the property the probe just established.
|
|
310
|
-
const lines = raw.split("\n");
|
|
311
|
-
if (lines.at(-1) === "")
|
|
312
|
-
lines.pop();
|
|
313
|
-
if (!lines.length)
|
|
314
|
-
throw new Error(`Pi 的会话文件是空的,拒绝回退:${path}`);
|
|
315
|
-
const parsed = lines.map((line) => {
|
|
316
|
-
try {
|
|
317
|
-
return JSON.parse(line);
|
|
318
|
-
}
|
|
319
|
-
catch {
|
|
320
|
-
return undefined;
|
|
321
|
-
}
|
|
322
|
-
});
|
|
323
|
-
// Every claim probe A made, re-checked here because a Pi upgrade may invalidate any of them. Each
|
|
324
|
-
// failure names the shape that was actually found, because "revert failed" alone sends the reader
|
|
325
|
-
// looking for their own data instead of at the version mismatch that caused it.
|
|
326
|
-
// Checked BEFORE the JSON check, so the refusal names the actual defect: a blank line reads as "this
|
|
327
|
-
// file has a shape my line arithmetic cannot trust", which is what it is, and a bare "not valid JSON"
|
|
328
|
-
// would send the reader looking for a corrupted file instead.
|
|
329
|
-
const blank = lines.findIndex((line) => line.trim() === "");
|
|
330
|
-
if (blank >= 0)
|
|
331
|
-
throw new Error(`Pi 的会话文件第 ${blank} 行是空行,行号与 entry 号不再一一对应,拒绝回退:${path}`);
|
|
332
|
-
const unparseable = parsed.findIndex((entry) => entry === undefined);
|
|
333
|
-
if (unparseable >= 0)
|
|
334
|
-
throw new Error(`Pi 的会话文件第 ${unparseable} 行不是合法 JSON,这个文件的格式已经变了,拒绝回退以免损坏它:${path}`);
|
|
335
|
-
const headerLines = parsed.filter((entry) => entry?.type === "session");
|
|
336
|
-
if (headerLines.length !== 1)
|
|
337
|
-
throw new Error(`Pi 的会话文件里有 ${headerLines.length} 个 header(应为 1 个),格式已经变了,拒绝回退:${path}`);
|
|
338
|
-
if (parsed[0]?.type !== "session")
|
|
339
|
-
throw new Error(`Pi 的会话文件第一行不是 header(实际 type=${JSON.stringify(parsed[0]?.type ?? null)}),格式已经变了,拒绝回退:${path}`);
|
|
340
|
-
if (parsed[0]?.id !== expectedSessionId)
|
|
341
|
-
throw new Error(`Pi 的会话文件属于会话 ${JSON.stringify(parsed[0]?.id ?? null)},不是要回退的会话 ${JSON.stringify(expectedSessionId)}。拒绝截断别人的会话。`);
|
|
342
|
-
if (!raw.endsWith("\n"))
|
|
343
|
-
throw new Error(`Pi 的会话文件末尾没有换行,格式已经变了,拒绝回退:${path}`);
|
|
344
|
-
let boundary = boundaryEntryId === "" ? 0 : parsed.findIndex((entry, index) => index > 0 && entry?.id === boundaryEntryId);
|
|
345
|
-
if (boundary < 0)
|
|
346
|
-
throw new Error(`在这份 Pi 会话记录里找不到要保留的那条消息(entry ${JSON.stringify(boundaryEntryId)})。这条消息可能是更早的版本写的,Super Pi 当时没有记下它对应 Pi 的哪一条。`);
|
|
347
|
-
const expectedLastId = beforeBoundary ? parsed[boundary - 1]?.id : parsed[boundary]?.id;
|
|
348
|
-
if (beforeBoundary)
|
|
349
|
-
boundary -= 1;
|
|
350
|
-
// Fail closed: a boundary of -1 here means "revert to before the FIRST entry", which would write an
|
|
351
|
-
// empty session file. The verification below would pass vacuously (0 !== 0 is false), so the refusal
|
|
352
|
-
// must happen here rather than be trusted to a check that cannot fire.
|
|
353
|
-
if (boundary < 0)
|
|
354
|
-
throw new Error(`回退点在第一条消息之前,截断会得到一个空会话文件,拒绝回退:${path}`);
|
|
355
|
-
// The `parentId` property probe A measured, enforced at the boundary that matters: every entry still
|
|
356
|
-
// being KEPT must point at something also being kept. A prefix that violates this is not a session,
|
|
357
|
-
// and writing it would produce a file Pi either rejects or silently misreads.
|
|
358
|
-
const kept = new Set();
|
|
359
|
-
for (let index = 0; index <= boundary; index += 1) {
|
|
360
|
-
const parent = parsed[index]?.parentId;
|
|
361
|
-
if (parent !== undefined && parent !== null && !kept.has(parent))
|
|
362
|
-
throw new Error(`要保留的部分不是自洽的:第 ${index} 行引用了不在保留范围内的 ${JSON.stringify(parent)},拒绝回退以免产出 Pi 打不开的会话。`);
|
|
363
|
-
kept.add(parsed[index]?.id);
|
|
364
|
-
}
|
|
365
|
-
const rewritten = `${lines.slice(0, boundary + 1).join("\n")}\n`;
|
|
366
|
-
const backupPath = `${path}.super-pi-revert-backup`;
|
|
367
|
-
const temporaryPath = `${path}.super-pi-revert-tmp`;
|
|
368
|
-
await writeFile(backupPath, raw, "utf8");
|
|
369
|
-
await writeFile(temporaryPath, rewritten, "utf8");
|
|
370
|
-
await rename(temporaryPath, path);
|
|
371
|
-
// Re-read rather than trust the write. The claim this whole mechanism rests on is "the file's last
|
|
372
|
-
// line is now the boundary entry", and `_buildIndex` derives the leaf from exactly that line - so if
|
|
373
|
-
// the write did not land, nothing downstream would say so until a turn produced wrong context.
|
|
374
|
-
const verified = (await readFile(path, "utf8")).split("\n").filter((line) => line !== "");
|
|
375
|
-
const last = verified[verified.length - 1];
|
|
376
|
-
let lastEntry;
|
|
377
|
-
try {
|
|
378
|
-
lastEntry = JSON.parse(last);
|
|
379
|
-
}
|
|
380
|
-
catch {
|
|
381
|
-
lastEntry = undefined;
|
|
382
|
-
}
|
|
383
|
-
if (verified.length !== boundary + 1 || lastEntry?.id !== expectedLastId || boundary + 1 < 1) {
|
|
384
|
-
throw new Error(`截断后的 Pi 会话文件没有以要保留的那条消息结尾(写到 ${verified.length} 行,期望 ${boundary + 1} 行,末行 id=${JSON.stringify(lastEntry?.id ?? null)})。原始文件仍在 ${backupPath}。`);
|
|
385
|
-
}
|
|
386
|
-
return { linesKept: boundary + 1, entriesDropped: lines.length - (boundary + 1) };
|
|
387
|
-
}
|
|
388
|
-
/**
|
|
389
|
-
* Writes a second Pi session file that begins as an EXACT copy of one, under a different session id.
|
|
390
|
-
*
|
|
391
|
-
* ============================ WHAT THIS DOES TO THE ORIGINAL ============================
|
|
392
|
-
* **Nothing.** The source file is read and never written, never truncated, never renamed. This is the
|
|
393
|
-
* whole difference from `truncateSessionFile`, and it is the reason a fork is the safe half of this pair
|
|
394
|
-
* of operations: a revert destroys entries permanently and keeps a hand-recovery backup, while a fork
|
|
395
|
-
* leaves both files whole and keeps no backup because there is nothing to recover.
|
|
396
|
-
* ========================================================================================
|
|
397
|
-
*
|
|
398
|
-
* WHY A COPY AND NOT PI'S `fork()`. Covered at length on `ClientCommand`'s `conversation.fork`: Pi's fork
|
|
399
|
-
* always tears down the runtime and switches to the new session (`agent-session-runtime.ts:299-307`), and
|
|
400
|
-
* Super Pi has no runtime to switch - it spawns one Pi per turn and kills it. The file is the only durable
|
|
401
|
-
* thing a fork has to produce here, so the file is what gets produced.
|
|
402
|
-
*
|
|
403
|
-
* ============================ WHAT THIS DOES TO PI's ENTRY IDS ============================
|
|
404
|
-
* **Nothing either, and this is a deliberate divergence from Pi's own `createBranchedSession`, which
|
|
405
|
-
* RENUMBERS `parentId` (`:1655-1666`).** Keeping every id verbatim means every cross-reference already
|
|
406
|
-
* inside the file stays valid, and research pitfall 6 is exactly why that matters: a Pi entry may point
|
|
407
|
-
* at other entries by id in ways this code has never enumerated - a `compaction` record carries a
|
|
408
|
-
* `firstKeptEntryId`, `branch_summary` carries the branch point - and renumbering ids means rewriting every
|
|
409
|
-
* one of those references correctly, with no way to find them all. Copying the bytes cannot break a
|
|
410
|
-
* reference because it changes nothing a reference points at. The cost is that the same entry id now
|
|
411
|
-
* exists in two files; that is safe because `_buildIndex` builds its `byId` map per file (`:1103-1122`),
|
|
412
|
-
* and Super Pi only ever opens one file per process.
|
|
413
|
-
*
|
|
414
|
-
* WHAT THE HEADER CHANGES, and it is two fields. `id` becomes the new conversation's id, because that is
|
|
415
|
-
* what Pi's lookup matches (`findLocalSessionByExactId` reads the header, probe B) and therefore what makes
|
|
416
|
-
* `--session-id <forked>` open THIS file rather than silently inventing an empty session with that id
|
|
417
|
-
* (`main.ts:436-448`, measured: `Warning: No project session found with id '...'`). And `parentSession`
|
|
418
|
-
* records the file this one came from, which is the same one-line lineage pointer Pi itself writes
|
|
419
|
-
* (`:1675-1682`) and is not a link - the files share nothing. `cwd`, `version` and `timestamp` are copied
|
|
420
|
-
* verbatim: `cwd` MUST be identical, because Pi's lookup requires the recorded cwd to equal the one it is
|
|
421
|
-
* asked from, and the fork always lives in the same workspace as its source.
|
|
422
|
-
*
|
|
423
|
-
* THE SAME FORMAT CHECKS AS TRUNCATE, AND FOR THE SAME REASON. Probe A measured this file's shape against
|
|
424
|
-
* real Pi (`scripts/probe-revert-session-format.mjs`); a Pi upgrade can invalidate any of those claims, so
|
|
425
|
-
* every one is re-checked before a byte is written and a failure names the shape actually found. Here the
|
|
426
|
-
* `parentId` property is checked across the WHOLE file rather than across a kept prefix, because the whole
|
|
427
|
-
* file is what is being copied.
|
|
428
|
-
*
|
|
429
|
-
* TEMP-THEN-RENAME, because Pi appends with a bare `appendFileSync` (`:1187`) and the target must never be
|
|
430
|
-
* observed half-written - a half-written file under the forked id is precisely the "Pi opens an empty
|
|
431
|
-
* session and the history has vanished with no error anywhere" outcome probe B was about.
|
|
432
|
-
*
|
|
433
|
-
* EXPORTED so it can be tested against fabricated session files in a temp directory, without Pi.
|
|
434
|
-
*/
|
|
435
|
-
export async function forkSessionFile(sourcePath, targetPath, expectedSessionId, targetSessionId) {
|
|
436
|
-
const raw = await readFile(sourcePath, "utf8");
|
|
437
|
-
const lines = raw.split("\n");
|
|
438
|
-
if (lines.at(-1) === "")
|
|
439
|
-
lines.pop();
|
|
440
|
-
if (!lines.length)
|
|
441
|
-
throw new Error(`Pi 的会话文件是空的,拒绝分叉:${sourcePath}`);
|
|
442
|
-
const parsed = lines.map((line) => {
|
|
443
|
-
try {
|
|
444
|
-
return JSON.parse(line);
|
|
445
|
-
}
|
|
446
|
-
catch {
|
|
447
|
-
return undefined;
|
|
448
|
-
}
|
|
449
|
-
});
|
|
450
|
-
// The refusal messages name the shape actually found, and say "拒绝分叉" rather than reusing the revert
|
|
451
|
-
// wording: the reader is looking at a fork button, and a message about 回退 would send them to look for a
|
|
452
|
-
// control they did not press.
|
|
453
|
-
const blank = lines.findIndex((line) => line.trim() === "");
|
|
454
|
-
if (blank >= 0)
|
|
455
|
-
throw new Error(`Pi 的会话文件第 ${blank} 行是空行,无法安全复制,拒绝分叉:${sourcePath}`);
|
|
456
|
-
const unparseable = parsed.findIndex((entry) => entry === undefined);
|
|
457
|
-
if (unparseable >= 0)
|
|
458
|
-
throw new Error(`Pi 的会话文件第 ${unparseable} 行不是合法 JSON,这个文件的格式已经变了,拒绝分叉以免复制出一个 Pi 打不开的会话:${sourcePath}`);
|
|
459
|
-
const headerLines = parsed.filter((entry) => entry?.type === "session");
|
|
460
|
-
if (headerLines.length !== 1)
|
|
461
|
-
throw new Error(`Pi 的会话文件里有 ${headerLines.length} 个 header(应为 1 个),格式已经变了,拒绝分叉:${sourcePath}`);
|
|
462
|
-
if (parsed[0]?.type !== "session")
|
|
463
|
-
throw new Error(`Pi 的会话文件第一行不是 header(实际 type=${JSON.stringify(parsed[0]?.type ?? null)}),格式已经变了,拒绝分叉:${sourcePath}`);
|
|
464
|
-
// The one that matters most. Copying the wrong file would put one person's conversation into another's
|
|
465
|
-
// session, and unlike a wrong truncation it would be written as a brand-new file rather than destroying
|
|
466
|
-
// something - so this check is what stops a fork from being a disclosure.
|
|
467
|
-
if (parsed[0]?.id !== expectedSessionId)
|
|
468
|
-
throw new Error(`Pi 的会话文件属于会话 ${JSON.stringify(parsed[0]?.id ?? null)},不是要分叉的会话 ${JSON.stringify(expectedSessionId)}。拒绝复制别人的会话。`);
|
|
469
|
-
if (!raw.endsWith("\n"))
|
|
470
|
-
throw new Error(`Pi 的会话文件末尾没有换行,格式已经变了,拒绝分叉:${sourcePath}`);
|
|
471
|
-
// Every kept entry must point at another kept entry. The whole file is being copied, so "kept" is all of
|
|
472
|
-
// it - and a file that violates this is not a session Pi can open.
|
|
473
|
-
const known = new Set();
|
|
474
|
-
for (let index = 0; index < parsed.length; index += 1) {
|
|
475
|
-
const parent = parsed[index]?.parentId;
|
|
476
|
-
if (parent !== undefined && parent !== null && !known.has(parent))
|
|
477
|
-
throw new Error(`这个 Pi 会话记录不自洽:第 ${index} 行引用了不存在的 ${JSON.stringify(parent)},拒绝分叉以免复制出一个 Pi 打不开的会话:${sourcePath}`);
|
|
478
|
-
known.add(parsed[index]?.id);
|
|
479
|
-
}
|
|
480
|
-
// The header is REBUILT rather than string-patched, so the two changed fields are structural and
|
|
481
|
-
// everything else - including any field a future Pi adds - is carried across untouched. A regex over the
|
|
482
|
-
// first line would silently drop a field it did not recognise.
|
|
483
|
-
const header = { ...parsed[0], id: targetSessionId, parentSession: sourcePath };
|
|
484
|
-
const rewritten = [JSON.stringify(header), ...lines.slice(1)].join("\n") + "\n";
|
|
485
|
-
const temporaryPath = `${targetPath}.super-pi-fork-tmp`;
|
|
486
|
-
await writeFile(temporaryPath, rewritten, "utf8");
|
|
487
|
-
await rename(temporaryPath, targetPath);
|
|
488
|
-
// Re-read rather than trust the write. The claim this rests on is "a Pi spawned with the new id will
|
|
489
|
-
// open THIS file and find the whole history in it", and nothing downstream would report a failed write -
|
|
490
|
-
// the fork would simply appear to work and the model would start from nothing.
|
|
491
|
-
const verified = (await readFile(targetPath, "utf8")).split("\n").filter((line) => line !== "");
|
|
492
|
-
let verifiedHeader;
|
|
493
|
-
try {
|
|
494
|
-
verifiedHeader = JSON.parse(verified[0]);
|
|
495
|
-
}
|
|
496
|
-
catch {
|
|
497
|
-
verifiedHeader = undefined;
|
|
498
|
-
}
|
|
499
|
-
if (verified.length !== lines.length || verifiedHeader?.id !== targetSessionId) {
|
|
500
|
-
throw new Error(`写入后的 Pi 会话文件不是预期的样子(写到 ${verified.length} 行,期望 ${lines.length} 行,header id=${JSON.stringify(verifiedHeader?.id ?? null)},期望 ${JSON.stringify(targetSessionId)})。已放弃,没有创建任何会话。`);
|
|
501
|
-
}
|
|
502
|
-
return { entriesCopied: lines.length - 1 };
|
|
503
|
-
}
|
|
173
|
+
// Session-file mutation and validation live behind this boundary; runtime only locates Pi sessions.
|
|
504
174
|
/** How long one command may take before the turn is failed rather than left hanging. */
|
|
505
175
|
const SESSION_COMMAND_TIMEOUT_MS = 10_000;
|
|
506
176
|
/** `get_state`/`get_entries` on an idle Pi. Generous, because it only has to answer a question. */
|
|
@@ -725,7 +395,9 @@ export class PiRpcRuntime {
|
|
|
725
395
|
[TASK_CAPABILITY_ENV]: mintCapability(conversationId),
|
|
726
396
|
};
|
|
727
397
|
}
|
|
728
|
-
async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, images, memoryReview, memorySourceConversationId }) {
|
|
398
|
+
async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, images, memoryReview, memorySourceConversationId }) {
|
|
399
|
+
const latencyStartedAt = performance.now();
|
|
400
|
+
traceLatency(conversation.id, "answer.begin", latencyStartedAt);
|
|
729
401
|
await mkdir(this.sessionDir, { recursive: true });
|
|
730
402
|
// Normalize first: the picker can hand over forward slashes or a trailing separator, and `resolve`
|
|
731
403
|
// turns both into one absolute native path. The directory must exist on disk before spawn - a
|
|
@@ -788,6 +460,7 @@ export class PiRpcRuntime {
|
|
|
788
460
|
});
|
|
789
461
|
const channel = new RpcChannel(child);
|
|
790
462
|
this.runs.set(conversation.id, { child, channel });
|
|
463
|
+
traceLatency(conversation.id, "pi.spawned", latencyStartedAt, `pid=${child.pid ?? "unknown"}`);
|
|
791
464
|
// Same guard as subagents.ts: a destroyed stdin throws synchronously on write, and an abort
|
|
792
465
|
// listener that throws inside a listener would surface as an unhandled error instead of a cancel.
|
|
793
466
|
const abort = () => {
|
|
@@ -835,16 +508,21 @@ export class PiRpcRuntime {
|
|
|
835
508
|
}
|
|
836
509
|
// Resumed Pi sessions can carry a different model than the global default.
|
|
837
510
|
// Check the real get_state result as well, before any prompt reaches the model.
|
|
838
|
-
if (this.modelValidator) {
|
|
511
|
+
if (this.modelValidator || onSessionFile) {
|
|
839
512
|
const actual = await channel.request({ id: "policy_state", type: "get_state" }, SESSION_COMMAND_TIMEOUT_MS);
|
|
840
513
|
const model = actual.data?.model;
|
|
841
|
-
await this.modelValidator(validationId, model?.provider && model.id ? { provider: model.provider, modelId: model.id, label: model.name ?? model.id } : undefined);
|
|
514
|
+
await this.modelValidator?.(validationId, model?.provider && model.id ? { provider: model.provider, modelId: model.id, label: model.name ?? model.id } : undefined);
|
|
515
|
+
const sessionFile = actual.data?.sessionFile;
|
|
516
|
+
if (typeof sessionFile === "string" && sessionFile)
|
|
517
|
+
await onSessionFile?.(sessionFile);
|
|
842
518
|
}
|
|
843
519
|
const imagePayloads = this.options.uploadsDir && images?.length
|
|
844
520
|
? await loadConversationImages(this.options.uploadsDir, conversation.id, images)
|
|
845
521
|
: undefined;
|
|
846
522
|
channel.write({ id: "prompt", type: "prompt", message: prompt, ...(imagePayloads?.length ? { images: imagePayloads } : {}) });
|
|
523
|
+
traceLatency(conversation.id, "prompt.sent", latencyStartedAt);
|
|
847
524
|
let settled = false;
|
|
525
|
+
let firstPiRecord = true;
|
|
848
526
|
// Nothing may be dropped without a trace. Pi emits a wide, open-ended family of agent
|
|
849
527
|
// session events (tool_call, thinking, compaction_*, auto_retry_*, ...), so an unhandled
|
|
850
528
|
// record is announced once per distinct type and then tallied instead of logging per event.
|
|
@@ -875,6 +553,10 @@ export class PiRpcRuntime {
|
|
|
875
553
|
const record = await channel.next(100);
|
|
876
554
|
if (!record)
|
|
877
555
|
break;
|
|
556
|
+
if (firstPiRecord) {
|
|
557
|
+
firstPiRecord = false;
|
|
558
|
+
traceLatency(conversation.id, "pi.first-record", latencyStartedAt, `type=${record.type}`);
|
|
559
|
+
}
|
|
878
560
|
if (record.type === "response" && record.id === "prompt" && record.success === false)
|
|
879
561
|
throw new Error(record.error ?? "Pi rejected the prompt");
|
|
880
562
|
// A `steer` written while this turn was running, answered here because this loop is the only
|
|
@@ -1002,7 +684,6 @@ export class PiRpcRuntime {
|
|
|
1002
684
|
}
|
|
1003
685
|
if (record.type === "message_update") {
|
|
1004
686
|
const event = record.assistantMessageEvent;
|
|
1005
|
-
console.log(`[runtime] message_update event.type=${JSON.stringify(event?.type)}`);
|
|
1006
687
|
// text_delta is what drives the visible answer, and toolcall_start/delta/end are what make a
|
|
1007
688
|
// call VISIBLE WHILE IT IS BEING WRITTEN.
|
|
1008
689
|
//
|
|
@@ -1031,14 +712,18 @@ export class PiRpcRuntime {
|
|
|
1031
712
|
// between a `read`'s path and a `bash`'s command.
|
|
1032
713
|
const name = toolNames.get(event.contentIndex) ?? "";
|
|
1033
714
|
argumentText.set(event.contentIndex, `${argumentText.get(event.contentIndex) ?? ""}${delta}`);
|
|
1034
|
-
|
|
1035
|
-
|
|
715
|
+
// WHAT ARRIVES IS A FRAGMENT; WHAT IS HANDED ON IS THE TEXT SO FAR. The same field on the
|
|
716
|
+
// caller's side receives `tool_execution_update`'s output, and Pi sends THAT already
|
|
717
|
+
// accumulated - so a caller storing both verbatim would have to append one kind of text and
|
|
718
|
+
// replace the other. Accumulating here, where the fragments already are, gives it one kind.
|
|
719
|
+
const text = argumentText.get(event.contentIndex) ?? "";
|
|
720
|
+
const detail = describeToolCallInProgress(name, text);
|
|
721
|
+
onToolPreparing?.({ contentIndex: event.contentIndex, name, ...(text ? { live: text } : {}), ...(detail ? { detail } : {}) });
|
|
1036
722
|
}
|
|
1037
723
|
else if (event?.type === "thinking_delta")
|
|
1038
724
|
onThinking?.(typeof event.delta === "string" ? event.delta : "");
|
|
1039
725
|
else {
|
|
1040
726
|
noteUnhandled(`message_update:${JSON.stringify(event?.type ?? "(missing)")}`);
|
|
1041
|
-
console.log(`[runtime] message_update not matched: event.type=${JSON.stringify(event?.type)}`);
|
|
1042
727
|
}
|
|
1043
728
|
continue;
|
|
1044
729
|
}
|
|
@@ -1055,12 +740,13 @@ export class PiRpcRuntime {
|
|
|
1055
740
|
throw new Error(`Pi extension error in ${record.extensionPath ?? "unknown path"} (${record.event ?? "unknown event"}): ${record.error ?? "unknown error"}`);
|
|
1056
741
|
}
|
|
1057
742
|
if (record.type === "agent_settled") {
|
|
743
|
+
traceLatency(conversation.id, "pi.settled", latencyStartedAt);
|
|
1058
744
|
settled = true;
|
|
1059
745
|
continue;
|
|
1060
746
|
}
|
|
1061
747
|
noteUnhandled(`type ${JSON.stringify(record.type ?? "(missing type)")}`);
|
|
1062
748
|
}
|
|
1063
|
-
if (!settled &&
|
|
749
|
+
if (!settled && channel.closed)
|
|
1064
750
|
throw new Error(`Pi RPC exited before settling (code ${child.exitCode}). ${channel.stderr.trim()}`);
|
|
1065
751
|
}
|
|
1066
752
|
for (const [key, count] of repeats)
|
|
@@ -1209,6 +895,21 @@ export class PiRpcRuntime {
|
|
|
1209
895
|
throw new Error("这个会话还有一轮任务在跑,先停止它再回退");
|
|
1210
896
|
if (!this.pi.found)
|
|
1211
897
|
throw new Error(this.pi.reason);
|
|
898
|
+
// Normal path: this exact worker already asked Pi for get_state before its prompt was sent, so reuse
|
|
899
|
+
// that locator. `truncateSessionFile` below still proves the header belongs to this conversation and
|
|
900
|
+
// validates the requested boundary and parent chain before a byte is changed. The cache removes an
|
|
901
|
+
// otherwise pointless second Pi startup from every ordinary revert.
|
|
902
|
+
const cached = conversation.piSessionFile;
|
|
903
|
+
if (cached) {
|
|
904
|
+
const exists = await stat(cached).catch(() => undefined);
|
|
905
|
+
if (exists?.isFile()) {
|
|
906
|
+
const result = await truncateSessionFile(cached, boundaryEntryId, conversation.id, beforeBoundary);
|
|
907
|
+
return { sessionFile: cached, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
// Compatibility fallback for conversations created before the live-worker locator existed, or a
|
|
911
|
+
// transcript that was moved since the last turn. This retains Pi's authoritative lookup rather than
|
|
912
|
+
// guessing from filenames.
|
|
1212
913
|
const childCwd = resolve(cwd);
|
|
1213
914
|
const info = await stat(childCwd).catch(() => undefined);
|
|
1214
915
|
if (!info?.isDirectory())
|
|
@@ -1305,6 +1006,13 @@ export class PiRpcRuntime {
|
|
|
1305
1006
|
return { entriesCopied: 0 };
|
|
1306
1007
|
if (!this.pi.found)
|
|
1307
1008
|
throw new Error(this.pi.reason);
|
|
1009
|
+
const cached = conversation.piSessionFile;
|
|
1010
|
+
if (cached && (await stat(cached).catch(() => undefined))?.isFile()) {
|
|
1011
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
|
1012
|
+
const target = join(this.sessionDir, `${stamp}_${forkedConversationId}.jsonl`);
|
|
1013
|
+
const result = await forkSessionFile(cached, target, conversation.id, forkedConversationId);
|
|
1014
|
+
return { sessionFile: target, entriesCopied: result.entriesCopied };
|
|
1015
|
+
}
|
|
1308
1016
|
const childCwd = resolve(cwd);
|
|
1309
1017
|
const info = await stat(childCwd).catch(() => undefined);
|
|
1310
1018
|
if (!info?.isDirectory())
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cuts a Pi session file down to the header plus everything up to and including `boundaryEntryId`.
|
|
3
|
+
*
|
|
4
|
+
* ============================ WHAT THIS DESTROYS ============================
|
|
5
|
+
* **Every entry written after the boundary is gone from disk, permanently, and there is no way to
|
|
6
|
+
* reconstruct it from this file.** That includes not only the messages a person can see but also Pi's
|
|
7
|
+
* `tool_call` records, `compaction` summaries, `model_change` and `thinking_level_change` markers, usage
|
|
8
|
+
* accounting, and anything a future Pi version adds. This is the step the whole revert design is built
|
|
9
|
+
* around (research §4.2) and the step that cannot be undone by the application - Super Pi has no undo,
|
|
10
|
+
* and adding one was explicitly left out of scope.
|
|
11
|
+
*
|
|
12
|
+
* It does NOT touch the workspace. No file the agent read or wrote is read, written or reverted here.
|
|
13
|
+
* =============================================================================
|
|
14
|
+
*
|
|
15
|
+
* WHY TRUNCATE RATHER THAN ASK PI. Pi's `SessionManager` is append-only by design
|
|
16
|
+
* (`session-manager.ts:1516-1519`: "Entries cannot be modified or deleted"), and its in-session rewind -
|
|
17
|
+
* `branch()`, `navigateTree()` - is IN-MEMORY ONLY (`:1573-1584`): `_buildIndex()` re-reads the whole
|
|
18
|
+
* file on every `open()` and sets the leaf to the last line unconditionally (`:1103-1122`). Super Pi
|
|
19
|
+
* spawns one Pi per turn and kills it afterwards, so a leaf pointer set in one turn is gone before the
|
|
20
|
+
* next one starts - a "call `branch()` once" solution would look correct for a turn and then silently
|
|
21
|
+
* forget (research §5 pitfall 2). Truncating the file is the only version of this that is a durable state
|
|
22
|
+
* change rather than per-turn bookkeeping that can be skipped exactly once.
|
|
23
|
+
*
|
|
24
|
+
* WHY THE FORMAT IS ASSUMED RATHER THAN GUESSED. The shape this relies on - line 0 is the one and only
|
|
25
|
+
* `type: "session"` header, every other line is one self-contained JSON entry, every entry's `parentId`
|
|
26
|
+
* already appears earlier in the file, no blank lines, and a trailing newline - was measured against
|
|
27
|
+
* real Pi by scripts/probe-revert-session-format.mjs, which also proves the `parentId` property that
|
|
28
|
+
* makes a prefix a VALID session rather than a fragment. That probe is the reason this function may
|
|
29
|
+
* index by line at all; every one of those claims is re-checked here before anything is written, so a
|
|
30
|
+
* Pi that changed the format fails loudly instead of producing a file Pi cannot open.
|
|
31
|
+
*
|
|
32
|
+
* WHY THE WRITE IS TEMP-THEN-RENAME. Pi appends with a bare `appendFileSync`
|
|
33
|
+
* (`session-manager.ts:1187`) and makes no atomicity guarantee of its own. Writing the truncated copy
|
|
34
|
+
* under a different name and renaming it over the original means a reader never observes a half-written
|
|
35
|
+
* file, and a failure before the rename leaves the original untouched.
|
|
36
|
+
*
|
|
37
|
+
* WHY A BACKUP IS KEPT. `.super-pi-revert-backup` is written next to the session file, deliberately not
|
|
38
|
+
* named `*.jsonl` so Pi's own discovery (`readdirSync` filtered on that suffix,
|
|
39
|
+
* `session-manager.ts:1881`) never lists it, and deliberately NOT surfaced anywhere as a feature:
|
|
40
|
+
* Super Pi has no undo and does not pretend to. It exists so that an irreversible, unrecoverable-by-code
|
|
41
|
+
* operation on a person's conversation history is at least recoverable by hand.
|
|
42
|
+
*
|
|
43
|
+
* EXPORTED so it can be tested against a fabricated session file in a temp directory, without Pi and
|
|
44
|
+
* without touching anybody's real transcript.
|
|
45
|
+
*/
|
|
46
|
+
export declare function truncateSessionFile(path: string, boundaryEntryId: string, expectedSessionId: string, beforeBoundary?: boolean): Promise<{
|
|
47
|
+
linesKept: number;
|
|
48
|
+
entriesDropped: number;
|
|
49
|
+
}>;
|
|
50
|
+
/**
|
|
51
|
+
* Writes a second Pi session file that begins as an EXACT copy of one, under a different session id.
|
|
52
|
+
*
|
|
53
|
+
* ============================ WHAT THIS DOES TO THE ORIGINAL ============================
|
|
54
|
+
* **Nothing.** The source file is read and never written, never truncated, never renamed. This is the
|
|
55
|
+
* whole difference from `truncateSessionFile`, and it is the reason a fork is the safe half of this pair
|
|
56
|
+
* of operations: a revert destroys entries permanently and keeps a hand-recovery backup, while a fork
|
|
57
|
+
* leaves both files whole and keeps no backup because there is nothing to recover.
|
|
58
|
+
* ========================================================================================
|
|
59
|
+
*
|
|
60
|
+
* WHY A COPY AND NOT PI'S `fork()`. Covered at length on `ClientCommand`'s `conversation.fork`: Pi's fork
|
|
61
|
+
* always tears down the runtime and switches to the new session (`agent-session-runtime.ts:299-307`), and
|
|
62
|
+
* Super Pi has no runtime to switch - it spawns one Pi per turn and kills it. The file is the only durable
|
|
63
|
+
* thing a fork has to produce here, so the file is what gets produced.
|
|
64
|
+
*
|
|
65
|
+
* ============================ WHAT THIS DOES TO PI's ENTRY IDS ============================
|
|
66
|
+
* **Nothing either, and this is a deliberate divergence from Pi's own `createBranchedSession`, which
|
|
67
|
+
* RENUMBERS `parentId` (`:1655-1666`).** Keeping every id verbatim means every cross-reference already
|
|
68
|
+
* inside the file stays valid, and research pitfall 6 is exactly why that matters: a Pi entry may point
|
|
69
|
+
* at other entries by id in ways this code has never enumerated - a `compaction` record carries a
|
|
70
|
+
* `firstKeptEntryId`, `branch_summary` carries the branch point - and renumbering ids means rewriting every
|
|
71
|
+
* one of those references correctly, with no way to find them all. Copying the bytes cannot break a
|
|
72
|
+
* reference because it changes nothing a reference points at. The cost is that the same entry id now
|
|
73
|
+
* exists in two files; that is safe because `_buildIndex` builds its `byId` map per file (`:1103-1122`),
|
|
74
|
+
* and Super Pi only ever opens one file per process.
|
|
75
|
+
*
|
|
76
|
+
* WHAT THE HEADER CHANGES, and it is two fields. `id` becomes the new conversation's id, because that is
|
|
77
|
+
* what Pi's lookup matches (`findLocalSessionByExactId` reads the header, probe B) and therefore what makes
|
|
78
|
+
* `--session-id <forked>` open THIS file rather than silently inventing an empty session with that id
|
|
79
|
+
* (`main.ts:436-448`, measured: `Warning: No project session found with id '...'`). And `parentSession`
|
|
80
|
+
* records the file this one came from, which is the same one-line lineage pointer Pi itself writes
|
|
81
|
+
* (`:1675-1682`) and is not a link - the files share nothing. `cwd`, `version` and `timestamp` are copied
|
|
82
|
+
* verbatim: `cwd` MUST be identical, because Pi's lookup requires the recorded cwd to equal the one it is
|
|
83
|
+
* asked from, and the fork always lives in the same workspace as its source.
|
|
84
|
+
*
|
|
85
|
+
* THE SAME FORMAT CHECKS AS TRUNCATE, AND FOR THE SAME REASON. Probe A measured this file's shape against
|
|
86
|
+
* real Pi (`scripts/probe-revert-session-format.mjs`); a Pi upgrade can invalidate any of those claims, so
|
|
87
|
+
* every one is re-checked before a byte is written and a failure names the shape actually found. Here the
|
|
88
|
+
* `parentId` property is checked across the WHOLE file rather than across a kept prefix, because the whole
|
|
89
|
+
* file is what is being copied.
|
|
90
|
+
*
|
|
91
|
+
* TEMP-THEN-RENAME, because Pi appends with a bare `appendFileSync` (`:1187`) and the target must never be
|
|
92
|
+
* observed half-written - a half-written file under the forked id is precisely the "Pi opens an empty
|
|
93
|
+
* session and the history has vanished with no error anywhere" outcome probe B was about.
|
|
94
|
+
*
|
|
95
|
+
* EXPORTED so it can be tested against fabricated session files in a temp directory, without Pi.
|
|
96
|
+
*/
|
|
97
|
+
export declare function forkSessionFile(sourcePath: string, targetPath: string, expectedSessionId: string, targetSessionId: string): Promise<{
|
|
98
|
+
entriesCopied: number;
|
|
99
|
+
}>;
|