@evandu/super-pi 0.1.29 → 0.1.31

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-_xNokuGH.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-DoUhMucG.css">
11
+ <script type="module" crossorigin src="/assets/index-Bv8kBXop.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-BVsQMo7-.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -166,10 +166,43 @@ export class RunService {
166
166
  // counter advanced once per yielded delta is the real position of the call in the answer.
167
167
  let spoken = 0;
168
168
  const rowByToolCallId = new Map();
169
+ /**
170
+ * Rows for calls the model is STILL WRITING, keyed by the contentIndex its toolcall_start named.
171
+ *
172
+ * The index is the only handle that exists before the arguments are parsed: the tool's id arrives
173
+ * with toolcall_end, and the host is told nothing until tool_execution_start. Keeping the index → row
174
+ * map here rather than in the store is what lets tool_execution_start FILL IN the row the reader is
175
+ * already looking at, instead of appending a second block for the same call and making one call look
176
+ * like two.
177
+ */
178
+ const preparingRowByIndex = new Map();
179
+ const onToolPreparing = (call) => {
180
+ const existing = preparingRowByIndex.get(call.contentIndex);
181
+ if (existing !== undefined) {
182
+ if (call.delta)
183
+ enqueueTools("a tool call argument", () => this.store.updateToolCall(conversation.id, assistant.id, existing, { live: call.delta }));
184
+ return;
185
+ }
186
+ // No name yet is the normal first token, and it is NOT a reason to wait: the block appearing with an
187
+ // empty name the instant the model starts writing is what tells the reader anything is happening at
188
+ // all, and it fills in on the next delta.
189
+ const at = spoken;
190
+ enqueueTools("a tool call being prepared", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name || "工具", detail: "正在生成参数…", preparing: true, at }));
191
+ preparingRowByIndex.set(call.contentIndex, preparingRowByIndex.size);
192
+ };
169
193
  const onToolCall = (call) => {
170
194
  if (call.id)
171
195
  rowByToolCallId.set(call.id, rowByToolCallId.size);
172
196
  const at = spoken;
197
+ const prepared = [...preparingRowByIndex.values()].at(-1);
198
+ // THE CALL THAT IS ALREADY ON SCREEN GETS FILLED IN. Appending here instead would show one call
199
+ // twice - once as a placeholder, once as the real thing - which reads as the agent calling a tool
200
+ // it never called.
201
+ if (prepared !== undefined) {
202
+ preparingRowByIndex.clear();
203
+ enqueueTools("a tool call", () => this.store.updateToolCall(conversation.id, assistant.id, prepared, { label: call.name, detail: call.detail, args: call.args, live: "", preparing: false }));
204
+ return;
205
+ }
173
206
  enqueueTools("a tool call", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name, detail: call.detail, args: call.args, at }));
174
207
  };
