@evandu/super-pi 0.1.70 → 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.
@@ -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-B0eJbg4U.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-CbHwRb22.css">
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
- // fragment is stored as well, because that is what the reader watches grow.
391
- if (call.delta)
392
- enqueueTools("a tool call argument", () => this.store.updateToolCall(conversation.id, assistant.id, existing.row, { live: call.delta, ...(call.detail ? { detail: call.detail } : {}) }));
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: thought,
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
- enqueueTools("a tool call", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name, detail: call.detail, args: call.args, at, thinkingAt: thought, ...(call.question ? { question: call.question } : {}) }));
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
- for await (const delta of this.runtime.answer({
457
- conversation,
458
- prompt,
459
- signal: controller.signal,
460
- cwd: workspace.path,
461
- host: this.extensionUiService.createHost(conversation.id),
462
- onPiQueue: (snapshot) => this.onPiQueue(conversation.id, snapshot),
463
- onUserEntry: (entryId) => this.store.setMessagePiEntryId(conversation.id, userMessageId, entryId),
464
- onToolCall,
465
- onToolPreparing,
466
- onToolUpdate,
467
- onToolEnd,
468
- // BROADCAST AND KEEP. It used to only broadcast, into an event no one handled - so the reasoning
469
- // reached nobody, live or afterwards. Both halves now happen: the browser draws the delta as it
470
- // arrives, and the message keeps it so the finished turn has something to go back to.
471
- onThinking: (delta) => {
472
- thought += delta.length;
473
- this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true });
474
- void this.store.appendThinkingDelta(conversation.id, assistant.id, delta);
475
- },
476
- images,
477
- })) {
478
- await this.store.appendAssistantDelta(conversation.id, assistant.id, delta);
479
- spoken += delta.length;
480
- this.broadcaster.broadcast({ type: "message.delta", conversationId: conversation.id, messageId: assistant.id, delta });
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
+ }