@evandu/super-pi 0.1.40 → 0.1.42

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-Dxm2Vtis.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-DuqJLo6R.css">
11
+ <script type="module" crossorigin src="/assets/index-DlKKZuzf.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-C6BogHc-.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -110,35 +110,66 @@ export class RunService {
110
110
  * backfilled with `options[0]`.
111
111
  */
112
112
  async askQuestion(conversationId) {
113
- const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
114
- const message = conversation?.messages.slice().reverse().find((item) => item.role === "assistant");
115
- if (!message)
116
- throw new Error("No active assistant message");
117
- // THE CALL, NOT THE MESSAGE. The newest `question` call still marked pending is the one the model is
118
- // blocked inside; anything else would attach an answer to a question nobody is waiting on.
119
- let index = -1;
120
- for (let i = (message.toolCalls?.length ?? 0) - 1; i >= 0; i -= 1) {
121
- const call = message.toolCalls?.[i];
122
- if (call?.label === "question" && call.question?.status === "pending") {
123
- index = i;
124
- break;
113
+ // THE ROW ARRIVES AFTER THE REQUEST, AND THAT IS A RACE, NOT A BUG IN THE CALLER.
114
+ //
115
+ // The extension lives INSIDE the Pi process. When Pi starts running the tool it writes
116
+ // `tool_execution_start` to stdout AND calls the tool - so the tool's HTTP request can reach this
117
+ // server while the transcript drain loop is still reading that very record off the pipe. The endpoint
118
+ // therefore asked for a tool call that had not been stored yet, and answered 「这个会话里没有正在
119
+ // 等待回答的 question 工具调用」 - which is a refusal, so the tool FAILED, and the person was never
120
+ // shown the question at all.
121
+ //
122
+ // So the lookup WAITS. A few hundred milliseconds of polling covers the drain, and a question that
123
+ // genuinely has no pending call still fails - just not instantly.
124
+ const findCall = () => {
125
+ const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
126
+ const message = conversation?.messages.slice().reverse().find((item) => item.role === "assistant");
127
+ if (!message)
128
+ return undefined;
129
+ for (let i = (message.toolCalls?.length ?? 0) - 1; i >= 0; i -= 1) {
130
+ const call = message.toolCalls?.[i];
131
+ if (call?.label === "question" && call.question?.status === "pending")
132
+ return { messageId: message.id, index: i };
125
133
  }
134
+ return undefined;
135
+ };
136
+ let found;
137
+ for (let attempt = 0; attempt < 50 && !found; attempt += 1) {
138
+ found = findCall();
139
+ if (found)
140
+ break;
141
+ await new Promise((resolve) => setTimeout(resolve, 100));
126
142
  }
127
- if (index < 0)
143
+ const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
144
+ if (!conversation?.messages.length)
145
+ throw new Error("No active assistant message");
146
+ if (!found)
128
147
  throw new Error("这个会话里没有正在等待回答的 question 工具调用");
129
- const messageId = message.id;
148
+ const { messageId, index } = found;
130
149
  return new Promise((resolve, reject) => {
131
150
  const finish = (settle) => { clearInterval(poll); clearTimeout(deadline); settle(); };
132
151
  const poll = setInterval(() => {
133
152
  const current = this.store.snapshot().conversations.find((item) => item.id === conversationId)?.messages.find((item) => item.id === messageId)?.toolCalls?.[index]?.question;
134
153
  if (current?.status === "answered")
135
- finish(() => resolve({ answers: current.answers ?? [] }));
154
+ finish(() => resolve(answerPayload(current)));
136
155
  // CANCELLED IS NOT AN ANSWER. Rejecting is what makes the tool call fail, which is what stops the
137
156
  // run; resolving here would let the model carry on from a question the person refused to answer.
138
157
  else if (current?.status === "cancelled")
139
158
  finish(() => reject(new Error("用户没有回答这个问题")));
140
159
  }, 250);
141
160
  const deadline = setTimeout(() => finish(() => reject(new Error("等待回答超时"))), 30 * 60 * 1000);
161
+ // WHAT GOES BACK TO THE MODEL IS WRITTEN FOR THE MODEL. The answers pair to their questions by id,
162
+ // which is what stops a person answering the second of two being recorded against the first - but an
163
+ // id is a UUID, and a model handed `{answers:[{id:"671b0f0b-…", selected:["发"]}]}` has to guess at
164
+ // what that means. It guessed, and reported a real answer as a cancellation. So the answer goes back
165
+ // as its questions and its choices, with a sentence naming the outcome.
166
+ const answerPayload = (current) => {
167
+ const answers = (current.answers ?? []).map((answer) => {
168
+ const asked = current.items.find((item) => item.id === answer.id);
169
+ return { question: asked?.question, selected: answer.selected, ...(answer.note ? { note: answer.note } : {}) };
170
+ });
171
+ return { answers, summary: `用户回答:${answers.map((answer) => answer.selected.join("、")).join(";")}` };
172
+ };
142
173
  });
143
174
  }
144
175
  /**
@@ -283,6 +314,11 @@ export class RunService {
283
314
  // an order that never happened. The runtime calls `onToolCall` synchronously between yields, so a
284
315
  // counter advanced once per yielded delta is the real position of the call in the answer.
285
316
  let spoken = 0;
317
+ // AND HOW MUCH REASONING HAD BEEN STREAMED, for the same reason and from the same place. The store's
318
+ // `message.thinking` is only updated at the end of the turn (in `finally`), so it cannot be read here.
319
+ // The running total is what lets a call made during reasoning anchor inside the thinking block rather than
320
+ // after it, which is the order the events had.
321
+ let thought = 0;
286
322
  /**
287
323
  * ONE COUNTER FOR EVERY ROW, AND THE ONLY WAY A ROW NUMBER IS EVER MADE.
288
324
  *
@@ -333,6 +369,7 @@ export class RunService {
333
369
  ...(call.detail ? { detail: call.detail } : { detail: "正在生成参数…" }),
334
370
  preparing: true,
335
371
  at,
372
+ thinkingAt: thought,
336
373
  }));
337
374
  preparingRows.set(call.contentIndex, { row, name: call.name });
338
375
  };
@@ -364,7 +401,7 @@ export class RunService {
364
401
  const row = rows++;
365
402
  if (call.id)
366
403
  rowByToolCallId.set(call.id, row);
367
- enqueueTools("a tool call", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name, detail: call.detail, args: call.args, at, ...(call.question ? { question: call.question } : {}) }));
404
+ 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 } : {}) }));
368
405
  };
369
406
  const onToolUpdate = (call) => {
370
407
  const index = call.id ? rowByToolCallId.get(call.id) : undefined;
@@ -390,7 +427,14 @@ export class RunService {
390
427
  onToolPreparing,
391
428
  onToolUpdate,
392
429
  onToolEnd,
393
- onThinking: (delta) => this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true }),
430
+ // BROADCAST AND KEEP. It used to only broadcast, into an event no one handled - so the reasoning
431
+ // reached nobody, live or afterwards. Both halves now happen: the browser draws the delta as it
432
+ // arrives, and the message keeps it so the finished turn has something to go back to.
433
+ onThinking: (delta) => {
434
+ thought += delta.length;
435
+ this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true });
436
+ void this.store.appendThinkingDelta(conversation.id, assistant.id, delta);
437
+ },
394
438
  images,
395
439
  })) {
396
440
  await this.store.appendAssistantDelta(conversation.id, assistant.id, delta);
@@ -593,36 +637,38 @@ export class RunService {
593
637
  const result = run.status === "completed" ? run.output ?? "" : `Background task failed: ${run.error ?? run.status}`;
594
638
  const tag = run.status === "completed" ? "task_result" : "task_error";
595
639
  const message = `<task id="${run.id}" state="${run.status === "completed" ? "completed" : "error"}">\n<summary>Background task ${run.status === "completed" ? "completed" : "failed"}: ${body.description?.trim() || subagentType}</summary>\n<${tag}>\n${result}\n</${tag}>\n</task>`;
596
- // Background results are durable follow-up material. Keep them visible in the queue until the
597
- // user sends the next prompt; injecting a result into an idle conversation starts an unexpected
598
- // model turn and makes the result disappear before the Queue Dock can render it.
599
- try {
600
- if (this.activeRuns.has(conversation.id))
601
- await this.enqueueAndRun(conversation.id, message);
602
- else
603
- await this.store.enqueueMessage(conversation.id, message);
604
- }
605
- catch (error) {
640
+ // A BACKGROUND RESULT IS A PROMPT, AND DELIVERING IT IS NOT TRIGGERING IT.
641
+ //
642
+ // This used to park it in the queue until the reader sent the next prompt, which is the hole the
643
+ // queue exists to avoid: nothing owned it. `takeNextQueuedMessage` is only ever called from
644
+ // inside a turn, so a result arriving at an IDLE conversation sat in the dock forever - and when
645
+ // the reader tried to steer it, the runtime refused with 「会话 X 现在没有正在运行的 Pi 进程」,
646
+ // because of course there was no run: the message had been waiting for one nobody would start.
647
+ //
648
+ // `enqueueAndRun` is the owner this needed. It claims the run slot synchronously, so it starts a
649
+ // turn when the conversation is idle and enqueues when one is running - and the turn it starts
650
+ // drains whatever follows. One call, and no queued message is ever left without a scheduled reader.
651
+ // HANDED OFF, NOT AWAITED. `enqueueAndRun` resolves when the whole turn it started has FINISHED,
652
+ // and this handler owns the `backgroundTasks` row - so awaiting it here kept that row on screen
653
+ // for the entire delivered answer, a finished task wearing its own 「running」 state. The row is
654
+ // removed as soon as the result has a home, which is what its lifecycle means.
655
+ const started = this.enqueueAndRun(conversation.id, message);
656
+ await this.store.removeBackgroundTask(run.id);
657
+ this.broadcaster.broadcastState();
658
+ await started.catch(async (error) => {
659
+ // The turn could not start, so the result has nowhere to be read from. Put it back in the queue,
660
+ // where it is at least visible and cancellable, rather than dropping it on the floor.
606
661
  await this.store.enqueueMessage(conversation.id, message);
607
- console.warn(`Super Pi: background result could not start a turn: ${error instanceof Error ? error.message : "unknown error"}`);
608
- }
609
- finally {
610
- await this.store.removeBackgroundTask(run.id);
611
662
  this.broadcaster.broadcastState();
612
- }
663
+ console.warn(`Super Pi: background result could not start a turn: ${error instanceof Error ? error.message : "unknown error"}`);
664
+ });
613
665
  }).catch(async (error) => {
614
666
  const message = `<task id="${backgroundId}" state="error">\n<summary>Background task failed: ${body.description?.trim() || subagentType}</summary>\n<task_error>Background task failed before completion: ${error instanceof Error ? error.message : "unknown error"}</task_error>\n</task>`;
615
- try {
616
- if (this.activeRuns.has(conversationId))
617
- await this.enqueueAndRun(conversationId, message);
618
- else
619
- await this.store.enqueueMessage(conversationId, message);
620
- }
621
- catch {
622
- await this.store.enqueueMessage(conversationId, message);
623
- }
667
+ // The same rule as the task result above, and the same shape: hand it off, then drop the row.
668
+ const started = this.enqueueAndRun(conversationId, message);
624
669
  await this.store.removeBackgroundTask(backgroundId);
625
670
  this.broadcaster.broadcastState();
671
+ await started.catch(async () => { await this.store.enqueueMessage(conversationId, message); this.broadcaster.broadcastState(); });
626
672
  console.warn(`Super Pi: background sub-agent dispatch failed: ${error instanceof Error ? error.message : "unknown error"}`);
627
673
  }));
628
674
  return {
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
4
3
  import { existsSync } from "node:fs";
4
+ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
5
5
  import { resolve, join } from "node:path";
6
6
  import { agentSpawnArgs, readAgents } from "./agents.js";
7
7
  import { TASK_CAPABILITY_ENV, mintCapability, releaseCapability } from "./capability.js";
@@ -779,7 +779,6 @@ export class PiRpcRuntime {
779
779
  } : {}) } } : {}),
780
780
  });
781
781
  const channel = new RpcChannel(child);
782
- this.runs.set(conversation.id, { child, channel });
783
782
  // Same guard as subagents.ts: a destroyed stdin throws synchronously on write, and an abort
784
783
  // listener that throws inside a listener would surface as an unhandled error instead of a cancel.
785
784
  const abort = () => {
@@ -994,6 +993,7 @@ export class PiRpcRuntime {
994
993
  }
995
994
  if (record.type === "message_update") {
996
995
  const event = record.assistantMessageEvent;
996
+ console.log(`[runtime] message_update event.type=${JSON.stringify(event?.type)}`);
997
997
  // text_delta is what drives the visible answer, and toolcall_start/delta/end are what make a
998
998
  // call VISIBLE WHILE IT IS BEING WRITTEN.
999
999
  //
@@ -1027,8 +1027,10 @@ export class PiRpcRuntime {
1027
1027
  }
1028
1028
  else if (event?.type === "thinking_delta")
1029
1029
  onThinking?.(typeof event.delta === "string" ? event.delta : "");
1030
- else
1030
+ else {
1031
1031
  noteUnhandled(`message_update:${JSON.stringify(event?.type ?? "(missing)")}`);
1032
+ console.log(`[runtime] message_update not matched: event.type=${JSON.stringify(event?.type)}`);
1033
+ }
1032
1034
  continue;
1033
1035
  }
1034
1036
  if (record.type === "extension_ui_request") {
@@ -211,6 +211,17 @@ export declare class Store {
211
211
  }): Promise<Conversation>;
212
212
  beginAssistantMessage(conversationId: string, agentIdentity?: Message["agentIdentity"]): Promise<Message>;
213
213
  appendAssistantDelta(conversationId: string, messageId: string, delta: string): Promise<void>;
214
+ /**
215
+ * THE MODEL'S REASONING, APPENDED TO THE MESSAGE IT BELONGS TO.
216
+ *
217
+ * It used to be broadcast and nothing more: the server emitted `message.thinking` on every delta and no
218
+ * code anywhere read it, so the reasoning reached no one - not the screen while it streamed, and not
219
+ * the transcript afterwards. The field, the event and the stylesheet were all already there; the two
220
+ * halves that would have used them were missing, which is why a finished turn had no visible reasoning
221
+ * to go back to. Persisting here rather than only in the browser is the point of its living on the
222
+ * message: a reasoning stream that vanishes on reload was never a record of anything.
223
+ */
224
+ appendThinkingDelta(conversationId: string, messageId: string, delta: string): Promise<void>;
214
225
  /**
215
226
  * Appends one tool call to the message it belongs to, and returns the message's whole `toolCalls`
216
227
  * array so the caller can broadcast it as one snapshot.
@@ -864,6 +864,25 @@ export class Store {
864
864
  conversation.updatedAt = now();
865
865
  await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: conversation.updatedAt });
866
866
  }
867
+ /**
868
+ * THE MODEL'S REASONING, APPENDED TO THE MESSAGE IT BELONGS TO.
869
+ *
870
+ * It used to be broadcast and nothing more: the server emitted `message.thinking` on every delta and no
871
+ * code anywhere read it, so the reasoning reached no one - not the screen while it streamed, and not
872
+ * the transcript afterwards. The field, the event and the stylesheet were all already there; the two
873
+ * halves that would have used them were missing, which is why a finished turn had no visible reasoning
874
+ * to go back to. Persisting here rather than only in the browser is the point of its living on the
875
+ * message: a reasoning stream that vanishes on reload was never a record of anything.
876
+ */
877
+ async appendThinkingDelta(conversationId, messageId, delta) {
878
+ const conversation = this.requireConversation(conversationId);
879
+ const message = conversation.messages.find((item) => item.id === messageId);
880
+ if (!message)
881
+ throw new Error("Assistant message not found");
882
+ message.thinking = `${message.thinking ?? ""}${delta}`;
883
+ conversation.updatedAt = now();
884
+ await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: conversation.updatedAt });
885
+ }
867
886
  /**
868
887
  * Appends one tool call to the message it belongs to, and returns the message's whole `toolCalls`
869
888
  * array so the caller can broadcast it as one snapshot.
@@ -894,7 +913,22 @@ export class Store {
894
913
  // halves: the call arrived, and the message already holds every character streamed before it. Without
895
914
  // it the browser can only render the blocks as one list beside the text, so a turn that read a file,
896
915
  // said something, then ran a test would show all three in the wrong order.
897
- calls.push(structuredClone(call.at === undefined ? { ...call, at: message.content.length } : call));
916
+ //
917
+ // AND WHERE IN THE REASONING, for the same reason and from the same place. `thinking` is the other
918
+ // stream a turn has, and a call made while the model was still thinking has to be placeable inside it -
919
+ // otherwise the transcript can only say "it reasoned, and then it called a tool", which is the one order
920
+ // a `hmm → read → hmm → grep → answer` turn never had. Stamped as the thinking length BEFORE this call,
921
+ // so a call that started before any reasoning anchors at 0 and renders first.
922
+ //
923
+ // The thinking read here is the message's own field, written synchronously as each delta arrives - not
924
+ // a count of how much a queued disk write had managed to flush. That distinction is the same one `at`
925
+ // was stamped for.
926
+ const placed = {
927
+ ...call,
928
+ ...(call.at === undefined ? { at: message.content.length } : {}),
929
+ ...(call.thinkingAt === undefined ? { thinkingAt: (message.thinking ?? "").length } : {}),
930
+ };
931
+ calls.push(structuredClone(placed));
898
932
  conversation.updatedAt = now();
899
933
  await this.persist();
900
934
  return structuredClone(calls);
@@ -140,6 +140,15 @@ const oneLine = (value) => {
140
140
  * shape can be regression-tested at all.
141
141
  */
