@evandu/super-pi 0.1.31 → 0.1.33

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-Bv8kBXop.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-BVsQMo7-.css">
11
+ <script type="module" crossorigin src="/assets/index-dU9RhbHX.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-D6bdUvG_.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -165,9 +165,23 @@ export class RunService {
165
165
  // an order that never happened. The runtime calls `onToolCall` synchronously between yields, so a
166
166
  // counter advanced once per yielded delta is the real position of the call in the answer.
167
167
  let spoken = 0;
168
+ /**
169
+ * ONE COUNTER FOR EVERY ROW, AND THE ONLY WAY A ROW NUMBER IS EVER MADE.
170
+ *
171
+ * Both maps below are addressed by POSITION in the message's `toolCalls` array, so both have to count
172
+ * in the same space. They did not: each kept its own `map.size`, and those two numbers stop agreeing
173
+ * the moment a turn makes two tool calls - the first call's placeholder is consumed and the map is
174
+ * cleared, so the SECOND placeholder is handed the row number `0` while `appendToolCall` actually put
175
+ * it at `1`. From there every later write lands on the wrong row: the second call's arguments overwrote
176
+ * the first call's finished block, and the second call's own row was never settled, so it kept
177
+ * 「正在生成参数…」 for the rest of the turn. A finished tool that still claims to be generating its
178
+ * arguments is worse than a missing one - it says the agent is stuck on work it already did.
179
+ */
180
+ let rows = 0;
168
181
  const rowByToolCallId = new Map();
169
182
  /**
170
- * Rows for calls the model is STILL WRITING, keyed by the contentIndex its toolcall_start named.
183
+ * Rows for calls the model is STILL WRITING, keyed by the contentIndex its toolcall_start named, and
184
+ * carrying the name that index announced when it had one.
171
185
  *
172
186
  * The index is the only handle that exists before the arguments are parsed: the tool's id arrives
173
187
  * with toolcall_end, and the host is told nothing until tool_execution_start. Keeping the index → row
@@ -175,34 +189,63 @@ export class RunService {
175
189
  * already looking at, instead of appending a second block for the same call and making one call look
176
190
  * like two.
177
191
  */
178
- const preparingRowByIndex = new Map();
192
+ const preparingRows = new Map();
179
193
  const onToolPreparing = (call) => {
180
- const existing = preparingRowByIndex.get(call.contentIndex);
194
+ const existing = preparingRows.get(call.contentIndex);
181
195
  if (existing !== undefined) {
196
+ // The line naming the call IMPROVES as the arguments arrive - a `read` learns its path from the
197
+ // first fragment - so it is patched rather than frozen at whatever the first frame could say. The
198
+ // fragment is stored as well, because that is what the reader watches grow.
182
199
  if (call.delta)
183
- enqueueTools("a tool call argument", () => this.store.updateToolCall(conversation.id, assistant.id, existing, { live: call.delta }));
200
+ enqueueTools("a tool call argument", () => this.store.updateToolCall(conversation.id, assistant.id, existing.row, { live: call.delta, ...(call.detail ? { detail: call.detail } : {}) }));
184
201
  return;
185
202
  }
186
203
  // No name yet is the normal first token, and it is NOT a reason to wait: the block appearing with an
187
204
  // empty name the instant the model starts writing is what tells the reader anything is happening at
188
205
  // all, and it fills in on the next delta.
189
206
  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);
207
+ const row = rows++;
208
+ // The block says WHICH TOOL and WHAT IT IS AIMED AT as soon as either is known. 「工具」 as the name
209
+ // and 「正在生成参数…」 as the only line below it told a reader nothing for the seconds a large `edit`
210
+ // spends writing its replacement text - and by the time the file path was on screen, the block looked
211
+ // broken rather than early. The placeholder word is now only the FALLBACK for the first fragment, when
212
+ // Pi has not yet said what the call is or what it opens.
213
+ enqueueTools("a tool call being prepared", () => this.store.appendToolCall(conversation.id, assistant.id, {
214
+ label: call.name || "工具",
215
+ ...(call.detail ? { detail: call.detail } : { detail: "正在生成参数…" }),
216
+ preparing: true,
217
+ at,
218
+ }));
219
+ preparingRows.set(call.contentIndex, { row, name: call.name });
192
220
  };
