@evandu/super-pi 0.1.85 → 0.1.86

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-B_jOwjyp.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-CNNGl4eF.css">
11
+ <script type="module" crossorigin src="/assets/index-C_giUkvT.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-B3QVoYJY.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -1,6 +1,6 @@
1
1
  import { createReadStream, existsSync } from "node:fs";
2
2
  import { spawn } from "node:child_process";
3
- import { readdir, stat } from "node:fs/promises";
3
+ import { readdir, rm, stat } from "node:fs/promises";
4
4
  import { createServer } from "node:http";
5
5
  import { homedir, networkInterfaces } from "node:os";
6
6
  import { basename, dirname, extname, join, relative, resolve } from "node:path";
@@ -10,6 +10,7 @@ import { checkDiskVersion } from "./version.js";
10
10
  import { gitDiff, gitLog, gitStatus } from "./git.js";
11
11
  import { verifyCapability } from "./capability.js";
12
12
  import { MAX_KNOWLEDGE_REQUEST_BYTES } from "./knowledge.js";
13
+ import { MAX_TOOL_BODY_BYTES } from "./tool-registry.js";
13
14
  import { packageUpdateArgs } from "./update.js";
14
15
  const MIME_TYPES = {
15
16
  ".css": "text/css; charset=utf-8",
@@ -50,6 +51,28 @@ function toolBrowserSdk() {
50
51
  }
51
52
  },
52
53
  runAndWait: async (params = {}, options) => { const run = await api.run(params); return api.wait(run.id, options); },
54
+ // THE MODEL, ASKED ON THE TOOL'S BEHALF. One call, one answer, no client to write: Super Pi owns the
55
+ // credentials, the model choice and the ceilings, and the tool only ever sees the text. It is a
56
+ // completion, not an agent - the model that answers has no tools, no files and no session history.
57
+ // Only a tool registered with permissions.llm may call it; otherwise the server refuses.
58
+ llm: (input = {}) => request("./api/llm", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input) }),
59
+ llmGet: (callId) => request("./api/llm/" + encodeURIComponent(callId), { method: "GET" }),
60
+ llmCancel: (callId) => request("./api/llm/" + encodeURIComponent(callId), { method: "DELETE" }),
61
+ llmWait: async (callId, { intervalMs = 400, signal } = {}) => {
62
+ for (;;) {
63
+ if (signal && signal.aborted) throw new DOMException("Aborted", "AbortError");
64
+ const call = await api.llmGet(callId);
65
+ if (call.status !== "running") {
66
+ if (call.status !== "succeeded") throw new Error(call.error || "Super Pi's model call did not succeed");
67
+ return call;
68
+ }
69
+ await new Promise((resolve, reject) => {
70
+ const timer = setTimeout(resolve, Math.max(50, intervalMs));
71
+ if (signal) signal.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("Aborted", "AbortError")); }, { once: true });
72
+ });
73
+ }
74
+ },
75
+ llmText: async (input = {}, options) => { const call = await api.llm(input); return (await api.llmWait(call.id, options)).text; },
53
76
  };
54
77
  Object.freeze(api);
55
78
  Object.defineProperty(window, "SuperPiTool", { value: api, writable: false, configurable: false });
@@ -206,10 +229,35 @@ export function createHttpServer(options) {
206
229
  const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
207
230
  const remote = request.socket.remoteAddress ?? "";
208
231
  const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(JSON.stringify(body)); };