175
208
  const onToolUpdate = (call) => {
@@ -193,6 +226,7 @@ export class RunService {
193
226
  onPiQueue: (snapshot) => this.onPiQueue(conversation.id, snapshot),
194
227
  onUserEntry: (entryId) => this.store.setMessagePiEntryId(conversation.id, userMessageId, entryId),
195
228
  onToolCall,
229
+ onToolPreparing,
196
230
  onToolUpdate,
197
231
  onToolEnd,
198
232
  images,
@@ -1,6 +1,6 @@
1
1
  import { type ChildProcessWithoutNullStreams } from "node:child_process";
2
2
  import type { ActiveMemory } from "./active-memory.js";
3
- import { type AgentDefinition, type Conversation, type ExtensionUiAnswer, type ExtensionUiRequest, type MessageImage, type ModelCatalog, type ModelOption, type ModelSelection, type PiQueueSnapshot, type ToolCallNotice } from "./types.js";
3
+ import { type AgentDefinition, type Conversation, type ExtensionUiAnswer, type ExtensionUiRequest, type MessageImage, type ModelCatalog, type ModelOption, type ModelSelection, type PiQueueSnapshot, type ToolCallNotice, type ToolCallPreparing } from "./types.js";
4
4
  /**
5
5
  * Who answers Pi's Extension UI questions. The runtime never invents an answer: a blocking method
6
6
  * either comes back from `ask`, or it is cancelled. That is the whole point of Pi's third response
@@ -90,6 +90,12 @@ export interface AnswerInput {
90
90
  * from a frozen agent.
91
91
  */
92
92
  onToolUpdate?(call: ToolCallNotice): void;
93
+ /**
94
+ * Called as the model BUILDS a tool call, before the host has been told anything about it. It is the
95
+ * difference between a transcript that is quiet for the seconds an 'edit' takes to write its
96
+ * replacement text, and one that shows the call arriving.
97
+ */
98
+ onToolPreparing?(call: ToolCallPreparing): void;
93
99
  }
94
100
  /**
95
101
  * What Pi answered when a `steer` was written to it, from the `disposition` of that command's response
@@ -273,6 +279,8 @@ export type RpcRecord = {
273
279
  assistantMessageEvent?: {
274
280
  type?: string;
275
281
  delta?: string;
282
+ contentIndex?: number;
283
+ partial?: unknown;
276
284
  reason?: string;
277
285
  message?: {
278
286
  role?: string;
@@ -558,7 +566,6 @@ export declare const catalogKey: (selection?: ModelSelection) => string;
558
566
  * warning on a path that is genuinely optional rather than a startup crash on a build that skipped it.
559
567
  */
560
568
  export declare function taskExtensionPath(from?: string): string;
561
- /** Bridges a Pi session to Super Pi through Pi's documented JSONL RPC protocol. */
562
569
  export declare class PiRpcRuntime implements AgentRuntime {
563
570
  private readonly sessionDir;
564
571
  /**
@@ -696,7 +703,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
696
703
  * own address, so it is the same string on every turn and is read once from the constructor's options.
697
704
  */
698
705
  private taskExtensionEnv;
699
- answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolUpdate, onToolEnd, images, memoryReview, memorySourceConversationId }: AnswerInput): AsyncIterable<string>;
706
+ answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, images, memoryReview, memorySourceConversationId }: AnswerInput): AsyncIterable<string>;
700
707
  /**
701
708
  * Reads back the id of the user message this turn just created, and hands it to the caller.
702
709
  *
@@ -549,6 +549,16 @@ export function taskExtensionPath(from = import.meta.dirname) {
549
549
  return resolve(from, "../../agents/super-pi-task/index.ts");
550
550
  }
551
551
  /** Bridges a Pi session to Super Pi through Pi's documented JSONL RPC protocol. */
552
+ /**
553
+ * The tool's name as far as the stream has revealed it so far, or an empty string while the model is
554
+ * still on the first token. Deliberately NOT a placeholder: a block labelled 「工具」 tells the reader
555
+ * nothing they cannot already see, whereas one labelled 'edit' one second early is the whole point.
556
+ */
557
+ function toolNameOf(partial) {
558
+ const content = partial?.content;
559
+ const block = Array.isArray(content) ? content.find((item) => item?.type === "toolCall") : undefined;
560
+ return typeof block?.name === "string" ? block.name : "";
561
+ }
552
562
  export class PiRpcRuntime {
553
563
  sessionDir;
554
564
  agents;
@@ -700,7 +710,7 @@ export class PiRpcRuntime {
700
710
  [TASK_CAPABILITY_ENV]: mintCapability(conversationId),
701
711
  };
702
712
  }