142
142
  export function describeToolCall(name, args) {
143
+ // A QUESTION NAMES ITSELF. The generic path reads a target-ish string out of the arguments and a
144
+ // question has none, so it fell through to the preparing placeholder - which left every settled
145
+ // question row headed 「正在生成参数…」 above the question itself. The row is the decision; the header
146
+ // should say which one.
147
+ if (name.trim().toLowerCase() === "question") {
148
+ const asked = readQuestionCall(name, args);
149
+ if (asked?.items.length)
150
+ return oneLine(asked.items.length === 1 ? asked.items[0].question : `${asked.items.length} 个问题`);
151
+ }
143
152
  if (args === null || typeof args !== "object" || Array.isArray(args))
144
153
  return undefined;
145
154
  const record = args;
@@ -167,7 +167,23 @@ export type ToolCallBlock = {
167
167
  * the only thing on screen during a long parameter stream - an 'edit' block carries a whole file's
168
168
  * replacement text, and without this the screen is blank until the last token arrives. */
169
169
  preparing?: boolean;
170
+ /**
171
+ * WHERE IN THE ANSWER the call happened, as a character offset into `content` - and WHERE IN THE
172
+ * REASONING, as an offset into `thinking`.
173
+ *
174
+ * A TURN HAS TWO STREAMS, and a call belongs to a position in each. `at` places it among the words the
175
+ * agent spoke; `thinkingAt` places it inside the reasoning it did before speaking, which is what lets a
176
+ * call that happened mid-thought render INSIDE the thinking block rather than beside it. Without the
177
+ * second offset a tool call can only be stacked after the reasoning, which reads as "the model thought,
178
+ * and then, separately, called a tool" - the order the events did not have.
179
+ *
180
+ * OPTIONAL, and absent means "this call's place in the reasoning was never recorded" - a message written
181
+ * before the field existed. A reader that guesses a position for such a call would be re-ordering a turn
182
+ * on no evidence, so it must fall back to `at` alone, which is exactly what those blocks were written to
183
+ * be read by.
184
+ */
170
185
  at?: number;
186
+ thinkingAt?: number;
171
187
  question?: ToolCallQuestion;
172
188
  };
173
189
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",