@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/client/index.html
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<link rel="alternate icon" href="/favicon.ico" />
|
|
9
9
|
<link rel="apple-touch-icon" href="/favicon.svg" />
|
|
10
10
|
<title>Super Pi</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-CpHVxuB1.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CKdlMXJt.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -334,6 +334,32 @@ export class RunService {
|
|
|
334
334
|
this.extensionUiService.cancelPendingUi(conversation.id, "expired");
|
|
335
335
|
let toolCalls = [];
|
|
336
336
|
let pendingTools = Promise.resolve();
|
|
337
|
+
// Live events are independent from durable checkpoints. Persisting every token made the stdout
|
|
338
|
+
// reader wait for one disk transaction per token; over a remote browser connection that backlog
|
|
339
|
+
// looked exactly like a slow model. Keep the in-memory transcript current, publish immediately, and
|
|
340
|
+
// checkpoint at most once per short window plus unconditionally at the end of the turn.
|
|
341
|
+
let checkpointTimer;
|
|
342
|
+
let checkpointPending = false;
|
|
343
|
+
const checkpointNow = () => {
|
|
344
|
+
if (checkpointTimer !== undefined) {
|
|
345
|
+
clearTimeout(checkpointTimer);
|
|
346
|
+
checkpointTimer = undefined;
|
|
347
|
+
}
|
|
348
|
+
if (!checkpointPending)
|
|
349
|
+
return;
|
|
350
|
+
checkpointPending = false;
|
|
351
|
+
// Tool updates and message checkpoints both replace the persisted message. They share this lane
|
|
352
|
+
// so an earlier text snapshot cannot commit after a newer tool state and erase it on disk.
|
|
353
|
+
pendingTools = pendingTools.then(() => this.store.checkpointMessage(conversation.id, assistant.id)).catch((error) => {
|
|
354
|
+
console.warn(`Super Pi: streaming checkpoint failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
const scheduleCheckpoint = () => {
|
|
358
|
+
checkpointPending = true;
|
|
359
|
+
if (checkpointTimer !== undefined)
|
|
360
|
+
return;
|
|
361
|
+
checkpointTimer = setTimeout(checkpointNow, 50);
|
|
362
|
+
};
|
|
337
363
|
const enqueueTools = (what, work) => {
|
|
338
364
|
pendingTools = pendingTools.then(async () => {
|
|
339
365
|
toolCalls = await work();
|
|
@@ -387,9 +413,11 @@ export class RunService {
|
|
|
387
413
|
if (existing !== undefined) {
|
|
388
414
|
// The line naming the call IMPROVES as the arguments arrive - a `read` learns its path from the
|
|
389
415
|
// first fragment - so it is patched rather than frozen at whatever the first frame could say. The
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
416
|
+
// text is patched as well, and it is the WHOLE text written so far rather than the newest
|
|
417
|
+
// fragment: it is the same field the execution output later lands in, and the reader watches it
|
|
418
|
+
// grow in one piece either way.
|
|
419
|
+
if (call.live)
|
|
420
|
+
enqueueTools("a tool call argument", () => this.store.updateToolCall(conversation.id, assistant.id, existing.row, { live: call.live, ...(call.detail ? { detail: call.detail } : {}) }));
|
|
393
421
|
return;
|
|
394
422
|
}
|
|
395
423
|
// No name yet is the normal first token, and it is NOT a reason to wait: the block appearing with an
|
|
@@ -397,6 +425,13 @@ export class RunService {
|
|
|
397
425
|
// all, and it fills in on the next delta.
|
|
398
426
|
const at = spoken;
|
|
399
427
|
const row = rows++;
|
|
428
|
+
// THE OFFSET IS TAKEN NOW, NOT WHEN THE QUEUED APPEND RUNS. `thought` is the LIVE counter, and the
|
|
429
|
+
// append below runs when the enqueue chain drains - by which time it may already hold the whole
|
|
430
|
+
// turn's reasoning, so a call made during reasoning would anchor at the END of the text it was made
|
|
431
|
+
// inside of and render outside the thinking block. `at` above is captured for exactly this reason;
|
|
432
|
+
// this one was not, and the JSON persistence backend's slower writes widen the window enough for
|
|
433
|
+
// the browser test to catch the difference between capturing and reading.
|
|
434
|
+
const thinkingAt = thought;
|
|
400
435
|
// The block says WHICH TOOL and WHAT IT IS AIMED AT as soon as either is known. 「工具」 as the name
|
|
401
436
|
// and 「正在生成参数…」 as the only line below it told a reader nothing for the seconds a large `edit`
|
|
402
437
|
// spends writing its replacement text - and by the time the file path was on screen, the block looked
|
|
@@ -407,7 +442,7 @@ export class RunService {
|
|
|
407
442
|
...(call.detail ? { detail: call.detail } : { detail: "正在生成参数…" }),
|
|
408
443
|
preparing: true,
|
|
409
444
|
at,
|
|
410
|
-
thinkingAt
|
|
445
|
+
thinkingAt,
|
|
411
446
|
}));
|
|
412
447
|
preparingRows.set(call.contentIndex, { row, name: call.name });
|
|
413
448
|
};
|
|
@@ -439,7 +474,10 @@ export class RunService {
|
|
|
439
474
|
const row = rows++;
|
|
440
475
|
if (call.id)
|
|
441
476
|
rowByToolCallId.set(call.id, row);
|
|
442
|
-
|
|
477
|
+
// Captured for the same reason as the preparing path above: the queued append must anchor the call
|
|
478
|
+
// where it was MADE, not where the queue happened to be when it drained.
|
|
479
|
+
const thinkingAt = thought;
|
|
480
|
+
enqueueTools("a tool call", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name, detail: call.detail, args: call.args, at, thinkingAt, ...(call.question ? { question: call.question } : {}) }));
|
|
443
481
|
};
|
|
444
482
|
const onToolUpdate = (call) => {
|
|
445
483
|
const index = call.id ? rowByToolCallId.get(call.id) : undefined;
|
|
@@ -453,33 +491,47 @@ export class RunService {
|
|
|
453
491
|
return;
|
|
454
492
|
enqueueTools("a tool call outcome", () => this.store.settleToolCall(conversation.id, assistant.id, index, { status: call.status, durationMs: call.durationMs, ...(call.result ? { result: call.result } : {}) }));
|
|
455
493
|
};
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
494
|
+
try {
|
|
495
|
+
for await (const delta of this.runtime.answer({
|
|
496
|
+
conversation,
|
|
497
|
+
prompt,
|
|
498
|
+
signal: controller.signal,
|
|
499
|
+
cwd: workspace.path,
|
|
500
|
+
host: this.extensionUiService.createHost(conversation.id),
|
|
501
|
+
onPiQueue: (snapshot) => this.onPiQueue(conversation.id, snapshot),
|
|
502
|
+
onUserEntry: (entryId) => this.store.setMessagePiEntryId(conversation.id, userMessageId, entryId),
|
|
503
|
+
onSessionFile: (path) => this.store.setConversationPiSessionFile(conversation.id, path),
|
|
504
|
+
onToolCall,
|
|
505
|
+
onToolPreparing,
|
|
506
|
+
onToolUpdate,
|
|
507
|
+
onToolEnd,
|
|
508
|
+
// BROADCAST AND KEEP. It used to only broadcast, into an event no one handled - so the reasoning
|
|
509
|
+
// reached nobody, live or afterwards. Both halves now happen: the browser draws the delta as it
|
|
510
|
+
// arrives, and the message keeps it so the finished turn has something to go back to.
|
|
511
|
+
onThinking: (delta) => {
|
|
512
|
+
thought += delta.length;
|
|
513
|
+
this.store.appendThinkingDeltaLive(conversation.id, assistant.id, delta);
|
|
514
|
+
this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true });
|
|
515
|
+
scheduleCheckpoint();
|
|
516
|
+
},
|
|
517
|
+
images,
|
|
518
|
+
})) {
|
|
519
|
+
this.store.appendAssistantDeltaLive(conversation.id, assistant.id, delta);
|
|
520
|
+
spoken += delta.length;
|
|
521
|
+
// Tool events are structural transcript boundaries. Publish any boundary Pi announced before
|
|
522
|
+
// this text before publishing the text itself; this is a handful of writes per call, never one
|
|
523
|
+
// write per token, and preserves the event order a reconnecting client reconstructs.
|
|
524
|
+
await pendingTools;
|
|
525
|
+
this.broadcaster.broadcast({ type: "message.delta", conversationId: conversation.id, messageId: assistant.id, delta });
|
|
526
|
+
scheduleCheckpoint();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
finally {
|
|
530
|
+
// A failed or cancelled producer still owns live text and a checkpoint timer.
|
|
531
|
+
// Drain the lane before the caller changes status or permits another turn.
|
|
532
|
+
checkpointNow();
|
|
533
|
+
await pendingTools;
|
|
481
534
|
}
|
|
482
|
-
await pendingTools;
|
|
483
535
|
if (!controller.signal.aborted) {
|
|
484
536
|
const completed = this.store.snapshot().conversations.find(c => c.id === conversation.id);
|
|
485
537
|
if (completed)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
/** Open-ended Pi RPC record; consumers validate fields before use. */
|
|
3
|
+
export type RpcRecord = {
|
|
4
|
+
type?: string;
|
|
5
|
+
id?: string;
|
|
6
|
+
success?: boolean;
|
|
7
|
+
error?: string;
|
|
8
|
+
/** Every `response` record's payload. Kept opaque: only the catalog reader knows these shapes. */
|
|
9
|
+
data?: unknown;
|
|
10
|
+
/**
|
|
11
|
+
* `tool_execution_end` carries the tool's OUTPUT. Declared so it can be REDUCED by
|
|
12
|
+
* extractToolResultText and then dropped: the raw object must never travel past this record, and that
|
|
13
|
+
* reduction is the whole reason the result is safe to persist. Kept opaque for the same reason `data` is.
|
|
14
|
+
*/
|
|
15
|
+
result?: unknown;
|
|
16
|
+
/** The tool_execution_update record's running output. Reduced by the same function as `result`, never carried raw. */
|
|
17
|
+
partialResult?: unknown;
|
|
18
|
+
/**
|
|
19
|
+
* The inner event of a `message_update`. `message` rides on `done` - the authoritative final
|
|
20
|
+
* assistant message, with its text content, its stop reason and its model (pi-ai AssistantMessageEvent).
|
|
21
|
+
*/
|
|
22
|
+
assistantMessageEvent?: {
|
|
23
|
+
type?: string;
|
|
24
|
+
delta?: string;
|
|
25
|
+
contentIndex?: number;
|
|
26
|
+
partial?: unknown;
|
|
27
|
+
reason?: string;
|
|
28
|
+
message?: {
|
|
29
|
+
role?: string;
|
|
30
|
+
content?: unknown;
|
|
31
|
+
stopReason?: string;
|
|
32
|
+
model?: string;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Cumulative usage of the CURRENT assistant message, repeated on every one of that message's updates
|
|
37
|
+
* (modes/json-event.ts:56-59). Reading it anywhere but on `done` counts the same tokens per token.
|
|
38
|
+
*/
|
|
39
|
+
usage?: {
|
|
40
|
+
input?: number;
|
|
41
|
+
output?: number;
|
|
42
|
+
cacheRead?: number;
|
|
43
|
+
cacheWrite?: number;
|
|
44
|
+
cost?: {
|
|
45
|
+
total?: number;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
toolCallId?: string;
|
|
49
|
+
toolName?: string;
|
|
50
|
+
args?: unknown;
|
|
51
|
+
/**
|
|
52
|
+
* tool_execution_end payload. `result` is NOT declared: it is deliberately never read, because for a
|
|
53
|
+
* `read` it is the file's contents and this type is the one place a reader would reach for it. The two
|
|
54
|
+
* numbers below are the whole of what a human-readable tool row can honestly report.
|
|
55
|
+
*/
|
|
56
|
+
isError?: boolean;
|
|
57
|
+
durationMs?: number;
|
|
58
|
+
method?: string;
|
|
59
|
+
title?: string;
|
|
60
|
+
options?: unknown;
|
|
61
|
+
message?: string;
|
|
62
|
+
placeholder?: string;
|
|
63
|
+
prefill?: string;
|
|
64
|
+
timeout?: number;
|
|
65
|
+
notifyType?: string;
|
|
66
|
+
statusKey?: string;
|
|
67
|
+
statusText?: string;
|
|
68
|
+
widgetKey?: string;
|
|
69
|
+
widgetLines?: unknown;
|
|
70
|
+
widgetPlacement?: string;
|
|
71
|
+
text?: string;
|
|
72
|
+
extensionPath?: string;
|
|
73
|
+
event?: string;
|
|
74
|
+
steering?: unknown;
|
|
75
|
+
followUp?: unknown;
|
|
76
|
+
};
|
|
77
|
+
/** One Pi child, its stdout reader, and the single writer to its stdin. */
|
|
78
|
+
export declare class RpcChannel {
|
|
79
|
+
private readonly child;
|
|
80
|
+
private readonly records;
|
|
81
|
+
/** See `stash`: a drain loop must not discard another command's response. */
|
|
82
|
+
private readonly stashed;
|
|
83
|
+
private readonly waiters;
|
|
84
|
+
private buffer;
|
|
85
|
+
private exited;
|
|
86
|
+
private exitReason;
|
|
87
|
+
/** stderr is kept only to explain an early exit; a well-behaved Pi writes nothing here. */
|
|
88
|
+
stderr: string;
|
|
89
|
+
constructor(child: ChildProcessWithoutNullStreams);
|
|
90
|
+
get exitCode(): number | null;
|
|
91
|
+
get closed(): boolean;
|
|
92
|
+
private wake;
|
|
93
|
+
/** Waits for stdout or process exit without adding a fixed polling delay to every record. */
|
|
94
|
+
private changed;
|
|
95
|
+
/** Writes one JSONL command. Throws rather than silently dropping: a command Pi never sees is a lie the caller would act on. */
|
|
96
|
+
write(command: object): void;
|
|
97
|
+
/** Returns a response to its waiter after the drain loop encountered it. */
|
|
98
|
+
stash(record: RpcRecord): void;
|
|
99
|
+
/** Removes and returns the next record, or undefined after `timeoutMs` with nothing to read. */
|
|
100
|
+
next(timeoutMs?: number): Promise<RpcRecord | undefined>;
|
|
101
|
+
/** Sends a command and waits for its response. For one command; use write()+waitFor() to overlap several. */
|
|
102
|
+
request(command: {
|
|
103
|
+
id: string;
|
|
104
|
+
type: string;
|
|
105
|
+
} & Record<string, unknown>, timeoutMs: number): Promise<RpcRecord>;
|
|
106
|
+
private take;
|
|
107
|
+
/** Waits for one correlated response while leaving every unrelated record in order. */
|
|
108
|
+
waitFor(id: string, timeoutMs: number, what?: string): Promise<RpcRecord>;
|
|
109
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** One Pi child, its stdout reader, and the single writer to its stdin. */
|
|
2
|
+
export class RpcChannel {
|
|
3
|
+
child;
|
|
4
|
+
records = [];
|
|
5
|
+
/** See `stash`: a drain loop must not discard another command's response. */
|
|
6
|
+
stashed = new Map();
|
|
7
|
+
waiters = new Set();
|
|
8
|
+
buffer = "";
|
|
9
|
+
exited = false;
|
|
10
|
+
exitReason = "";
|
|
11
|
+
/** stderr is kept only to explain an early exit; a well-behaved Pi writes nothing here. */
|
|
12
|
+
stderr = "";
|
|
13
|
+
constructor(child) {
|
|
14
|
+
this.child = child;
|
|
15
|
+
child.stdout.setEncoding("utf8");
|
|
16
|
+
child.stderr.setEncoding("utf8");
|
|
17
|
+
child.stderr.on("data", (chunk) => { this.stderr += chunk.toString(); });
|
|
18
|
+
child.stdout.on("data", (chunk) => {
|
|
19
|
+
this.buffer += chunk.toString("utf8");
|
|
20
|
+
let changed = false;
|
|
21
|
+
let newline;
|
|
22
|
+
while ((newline = this.buffer.indexOf("\n")) >= 0) {
|
|
23
|
+
const line = this.buffer.slice(0, newline).replace(/\r$/, "");
|
|
24
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
25
|
+
if (!line)
|
|
26
|
+
continue;
|
|
27
|
+
try {
|
|
28
|
+
this.records.push(JSON.parse(line));
|
|
29
|
+
changed = true;
|
|
30
|
+
}
|
|
31
|
+
catch { /* Pi stdout must be JSONL */ }
|
|
32
|
+
}
|
|
33
|
+
if (changed)
|
|
34
|
+
this.wake();
|
|
35
|
+
});
|
|
36
|
+
child.once("exit", (code, signal) => {
|
|
37
|
+
this.exited = true;
|
|
38
|
+
this.exitReason = signal ? `signal ${signal}` : `code ${code}`;
|
|
39
|
+
this.wake();
|
|
40
|
+
});
|
|
41
|
+
child.once("error", (error) => {
|
|
42
|
+
this.exited = true;
|
|
43
|
+
this.exitReason = error.message;
|
|
44
|
+
this.wake();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
get exitCode() { return this.child.exitCode; }
|
|
48
|
+
get closed() { return this.exited || this.child.exitCode !== null; }
|
|
49
|
+
wake() {
|
|
50
|
+
for (const resolve of this.waiters)
|
|
51
|
+
resolve();
|
|
52
|
+
this.waiters.clear();
|
|
53
|
+
}
|
|
54
|
+
/** Waits for stdout or process exit without adding a fixed polling delay to every record. */
|
|
55
|
+
changed(timeoutMs) {
|
|
56
|
+
if (timeoutMs <= 0)
|
|
57
|
+
return Promise.resolve();
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
const finish = () => { clearTimeout(timer); this.waiters.delete(finish); resolve(); };
|
|
60
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
61
|
+
this.waiters.add(finish);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Writes one JSONL command. Throws rather than silently dropping: a command Pi never sees is a lie the caller would act on. */
|
|
65
|
+
write(command) {
|
|
66
|
+
if (this.child.killed || this.child.stdin.destroyed)
|
|
67
|
+
throw new Error(`Pi RPC stdin is closed, so ${JSON.stringify(command)} was not sent`);
|
|
68
|
+
this.child.stdin.write(`${JSON.stringify(command)}\n`);
|
|
69
|
+
}
|
|
70
|
+
/** Returns a response to its waiter after the drain loop encountered it. */
|
|
71
|
+
stash(record) {
|
|
72
|
+
if (record.type === "response" && typeof record.id === "string") {
|
|
73
|
+
this.stashed.set(record.id, record);
|
|
74
|
+
this.wake();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Removes and returns the next record, or undefined after `timeoutMs` with nothing to read. */
|
|
78
|
+
async next(timeoutMs = 100) {
|
|
79
|
+
const deadline = performance.now() + timeoutMs;
|
|
80
|
+
for (;;) {
|
|
81
|
+
if (this.records.length)
|
|
82
|
+
return this.records.shift();
|
|
83
|
+
if (this.exited || this.child.exitCode !== null || performance.now() >= deadline)
|
|
84
|
+
return undefined;
|
|
85
|
+
await this.changed(deadline - performance.now());
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Sends a command and waits for its response. For one command; use write()+waitFor() to overlap several. */
|
|
89
|
+
async request(command, timeoutMs) {
|
|
90
|
+
this.write(command);
|
|
91
|
+
return this.waitFor(command.id, timeoutMs, command.type);
|
|
92
|
+
}
|
|
93
|
+
take(id) {
|
|
94
|
+
const index = this.records.findIndex((record) => record.type === "response" && record.id === id);
|
|
95
|
+
if (index >= 0)
|
|
96
|
+
return this.records.splice(index, 1)[0];
|
|
97
|
+
const stashed = this.stashed.get(id);
|
|
98
|
+
if (!stashed)
|
|
99
|
+
return undefined;
|
|
100
|
+
this.stashed.delete(id);
|
|
101
|
+
return stashed;
|
|
102
|
+
}
|
|
103
|
+
/** Waits for one correlated response while leaving every unrelated record in order. */
|
|
104
|
+
async waitFor(id, timeoutMs, what = id) {
|
|
105
|
+
const deadline = performance.now() + timeoutMs;
|
|
106
|
+
for (;;) {
|
|
107
|
+
const record = this.take(id);
|
|
108
|
+
if (record)
|
|
109
|
+
return record;
|
|
110
|
+
if (this.exited || this.child.exitCode !== null)
|
|
111
|
+
throw new Error(`Pi RPC exited (${this.exitReason || `code ${this.child.exitCode}`}) while waiting for ${what}. ${this.stderr.trim()}`);
|
|
112
|
+
if (performance.now() >= deadline)
|
|
113
|
+
throw new Error(`Pi did not answer ${what} within ${timeoutMs}ms`);
|
|
114
|
+
await this.changed(deadline - performance.now());
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|