703
- async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolUpdate, onToolEnd, images, memoryReview, memorySourceConversationId }) {
713
+ async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, images, memoryReview, memorySourceConversationId }) {
704
714
  await mkdir(this.sessionDir, { recursive: true });
705
715
  // Normalize first: the picker can hand over forward slashes or a trailing separator, and `resolve`
706
716
  // turns both into one absolute native path. The directory must exist on disk before spawn - a
@@ -953,11 +963,24 @@ export class PiRpcRuntime {
953
963
  }
954
964
  if (record.type === "message_update") {
955
965
  const event = record.assistantMessageEvent;
956
- // text_delta is the only inner event that drives the visible answer. The rest (thinking,
957
- // tool calls, message_start/end) are consumed here, but still announced once each so an
958
- // event shape this runtime ignores cannot pass unnoticed.
966
+ // text_delta is what drives the visible answer, and toolcall_start/delta/end are what make a
967
+ // call VISIBLE WHILE IT IS BEING WRITTEN.
968
+ //
969
+ // Those two used to be dropped here by a comment that named them - "the rest (thinking, tool
970
+ // calls, message_start/end) are consumed here". Consuming them was the bug, not the design: an
971
+ // `edit` call's arguments ARE the old text and the new text, so the model spends many seconds
972
+ // and thousands of tokens emitting them, and for every one of those seconds the screen showed
973
+ // nothing at all. A frozen transcript and a slow one look identical from the outside; a block
974
+ // that appears the instant the model starts writing its arguments is the difference.
975
+ //
976
+ // The text is what arrives - `toolcall_delta` is a raw JSON fragment, and parsing it early
977
+ // would mean guessing at a document that is not finished. It is handed on as it comes.
959
978
  if (event?.type === "text_delta")
960
979
  yield event.delta ?? "";
980
+ else if (event?.type === "toolcall_start" && typeof event.contentIndex === "number")
981
+ onToolPreparing?.({ contentIndex: event.contentIndex, name: toolNameOf(event.partial) });
982
+ else if (event?.type === "toolcall_delta" && typeof event.contentIndex === "number")
983
+ onToolPreparing?.({ contentIndex: event.contentIndex, name: "", ...(typeof event.delta === "string" ? { delta: event.delta } : {}) });
961
984
  else
962
985
  noteUnhandled(`message_update:${JSON.stringify(event?.type ?? "(missing)")}`);
963
986
  continue;
@@ -264,8 +264,11 @@ export declare class Store {
264
264
  * values a tool can genuinely produce, and treating them as "not reported" would erase a real answer.
265
265
  */
266
266
  updateToolCall(conversationId: string, messageId: string, index: number, patch: {
267
+ label?: string;
268
+ detail?: string;
267
269
  args?: ToolCallArg[];
268
270
  live?: string;
271
+ preparing?: boolean;
269
272
  }): Promise<ToolCallBlock[]>;
270
273
  setStatus(conversationId: string, status: Conversation["status"]): Promise<void>;
271
274
  /** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
@@ -947,6 +947,12 @@ export class Store {
947
947
  // whatever the latest update happened to carry loses every key the update left out, which on the very
948
948
  // first update is all of them. Merging can only ever add, so a sparse update can no longer erase what
949
949
  // an earlier one already reported - and the cap is re-applied here rather than trusted from outside.
950
+ if (patch.label !== undefined)
951
+ call.label = patch.label;
952
+ if (patch.detail !== undefined)
953
+ call.detail = patch.detail;
954
+ if (patch.preparing !== undefined)
955
+ call.preparing = patch.preparing;
950
956
  if (patch.args !== undefined) {
951
957
  const merged = new Map((call.args ?? []).map((arg) => [arg.key, arg]));
952
958
  for (const arg of patch.args)
@@ -131,6 +131,10 @@ export type ToolCallBlock = {
131
131
  * distinction that matters when a reader copies it.
132
132
  */
133
133
  live?: string;
134
+ /** The model is still GENERATING this call's arguments. Nothing has reached the host yet, so this is
135
+ * the only thing on screen during a long parameter stream - an 'edit' block carries a whole file's
136
+ * replacement text, and without this the screen is blank until the last token arrives. */
137
+ preparing?: boolean;
134
138
  at?: number;
135
139
  };
136
140
  /**
@@ -164,7 +168,14 @@ export type ToolCallNotice = {
164
168
  durationMs?: number;
165
169
  result?: ToolResultSummary;
166
170
  live?: string;
171
+ preparing?: boolean;
167
172
  };
173
+ /** WHAT THE RUNTIME HANDS A CALLER WHEN THE MODEL STARTS, OR CONTINUES, BUILDING A TOOL CALL. */
174
+ export interface ToolCallPreparing {
175
+ contentIndex: number;
176
+ name: string;
177
+ delta?: string;
178
+ }
168
179
  /**
169
180
  * `archivedAt` is the whole archive mechanism: a state MARKER, not a move. The conversation object never
170
181
  * changes workspace or position, so archiving and restoring are a stamp and a clear of one optional field.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",