232
+ if (url.pathname === "/api/tools/export") {
233
+ // NOT EXEMPT FROM THE TOKEN CHECK ABOVE, and that is deliberate: an export hands out a tool's
234
+ // whole source, so it is a management read exactly like GET and DELETE, not a capability call.
235
+ try {
236
+ const id = url.searchParams.get("id");
237
+ if (!id) {
238
+ json(400, { error: "id required" });
239
+ return;
240
+ }
241
+ const exported = await toolRegistry.exportBundle(id);
242
+ if (!exported) {
243
+ json(404, { error: "tool not found" });
244
+ return;
245
+ }
246
+ json(200, exported);
247
+ return;
248
+ }
249
+ catch (error) {
250
+ json(400, { error: error instanceof Error ? error.message : "tool export failed" });
251
+ return;
252
+ }
253
+ }
209
254
  if (url.pathname === "/api/tools") {
210
255
  try {
211
256
  if (request.method === "GET") {
212
- json(200, { tools: await toolRegistry.list() });
257
+ // The environment state is DERIVED per request and deliberately not stored: it changes when
258
+ // the user installs Python, when a tool's venv is built, and when nothing at all happens.
259
+ const tools = await toolRegistry.list();
260
+ json(200, { tools: await Promise.all(tools.map(async (tool) => ({ ...tool, environmentState: await toolRunner.environmentState(tool) }))) });
213
261
  return;
214
262
  }
215
263
  if (request.method === "DELETE") {
@@ -219,6 +267,11 @@ export function createHttpServer(options) {
219
267
  return;
220
268
  }
221
269
  const removed = await toolRegistry.remove(id);
270
+ // The venv is machine-local state that belongs to that tool and nothing else, so it goes with
271
+ // it. Leaving it behind would mean a re-registered tool of the same slug silently inherits a
272
+ // stale environment.
273
+ if (removed)
274
+ await rm(toolRunner.envDir(id), { recursive: true, force: true });
222
275
  if (removed)
223
276
  broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
224
277
  json(removed ? 200 : 404, { ok: removed });
@@ -228,13 +281,18 @@ export function createHttpServer(options) {
228
281
  json(405, { error: "method" });
229
282
  return;
230
283
  }
231
- let raw = "";
284
+ // BYTES, NOT CHARACTERS, AND FROM THE REGISTRY'S OWN CONSTANT. The old cap counted UTF-16 code
285
+ // units at 256,000, below the registry's own 512,000-byte asset limit, so JSON escaping decided
286
+ // where the real ceiling was and an exported bundle could be refused on the way back in.
287
+ let raw = "", bytes = 0;
232
288
  for await (const chunk of request) {
233
- raw += chunk;
234
- if (raw.length > 256_000) {
289
+ const piece = chunk;
290
+ bytes += piece.length;
291
+ if (bytes > MAX_TOOL_BODY_BYTES) {
235
292
  json(413, { error: "request too large" });
236
293
  return;
237
294
  }
295
+ raw += piece.toString("utf8");
238
296
  }
239
297
  const body = JSON.parse(raw);
240
298
  if (!isAuthorized(request, accessToken)) {
@@ -252,7 +310,16 @@ export function createHttpServer(options) {
252
310
  json(400, { error: "slug, name and files are required" });
253
311
  return;
254
312
  }
255
- const tool = await toolRegistry.register({ slug: body.slug, name: body.name, description: body.description, entrypoint: body.entrypoint, script: body.script, files: body.files });
313
+ // AN IMPORTED BUNDLE KEEPS THE EXPORTER'S FINGERPRINT. It describes where the tool was written
314
+ // and verified, and overwriting it with the importer's own machine would erase the very
315
+ // difference the tool list exists to show. A tool with no fingerprint gets one measured here.
316
+ let environment = body.environment;
317
+ if (environment && !environment.fingerprint) {
318
+ const local = await toolRunner.describeLocalPython();
319
+ if (local)
320
+ environment = { ...environment, fingerprint: { python: local.python, platform: local.platform, packages: environment.requires } };
321
+ }
322
+ const tool = await toolRegistry.register({ slug: body.slug, name: body.name, description: body.description, entrypoint: body.entrypoint, script: body.script, environment, permissions: body.permissions, files: body.files });
256
323
  broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
257
324
  json(201, tool);
258
325
  return;
@@ -316,7 +383,7 @@ export function createHttpServer(options) {
316
383
  toolApi(404, { error: "python script is not registered" });
317
384
  return;
318
385
  }
319
- const run = toolRunner.start({ toolId: id, script: resolve(resolved.root, resolved.tool.script.path), cwd: resolved.root, definition: resolved.tool.script, params: body.params ?? {} });
386
+ const run = toolRunner.start({ toolId: id, script: resolve(resolved.root, resolved.tool.script.path), cwd: resolved.root, definition: resolved.tool.script, params: body.params ?? {}, environment: resolved.tool.environment });
320
387
  toolApi(202, run);
321
388
  }
322
389
  catch (error) {
@@ -363,6 +430,91 @@ export function createHttpServer(options) {
363
430
  toolApi(405, { error: "method" });
364
431
  return;
365
432
  }
433
+ // ---- THE MODEL, ON THE TOOL'S OWN BEHALF ---------------------------------------------------
434
+ // A tool asking the model a question, over the same relative API as its Python jobs, so the frame
435
+ // still never learns an id, a path or a credential. Three gates, in this order:
436
+ // 1. the opaque sandbox origin - identical to every other tool API here;
437
+ // 2. the tool must CARRY the grant (`permissions.llm`), which is the thing the review card showed
438
+ // the person before they accepted the file. A tool that quietly started asking the model
439
+ // would be spending the user's quota from inside a sandbox they were told was isolated;
440
+ // 3. the run is bound to this tool id, so one frame cannot read or cancel another's answer.
441
+ const llmStart = /^api\/llm$/.exec(resource);
442
+ const llmRun = /^api\/llm\/([^/]+)$/.exec(resource);
443
+ if (llmStart || llmRun) {
444
+ if (!toolOrigin) {
445
+ toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
446
+ return;
447
+ }
448
+ // GRANT FIRST, THEN RUNTIME. What a tool is allowed to ask is a fact about the tool and does not
449
+ // depend on whether this machine happens to have a model, so a tool with no grant gets the same
450
+ // 403 whether or not Pi is installed.
451
+ if (!listed.permissions?.llm) {
452
+ toolApi(403, { error: "这个工具没有调用模型的权限;注册或导入时它必须声明 permissions: { llm: true }。" });
453
+ return;
454
+ }
455
+ if (!context.toolLlm?.available) {
456
+ toolApi(503, { error: "这台机器上没有可用的 Pi 运行时,工具无法调用模型。" });
457
+ return;
458
+ }
459
+ if (llmStart) {
460
+ if (request.method === "OPTIONS") {
461
+ response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
462
+ return;
463
+ }
464
+ if (request.method !== "POST") {
465
+ toolApi(405, { error: "method" });
466
+ return;
467
+ }
468
+ let raw = "";
469
+ for await (const chunk of request) {
470
+ raw += chunk;
471
+ if (raw.length > 64_000) {
472
+ toolApi(413, { error: "request too large" });
473
+ return;
474
+ }
475
+ }
476
+ try {
477
+ const body = JSON.parse(raw);
478
+ toolApi(202, context.toolLlm.start({ toolId: id, prompt: body.prompt ?? "", system: body.system, timeoutMs: body.timeoutMs }));
479
+ }
480
+ catch (error) {
481
+ toolApi(400, { error: error instanceof Error ? error.message : "model call failed" });
482
+ }
483
+ return;
484
+ }
485
+ if (request.method === "OPTIONS") {
486
+ response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
487
+ return;
488
+ }
489
+ let llmId;
490
+ try {
491
+ llmId = decodeURIComponent(llmRun[1]);
492
+ }
493
+ catch {
494
+ toolApi(400, { error: "Malformed tool resource" });
495
+ return;
496
+ }
497
+ if (request.method === "GET") {
498
+ const run = context.toolLlm.get(llmId, id);
499
+ if (!run) {
500
+ toolApi(404, { error: "model call not found" });
501
+ return;
502
+ }
503
+ toolApi(200, run);
504
+ return;
505
+ }
506
+ if (request.method === "DELETE") {
507
+ const run = context.toolLlm.cancel(llmId, id);
508
+ if (!run) {
509
+ toolApi(404, { error: "model call not found" });
510
+ return;
511
+ }
512
+ toolApi(200, run);
513
+ return;
514
+ }
515
+ toolApi(405, { error: "method" });
516
+ return;
517
+ }
366
518
  if (resource === "super-pi-tool.js") {
367
519
  response.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" }).end(toolBrowserSdk());
368
520
  return;
@@ -26,6 +26,7 @@ import { WsBroadcaster, WsServer } from "./ws-server.js";
26
26
  import { initRunningVersion } from "./version.js";
27
27
  import { ToolRegistry } from "./tool-registry.js";
28
28
  import { ToolRunner } from "./tool-runner.js";
29
+ import { ToolLlmService } from "./tool-llm.js";
29
30
  // ------------------------------------------------------------- Environment & paths
30
31
  const rootDir = resolve(import.meta.dirname, "../..");
31
32
  initRunningVersion(rootDir);
@@ -54,7 +55,9 @@ const toolsEndpoint = `http://${internalHost}:${port}/api/tools`;
54
55
  // ------------------------------------------------------------- Core singletons
55
56
  const store = await openStateStore(dataDir, process.env.SUPER_PI_PERSISTENCE ?? "sqlite");
56
57
  const toolRegistry = new ToolRegistry(dataDir);
57
- const toolRunner = new ToolRunner();
58
+ // The data dir is what makes a per-tool virtual environment possible: it lives at
59
+ // `<dataDir>/tool-envs/<toolId>`, beside the tools rather than inside one.
60
+ const toolRunner = new ToolRunner(dataDir);
58
61
  const initialAgentDirs = resolveAgentDirectories({
59
62
  customDirs: store.snapshot().customAgentDirs,
60
63
  rootDir,
@@ -77,6 +80,9 @@ const memory = new ActiveMemory(join(dataDir, "memories"), store, runtime);
77
80
  process.env.SUPER_PI_MEMORY_ENDPOINT = `http://${internalHost}:${port}/api/memory/tool`;
78
81
  if (runtime instanceof PiRpcRuntime)
79
82
  runtime.setMemory(memory);
83
+ // The model a mounted tool may ask. It IS Pi: `PiRpcRuntime.askOnce` spawns its own child with nothing
84
+ // attached but Pi, and a runtime without one reports itself unavailable rather than faking an answer.
85
+ const toolLlm = new ToolLlmService(runtime, rootDir);
80
86
  const knowledge = new KnowledgeBase(join(dataDir, "knowledge"), store, runtime, new VectorIndex(join(dataDir, "knowledge")));
81
87
  process.env.SUPER_PI_KNOWLEDGE_ENDPOINT = `http://${internalHost}:${port}/api/knowledge/tool`;
82
88
  if (runtime instanceof PiRpcRuntime)
@@ -169,6 +175,7 @@ const serverContext = {
169
175
  execService,
170
176
  toolRegistry,
171
177
  toolRunner,
178
+ toolLlm,
172
179
  terminalEnabled: () => terminalService.enabled,
173
180
  terminalDisabledReason: () => terminalDisabledReason(),
174
181
  };
@@ -245,6 +245,30 @@ export interface AgentRuntime {
245
245
  * Absent on the welcome runtime, which has no Pi behind it.
246
246
  */
247
247
  summarizeConversation?(conversation: Conversation, cwd: string, signal?: AbortSignal): Promise<string>;
248
+ /**
249
+ * ONE-SHOT COMPLETION, FOR A MOUNTED TOOL. A throwaway Pi child, one prompt, one answer, then gone.
250
+ *
251
+ * WHY IT IS A SEPARATE CHILD, LIKE `summarizeConversation`: a turn's child is the one reading the
252
+ * user's stream. Writing a tool's prompt at it would land in the conversation the user is watching.
253
+ *
254
+ * WHY NOTHING IS ATTACHED BUT Pi ITSELF. `--no-extensions --no-skills --no-mcp --no-context-files
255
+ * --no-prompt-templates`, the same line the knowledge reranker uses: a tool asking the model a question
256
+ * must not be able to reach the user's files, the task tools, or a session history. A model that
257
+ * spends the turn on a tool call anyway gets no answer and the call times out - the same accepted
258
+ * failure mode the reranker documents.
259
+ *
260
+ * WHY IT CHOOSES NO MODEL. Pi's own default is the user's own default, and a sandboxed frame is the
261
+ * last place that should get to pick which model spends the user's quota.
262
+ *
263
+ * Absent on a runtime with no Pi behind it, which the caller reports as a refusal rather than
264
+ * pretending a model answered.
265
+ */
266
+ askOnce?(input: {
267
+ prompt: string;
268
+ timeoutMs: number;
269
+ cwd: string;
270
+ signal?: AbortSignal;
271
+ }): Promise<string>;
248
272
  /**
249
273
  * The agent a conversation runs as, read fresh from the agent directory. Absent on a runtime with
250
274
  * no agents (the welcome runtime), where every conversation runs as Pi itself. Callers that need an
@@ -669,6 +693,16 @@ export declare class PiRpcRuntime implements AgentRuntime {
669
693
  * model; in the second the outcome is genuinely unknown and a `sending` state says so.
670
694
  */
671
695
  summarizeConversation(conversation: Conversation, cwd: string, signal?: AbortSignal): Promise<string>;
696
+ /**
697
+ * A single completion for a mounted tool. See `AgentRuntime.askOnce` for why it is its own child,
698
+ * why nothing is attached but Pi, and why it picks no model.
699
+ */
700
+ askOnce(input: {
701
+ prompt: string;
702
+ timeoutMs: number;
703
+ cwd: string;
704
+ signal?: AbortSignal;
705
+ }): Promise<string>;
672
706
  steer(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
673
707
  /**
674
708
  * The model/thinking catalog, from Pi, cached.
@@ -1360,6 +1360,51 @@ export class PiRpcRuntime {
1360
1360
  await killTree(child.pid);
1361
1361
  }
1362
1362
  }
1363
+ /**
1364
+ * A single completion for a mounted tool. See `AgentRuntime.askOnce` for why it is its own child,
1365
+ * why nothing is attached but Pi, and why it picks no model.
1366
+ */
1367
+ async askOnce(input) {
1368
+ if (!this.pi.found)
1369
+ throw new Error(this.pi.reason);
1370
+ // A throwaway session id, for the same reason the autotitle child gets one: this exchange is not
1371
+ // part of any conversation's transcript and must never be filed under one.
1372
+ const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", `tool-llm-${randomUUID()}`, "--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates"], {
1373
+ cwd: resolve(input.cwd),
1374
+ stdio: ["pipe", "pipe", "pipe"],
1375
+ shell: process.platform === "win32",
1376
+ });
1377
+ const channel = new RpcChannel(child);
1378
+ const deadline = Date.now() + input.timeoutMs;
1379
+ try {
1380
+ channel.write({ id: "tool_llm", type: "prompt", message: input.prompt });
1381
+ let settled = false, text = "";
1382
+ while (!settled && Date.now() < deadline) {
1383
+ if (input.signal?.aborted)
1384
+ throw new Error("已取消");
1385
+ const record = await channel.next(100);
1386
+ if (!record)
1387
+ continue;
1388
+ if (record.type === "message_update" && record.assistantMessageEvent?.type === "text_delta")
1389
+ text += record.assistantMessageEvent.delta ?? "";
1390
+ if (record.type === "agent_settled")
1391
+ settled = true;
1392
+ if (record.type === "response" && record.id === "tool_llm" && record.success === false)
1393
+ throw new Error(record.error ?? "Pi 拒绝了这次请求");
1394
+ if (child.exitCode !== null)
1395
+ throw new Error("模型进程意外退出");
1396
+ }
1397
+ if (!settled)
1398
+ throw new Error(`模型在 ${Math.round(input.timeoutMs / 1000)} 秒内没有返回`);
1399
+ if (!text.trim())
1400
+ throw new Error("模型没有返回任何内容");
1401
+ return text;
1402
+ }
1403
+ finally {
1404
+ if (child.exitCode === null && child.pid !== undefined)
1405
+ await killTree(child.pid);
1406
+ }
1407
+ }
1363
1408
  async steer(conversationId, message, images) {
1364
1409
  const run = this.runs.get(conversationId);
1365
1410
  if (!run || !this.canSteer(conversationId))
@@ -52,6 +52,8 @@ export interface ServerContext {
52
52
  execService: ExecService;
53
53
  toolRegistry: ToolRegistry;
54
54
  toolRunner: ToolRunner;
55
+ /** Absent only if construction failed; a runtime with no Pi reports itself through `available`. */
56
+ toolLlm?: import("./tool-llm.js").ToolLlmService;
55
57
  /**
56
58
  * Whether the right rail's terminal tab should exist, and why not when it should not.
57
59
  *
@@ -0,0 +1,65 @@
1
+ /**
2
+ * A MOUNTED TOOL ASKING THE MODEL A QUESTION - bookkeeping, ceilings and the consent gate's server half.
3
+ *
4
+ * WHY A TOOL GETS THIS AT ALL. A tool author who wants a model in their tool should not be writing a
5
+ * client: no API key to store, no provider endpoint, no retry, no streaming, no cost ceiling. Pi already
6
+ * holds the credentials and the model configuration, so Super Pi owns the call and the tool asks for it
7
+ * over the same relative API it already uses for its Python jobs.
8
+ *
9
+ * WHY IT IS A COMPLETION AND NOT AN AGENT. `askOnce` spawns Pi with `--no-extensions --no-skills
10
+ * --no-mcp --no-context-files --no-prompt-templates`, so the model answering a tool's prompt has no
11
+ * tools, no files and no history. A tool that could make the agent run things would be handing a
12
+ * sandboxed iframe the user's whole machine, and the iframe is exactly the thing that must not have it.
13
+ *
14
+ * WHY EVERY CEILING IS HERE AND NOT IN THE FRAME. The caller is an opaque sandbox on someone else's
15
+ * machine. Prompt size, answer size, duration, and how many calls may be in flight at once are all
16
+ * decided by this file, because "the tool asked nicely" is not a limit.
17
+ */
18
+ /** The slice of the runtime this needs, declared structurally for the same reason `KnowledgeRuntime` is. */
19
+ export type ToolLlmRuntime = {
20
+ /** OPTIONAL, exactly as it is on `AgentRuntime`: a runtime with no Pi behind it has none. */
21
+ askOnce?(input: {
22
+ prompt: string;
23
+ timeoutMs: number;
24
+ cwd: string;
25
+ signal?: AbortSignal;
26
+ }): Promise<string>;
27
+ };
28
+ export type ToolLlmStatus = "running" | "succeeded" | "failed" | "cancelled" | "timed_out";
29
+ export type ToolLlmRun = {
30
+ id: string;
31
+ toolId: string;
32
+ status: ToolLlmStatus;
33
+ startedAt: string;
34
+ finishedAt?: string;
35
+ /** The model's answer. Bounded by MAX_TOOL_LLM_RESPONSE_BYTES, and this is the only copy. */
36
+ text: string;
37
+ promptChars: number;
38
+ elapsedMs?: number;
39
+ error?: string;
40
+ };
41
+ export type ToolLlmRequest = {
42
+ toolId: string;
43
+ prompt: string;
44
+ system?: string;
45
+ timeoutMs?: number;
46
+ };
47
+ /** 32KB of prompt is a paragraph of a book; more than that is a tool trying to move a transcript. */
48
+ export declare const MAX_TOOL_LLM_PROMPT_BYTES = 32000;
49
+ /** Answers are clipped rather than refused: a truncated answer is still an answer, and the model may be
50
+ * mid-sentence when the ceiling lands. The clip is visible in `chars` being lower than expected. */
51
+ export declare const MAX_TOOL_LLM_RESPONSE_BYTES = 64000;
52
+ export declare const MAX_TOOL_LLM_TIMEOUT_MS: number;
53
+ export declare class ToolLlmService {
54
+ private readonly runtime;
55
+ private readonly cwd;
56
+ private readonly runs;
57
+ constructor(runtime: ToolLlmRuntime | undefined, cwd: string);
58
+ /** WHY A MISSING RUNTIME IS A SENTENCE AND NOT A 500. The welcome runtime has no Pi behind it, and a
59
+ * tool asking a question there should be told the machine cannot answer it. */
60
+ get available(): boolean;
61
+ start(request: ToolLlmRequest): ToolLlmRun;
62
+ get(id: string, toolId: string): ToolLlmRun | undefined;
63
+ cancel(id: string, toolId: string): ToolLlmRun | undefined;
64
+ wait(id: string, toolId: string, signal?: AbortSignal): Promise<ToolLlmRun>;
65
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * A MOUNTED TOOL ASKING THE MODEL A QUESTION - bookkeeping, ceilings and the consent gate's server half.
3
+ *
4
+ * WHY A TOOL GETS THIS AT ALL. A tool author who wants a model in their tool should not be writing a
5
+ * client: no API key to store, no provider endpoint, no retry, no streaming, no cost ceiling. Pi already
6
+ * holds the credentials and the model configuration, so Super Pi owns the call and the tool asks for it
7
+ * over the same relative API it already uses for its Python jobs.
8
+ *
9
+ * WHY IT IS A COMPLETION AND NOT AN AGENT. `askOnce` spawns Pi with `--no-extensions --no-skills
10
+ * --no-mcp --no-context-files --no-prompt-templates`, so the model answering a tool's prompt has no
11
+ * tools, no files and no history. A tool that could make the agent run things would be handing a
12
+ * sandboxed iframe the user's whole machine, and the iframe is exactly the thing that must not have it.
13
+ *
14
+ * WHY EVERY CEILING IS HERE AND NOT IN THE FRAME. The caller is an opaque sandbox on someone else's
15
+ * machine. Prompt size, answer size, duration, and how many calls may be in flight at once are all
16
+ * decided by this file, because "the tool asked nicely" is not a limit.
17
+ */
18
+ /** 32KB of prompt is a paragraph of a book; more than that is a tool trying to move a transcript. */
19
+ export const MAX_TOOL_LLM_PROMPT_BYTES = 32_000;
20
+ /** Answers are clipped rather than refused: a truncated answer is still an answer, and the model may be
21
+ * mid-sentence when the ceiling lands. The clip is visible in `chars` being lower than expected. */
22
+ export const MAX_TOOL_LLM_RESPONSE_BYTES = 64_000;
23
+ export const MAX_TOOL_LLM_TIMEOUT_MS = 5 * 60_000;
24
+ const DEFAULT_TOOL_LLM_TIMEOUT_MS = 60_000;
25
+ /** A tool that fires a hundred calls in a loop gets refused, loudly, instead of quietly costing money. */
26
+ const MAX_IN_FLIGHT_PER_TOOL = 3;
27
+ const MAX_IN_FLIGHT_TOTAL = 8;
28
+ export class ToolLlmService {
29
+ runtime;
30
+ cwd;
31
+ runs = new Map();
32
+ constructor(runtime, cwd) {
33
+ this.runtime = runtime;
34
+ this.cwd = cwd;
35
+ }
36
+ /** WHY A MISSING RUNTIME IS A SENTENCE AND NOT A 500. The welcome runtime has no Pi behind it, and a
37
+ * tool asking a question there should be told the machine cannot answer it. */
38
+ get available() { return typeof this.runtime?.askOnce === "function"; }
39
+ start(request) {
40
+ if (!this.available)
41
+ throw new Error("这台机器上没有可用的 Pi 运行时,工具无法调用模型。");
42
+ const prompt = typeof request.prompt === "string" ? request.prompt.trim() : "";
43
+ if (!prompt)
44
+ throw new Error("请提供要问模型的内容。");
45
+ if (Buffer.byteLength(prompt) > MAX_TOOL_LLM_PROMPT_BYTES)
46
+ throw new Error(`提问太长(上限 ${Math.round(MAX_TOOL_LLM_PROMPT_BYTES / 1024)}KB)。`);
47
+ const system = typeof request.system === "string" ? request.system.trim() : "";
48
+ if (system && Buffer.byteLength(system) > MAX_TOOL_LLM_PROMPT_BYTES)
49
+ throw new Error("系统提示太长。");
50
+ const running = [...this.runs.values()].filter((active) => active.run.status === "running");
51
+ if (running.length >= MAX_IN_FLIGHT_TOTAL)
52
+ throw new Error("已经有太多模型请求在排队,请稍后再试。");
53
+ if (running.filter((active) => active.run.toolId === request.toolId).length >= MAX_IN_FLIGHT_PER_TOOL)
54
+ throw new Error("这个工具同时在等的模型请求已经到上限了。");
55
+ const timeoutMs = Number.isFinite(request.timeoutMs) ? Math.max(1_000, Math.min(MAX_TOOL_LLM_TIMEOUT_MS, Math.floor(request.timeoutMs))) : DEFAULT_TOOL_LLM_TIMEOUT_MS;
56
+ const id = `toolllm_${randomId()}`;
57
+ const run = { id, toolId: request.toolId, status: "running", startedAt: new Date().toISOString(), text: "", promptChars: prompt.length };
58
+ const controller = new AbortController();
59
+ // The deadline is this service's own, not the frame's: a frame that stops polling does not get to keep
60
+ // a Pi process and a model call alive.
61
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
62
+ timer.unref();
63
+ const active = { run, controller, timer };
64
+ this.runs.set(id, active);
65
+ void (async () => {
66
+ try {
67
+ // Narrowed by `available` in `start`, and captured here: the service outlives any single call.
68
+ const ask = this.runtime?.askOnce?.bind(this.runtime);
69
+ if (!ask)
70
+ throw new Error("这台机器上没有可用的 Pi 运行时,工具无法调用模型。");
71
+ const answer = await ask({
72
+ prompt: system ? `${system}\n\n---\n\n${prompt}` : prompt,
73
+ timeoutMs,
74
+ cwd: this.cwd,
75
+ signal: controller.signal,
76
+ });
77
+ if (active.run.status !== "running")
78
+ return;
79
+ active.run.text = clip(answer);
80
+ }
81
+ catch (error) {
82
+ if (active.run.status !== "running")
83
+ return;
84
+ const message = error instanceof Error ? error.message : String(error);
85
+ active.run.status = controller.signal.aborted && active.run.finishedAt === undefined ? "timed_out" : "failed";
86
+ active.run.error = controller.signal.aborted ? `模型在 ${Math.round(timeoutMs / 1000)} 秒内没有返回` : message;
87
+ }
88
+ finally {
89
+ clearTimeout(timer);
90
+ if (active.run.status === "running")
91
+ active.run.status = "succeeded";
92
+ active.run.finishedAt ??= new Date().toISOString();
93
+ active.run.elapsedMs = Date.now() - Date.parse(active.run.startedAt);
94
+ // Bounded memory: a tool that made a thousand calls must not leave a thousand answers in the map.
95
+ if (this.runs.size > 64) {
96
+ const oldest = [...this.runs.entries()].filter(([, entry]) => entry.run.status !== "running").sort((a, b) => Date.parse(a[1].run.finishedAt ?? "") - Date.parse(b[1].run.finishedAt ?? "")).slice(0, this.runs.size - 64);
97
+ for (const [key] of oldest)
98
+ this.runs.delete(key);
99
+ }
100
+ }
101
+ })();
102
+ return copy(run);
103
+ }
104
+ get(id, toolId) { const active = this.runs.get(id); return active?.run.toolId === toolId ? copy(active.run) : undefined; }
105
+ cancel(id, toolId) {
106
+ const active = this.runs.get(id);
107
+ if (!active || active.run.toolId !== toolId)
108
+ return undefined;
109
+ if (active.run.status === "running") {
110
+ active.controller.abort();
111
+ active.run.status = "cancelled";
112
+ active.run.error = "已取消";
113
+ }
114
+ return copy(active.run);
115
+ }
116
+ async wait(id, toolId, signal) {
117
+ const deadline = Date.now() + MAX_TOOL_LLM_TIMEOUT_MS + 5_000;
118
+ for (;;) {
119
+ const run = this.get(id, toolId);
120
+ if (!run)
121
+ throw new Error("这次模型请求不存在或已经过期。");
122
+ if (run.status !== "running")
123
+ return run;
124
+ if (signal?.aborted)
125
+ throw new Error("已取消");
126
+ if (Date.now() > deadline)
127
+ throw new Error("等待模型回答超时。");
128
+ await new Promise((resolve) => setTimeout(resolve, 250));
129
+ }
130
+ }
131
+ }
132
+ function clip(text) {
133
+ const buffer = Buffer.from(text, "utf8");
134
+ if (buffer.length <= MAX_TOOL_LLM_RESPONSE_BYTES)
135
+ return text;
136
+ return buffer.subarray(0, MAX_TOOL_LLM_RESPONSE_BYTES).toString("utf8");
137
+ }
138
+ function copy(run) { return { ...run }; }
139
+ function randomId() { return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2); }