193
221
  const onToolCall = (call) => {
194
- if (call.id)
195
- rowByToolCallId.set(call.id, rowByToolCallId.size);
196
222
  const at = spoken;
197
- const prepared = [...preparingRowByIndex.values()].at(-1);
223
+ // THE PLACEHOLDER THAT BELONGS TO THIS CALL, IF ONE IS ON SCREEN.
224
+ //
225
+ // A model writing two tool calls writes their arguments in one unbroken stream, so more than one
226
+ // placeholder can be waiting when the first execution starts, and the newest is not necessarily the
227
+ // one being run. The NAME is the only thing that can tell them apart - `toolcall_start` carries it
228
+ // when Pi has parsed it - so a name match wins over recency, and recency is only the fallback for a
229
+ // placeholder that never got a name.
230
+ const pending = [...preparingRows];
231
+ const prepared = pending.find(([, entry]) => entry.name === call.name) ?? pending.at(-1);
198
232
  // THE CALL THAT IS ALREADY ON SCREEN GETS FILLED IN. Appending here instead would show one call
199
233
  // twice - once as a placeholder, once as the real thing - which reads as the agent calling a tool
200
234
  // it never called.
201
235
  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 }));
236
+ const row = prepared[1].row;
237
+ preparingRows.delete(prepared[0]);
238
+ // The id is bound to the row it was FILLED IN, not to a row of the caller's own counting. Every
239
+ // later update and the outcome resolve through this binding, so a wrong number here is a block
240
+ // that never settles and one that belongs to a different call.
241
+ if (call.id)
242
+ rowByToolCallId.set(call.id, row);
243
+ enqueueTools("a tool call", () => this.store.updateToolCall(conversation.id, assistant.id, row, { label: call.name, detail: call.detail, args: call.args, live: "", preparing: false }));
204
244
  return;
205
245
  }
246
+ const row = rows++;
247
+ if (call.id)
248
+ rowByToolCallId.set(call.id, row);
206
249
  enqueueTools("a tool call", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name, detail: call.detail, args: call.args, at }));
207
250
  };
208
251
  const onToolUpdate = (call) => {
@@ -1,4 +1,5 @@
1
1
  import { createReadStream, existsSync } from "node:fs";
2
+ import { spawn } from "node:child_process";
2
3
  import { readdir, stat } from "node:fs/promises";
3
4
  import { createServer } from "node:http";
4
5
  import { homedir, networkInterfaces } from "node:os";
@@ -191,6 +192,24 @@ export function createHttpServer(options) {
191
192
  setTimeout(() => { void onShutdown("api-shutdown"); }, 50);
192
193
  return;
193
194
  }
195
+ if (url.pathname === "/api/update" && request.method === "POST") {
196
+ if (!isLocalOrPrivateAddress(remote)) {
197
+ response.writeHead(403).end("Update requires a loopback or private network POST");
198
+ return;
199
+ }
200
+ response.writeHead(202, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ status: "updating" }));
201
+ const command = process.platform === "win32" ? "pi.cmd" : "pi";
202
+ const child = spawn(command, ["update", "npm:@evandu/super-pi"], { cwd: rootDir, detached: true, stdio: "ignore", shell: process.platform === "win32", env: process.env });
203
+ child.on("close", (code) => {
204
+ if (code !== 0)
205
+ return;
206
+ const restart = spawn(process.execPath, [join(rootDir, "scripts", "restart-server.mjs")], { cwd: rootDir, detached: true, stdio: "ignore", env: process.env });
207
+ restart.unref();
208
+ });
209
+ child.on("error", () => { });
210
+ child.unref();
211
+ return;
212
+ }
194
213
  if (url.pathname === "/api/health") {
195
214
  const version = checkDiskVersion(rootDir);
196
215
  response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
@@ -1,4 +1,4 @@
1
- // Workspace names match case-insensitively. A model is allowed when either its label
1
+ // Workspace paths match case-insensitively. A model is allowed when either its label
2
2
  // or provider/modelId matches. All matching rules must allow it (intersection).
3
3
  // Invalid regexes are skipped with a diagnostic, never interpreted as deny-all.
4
4
  export function applicableRules(rules, workspace) {
@@ -6,7 +6,8 @@ export function applicableRules(rules, workspace) {
6
6
  try {
7
7
  const workspaceRegex = new RegExp(rule.workspacePattern, "i");
8
8
  const modelRegex = new RegExp(rule.modelPattern, "i");
9
- return workspaceRegex.test(workspace.name) ? [{ rule, modelRegex }] : [];
9
+ const workspacePath = workspace.path.replace(/[\\/]+/g, "/");
10
+ return workspaceRegex.test(workspacePath) ? [{ rule, modelRegex }] : [];
10
11
  }
11
12
  catch (error) {
12
13
  console.warn(`模型过滤规则 ${rule.id} 无效,已跳过:${rule.workspacePattern} → ${rule.modelPattern}:${String(error)}`);
@@ -8,7 +8,7 @@ import { resolvePiCommand, shellQuote } from "./pi-command.js";
8
8
  import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, rosterPayload, } from "./task-tool.js";
9
9
  import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
10
10
  import { loadConversationImages } from "./upload.js";
11
- import { describeToolCall, describeToolCallArgs, extractToolResultText } from "./tool-calls.js";
11
+ import { describeToolCall, describeToolCallArgs, describeToolCallInProgress, extractToolResultText } from "./tool-calls.js";
12
12
  /** Deterministic offline runtime used by tests and local UI development. */
13
13
  export class WelcomeRuntime {
14
14
  async *answer({ prompt, signal }) {
@@ -827,6 +827,18 @@ export class PiRpcRuntime {
827
827
  // record is announced once per distinct type and then tallied instead of logging per event.
828
828
  const announced = new Set();
829
829
  const repeats = new Map();
830
+ /**
831
+ * THE ARGUMENT TEXT EACH CALL HAS PRODUCED SO FAR, keyed by the contentIndex `toolcall_start` named.
832
+ *
833
+ * Held per TURN and cleared with it, so a second turn writing a different call to the same index
834
+ * starts from an empty string rather than from the previous call's file path. The fragments are
835
+ * still handed on one at a time as `delta`; this is only what a line naming the call is read out of,
836
+ * and a block that could show 「正在生成参数…」 instead of the file it is about to open was telling
837
+ * the reader less than the record already knew.
838
+ */
839
+ const argumentText = new Map();
840
+ /** The tool name `toolcall_start` announced for each index, so a later fragment can still name it. */
841
+ const toolNames = new Map();
830
842
  const noteUnhandled = (key) => {
831
843
  if (announced.has(key))
832
844
  repeats.set(key, (repeats.get(key) ?? 0) + 1);
@@ -977,10 +989,23 @@ export class PiRpcRuntime {
977
989
  // would mean guessing at a document that is not finished. It is handed on as it comes.
978
990
  if (event?.type === "text_delta")
979
991
  yield event.delta ?? "";
980
- else if (event?.type === "toolcall_start" && typeof event.contentIndex === "number")
992
+ else if (event?.type === "toolcall_start" && typeof event.contentIndex === "number") {
993
+ // A fresh index means a fresh call, so anything the PREVIOUS call at this index wrote is
994
+ // dropped here rather than carried into this one's first fragment.
995
+ argumentText.delete(event.contentIndex);
996
+ toolNames.set(event.contentIndex, toolNameOf(event.partial));
981
997
  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 } : {}) });
998
+ }
999
+ else if (event?.type === "toolcall_delta" && typeof event.contentIndex === "number") {
1000
+ const delta = typeof event.delta === "string" ? event.delta : "";
1001
+ // The NAME IS CARRIED FORWARD because the line naming the call needs it: `toolcall_start`
1002
+ // is the only event that carries one, and by the second fragment the mapping has to choose
1003
+ // between a `read`'s path and a `bash`'s command.
1004
+ const name = toolNames.get(event.contentIndex) ?? "";
1005
+ argumentText.set(event.contentIndex, `${argumentText.get(event.contentIndex) ?? ""}${delta}`);
1006
+ const detail = describeToolCallInProgress(name, argumentText.get(event.contentIndex) ?? "");
1007
+ onToolPreparing?.({ contentIndex: event.contentIndex, name, ...(delta ? { delta } : {}), ...(detail ? { detail } : {}) });
1008
+ }
984
1009
  else
985
1010
  noteUnhandled(`message_update:${JSON.stringify(event?.type ?? "(missing)")}`);
986
1011
  continue;
@@ -99,8 +99,7 @@ export type TerminalEvent = {
99
99
  reason?: string;
100
100
  };
101
101
  /**
102
- * Reads the opt-in flag. Anything other than an explicit truthy string leaves the terminal OFF, so a
103
- * typo in a deployment config fails closed rather than silently exposing a shell.
102
+ * Reads the opt-in flag. Anything other than an explicit truthy string leaves the terminal OFF.
104
103
  */
105
104
  export declare function isTerminalEnabled(env?: NodeJS.ProcessEnv): boolean;
106
105
  /** The sentence a browser shows when the terminal is missing. Says WHICH of the three gates closed. */
@@ -164,8 +164,7 @@ const MAX_CHUNK_BYTES = 64 * 1024;
164
164
  /** Hard per-session backlog. Beyond this the session is considered runaway and is killed. */
165
165
  const MAX_BACKLOG_BYTES = 4 * 1024 * 1024;
166
166
  /**
167
- * Reads the opt-in flag. Anything other than an explicit truthy string leaves the terminal OFF, so a
168
- * typo in a deployment config fails closed rather than silently exposing a shell.
167
+ * Reads the opt-in flag. Anything other than an explicit truthy string leaves the terminal OFF.
169
168
  */
170
169
  export function isTerminalEnabled(env = process.env) {
171
170
  const raw = env.SUPER_PI_TERMINAL?.trim().toLowerCase();
@@ -174,7 +173,7 @@ export function isTerminalEnabled(env = process.env) {
174
173
  /** The sentence a browser shows when the terminal is missing. Says WHICH of the three gates closed. */
175
174
  export function terminalDisabledReason(env = process.env) {
176
175
  if (!isTerminalEnabled(env))
177
- return "终端未启用。设置 SUPER_PI_TERMINAL=1 后重启 Super Pi 即可使用。";
176
+ return "终端未启用。设置 SUPER_PI_TERMINAL=1 后重启即可使用。";
178
177
  if (ptyLoadFailure)
179
178
  return `node-pty 未能加载(${ptyLoadFailure})。请重新安装依赖,或确认 node-pty 版本不低于 ${MINIMUM_PTY_VERSION}。`;
180
179
  return "";
@@ -62,6 +62,27 @@ export declare function extractToolResultText(result: unknown): ToolResultSummar
62
62
  * shape can be regression-tested at all.
63
63
  */
64
64
  export declare function describeToolCall(name: string, args: unknown): string | undefined;
65
+ /**
66
+ * THE SAME LINE, READ OUT OF A CALL THAT IS STILL BEING WRITTEN.
67
+ *
68
+ * The block for a call appears the instant the model starts writing it, and for an `edit` that is several
69
+ * seconds before the arguments are complete. Showing 「工具 · 正在生成参数…」 through all of it says the
70
+ * reader nothing they can act on - not WHICH tool, and not what it is aimed at, which are the two things
71
+ * the finished block is going to say. So the same `describeToolCall` mapping runs against the fragments
72
+ * that have arrived so far.
73
+ *
74
+ * TWO STEPS, because a partial JSON document almost never parses. The fragments are tried whole first -
75
+ * that is the case where the last one closed the object. Failing that, the FIRST `"key": "value"` pair in
76
+ * the text is read out on its own, and its value does not have to be CLOSED - a fragment usually stops
77
+ * mid-string, and a path that has arrived half-written still says more than a placeholder does. A fragment
78
+ * that has produced neither yet yields `undefined`, and the caller decides what an unknown call says
79
+ * rather than this module inventing a word for it.
80
+ *
81
+ * The pair is read with a pattern rather than by parsing a repaired document: repairing JSON is a parser's
82
+ * job, and a half-repaired document here would risk naming the wrong file - the one thing this line must
83
+ * never do.
84
+ */
85
+ export declare function describeToolCallInProgress(name: string, argumentsSoFar: string): string | undefined;
65
86
  /**
66
87
  * Every parameter of one call, as a bounded list of `key`/`value` pairs a block can show when expanded.
67
88
  *
@@ -152,6 +152,46 @@ export function describeToolCall(name, args) {
152
152
  }
153
153
  return undefined;
154
154
  }
155
+ /**
156
+ * THE SAME LINE, READ OUT OF A CALL THAT IS STILL BEING WRITTEN.
157
+ *
158
+ * The block for a call appears the instant the model starts writing it, and for an `edit` that is several
159
+ * seconds before the arguments are complete. Showing 「工具 · 正在生成参数…」 through all of it says the
160
+ * reader nothing they can act on - not WHICH tool, and not what it is aimed at, which are the two things
161
+ * the finished block is going to say. So the same `describeToolCall` mapping runs against the fragments
162
+ * that have arrived so far.
163
+ *
164
+ * TWO STEPS, because a partial JSON document almost never parses. The fragments are tried whole first -
165
+ * that is the case where the last one closed the object. Failing that, the FIRST `"key": "value"` pair in
166
+ * the text is read out on its own, and its value does not have to be CLOSED - a fragment usually stops
167
+ * mid-string, and a path that has arrived half-written still says more than a placeholder does. A fragment
168
+ * that has produced neither yet yields `undefined`, and the caller decides what an unknown call says
169
+ * rather than this module inventing a word for it.
170
+ *
171
+ * The pair is read with a pattern rather than by parsing a repaired document: repairing JSON is a parser's
172
+ * job, and a half-repaired document here would risk naming the wrong file - the one thing this line must
173
+ * never do.
174
+ */
175
+ export function describeToolCallInProgress(name, argumentsSoFar) {
176
+ const text = argumentsSoFar.trim();
177
+ if (!text)
178
+ return undefined;
179
+ try {
180
+ const parsed = JSON.parse(text);
181
+ const described = describeToolCall(name, parsed);
182
+ if (described)
183
+ return described;
184
+ }
185
+ catch { /* the document is still being written, which is the normal case here */ }
186
+ const pair = /"([A-Za-z_][A-Za-z0-9_-]*)"\s*:\s*"((?:[^"\\]|\\.)*)/.exec(text);
187
+ if (!pair)
188
+ return undefined;
189
+ // The closing quote is OPTIONAL in the pattern because a fragment almost always stops mid-string, and a
190
+ // path that has arrived half-written is still worth more than the placeholder word. What is captured is
191
+ // unescaped because an escaped quote is part of the VALUE, not the end of it - without this, a file
192
+ // named with a quote in it would be displayed with a backslash the reader never typed.
193
+ return describeToolCall(name, { [pair[1]]: pair[2].replace(/\\(["\\])/g, "$1") });
194
+ }
155
195
  /**
156
196
  * How one non-string argument value is NAMED without being read.
157
197
  *
@@ -170,11 +170,18 @@ export type ToolCallNotice = {
170
170
  live?: string;
171
171
  preparing?: boolean;
172
172
  };
173
- /** WHAT THE RUNTIME HANDS A CALLER WHEN THE MODEL STARTS, OR CONTINUES, BUILDING A TOOL CALL. */
173
+ /** WHAT THE RUNTIME HANDS A CALLER WHEN THE MODEL STARTS, OR CONTINUES, BUILDING A TOOL CALL. *
174
+ *
175
+ * `detail` is the line naming what the call is aimed at - the file for a `read`, the command for a `bash`
176
+ * - read out of the arguments as far as they have arrived. It is `undefined` when nothing identifying has
177
+ * been written yet, which is a different fact from an empty one: the caller has a placeholder word for the
178
+ * second case and must not invent one for the first.
179
+ */
174
180
  export interface ToolCallPreparing {
175
181
  contentIndex: number;
176
182
  name: string;
177
183
  delta?: string;
184
+ detail?: string;
178
185
  }
179
186
  /**
180
187
  * `archivedAt` is the whole archive mechanism: a state MARKER, not a move. The conversation object never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -96,7 +96,8 @@
96
96
  "scripts/queue-dock.test.mjs",
97
97
  "scripts/md-render-browser.test.mjs",
98
98
  "scripts/right-sidebar-browser.test.mjs",
99
- "scripts/sidebar-tools-browser.test.mjs", "scripts/session-context-menu-browser.test.mjs",
99
+ "scripts/sidebar-tools-browser.test.mjs",
100
+ "scripts/session-context-menu-browser.test.mjs",
100
101
  "scripts/tool-call-blocks-browser.test.mjs",
101
102
  "scripts/files-tab-browser.test.mjs",
102
103
  "scripts/msg-tabs-browser.test.mjs",