@evandu/super-pi 0.1.98 → 0.1.100

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.
package/bin/super-pi.js CHANGED
@@ -5,6 +5,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { promisify } from "node:util";
8
+ import { pathToFileURL } from "node:url";
8
9
 
9
10
  const execFileAsync = promisify(execFile);
10
11
  const root = resolve(import.meta.dirname, "..");
@@ -62,7 +63,7 @@ async function running(port) {
62
63
  }
63
64
  async function stop(port) { try { const headers = process.env.SUPER_PI_ACCESS_TOKEN ? { authorization: `Bearer ${process.env.SUPER_PI_ACCESS_TOKEN}` } : {}; const response = await fetch(`http://127.0.0.1:${port}/api/shutdown`, { method: "POST", headers, signal: AbortSignal.timeout(3000) }); return response.ok || response.status === 202; } catch { return false; } }
64
65
  function openBrowser(url) { const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd.exe" : "xdg-open"; const args = platform === "darwin" ? [url] : platform === "win32" ? ["/c", "start", "", url] : [url]; spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true }).unref(); }
65
- function usage() { console.log("Usage: super-pi|super_pi <start|stop|restart|update|autostart> [options]"); console.log(" autostart enable|disable|status Configure startup at sign-in"); console.log(" --port PORT Server port (default: 3080)"); console.log(" --host HOST Server host (default: 0.0.0.0)"); console.log(" --no-browser Do not open a browser"); }
66
+ function usage() { console.log("Usage: super-pi|super_pi <start|stop|restart|update|diagnose|autostart> [options]"); console.log(" autostart enable|disable|status Configure startup at sign-in"); console.log(" diagnose Measure where a message spends its time"); console.log(" --port PORT Server port (default: 3080)"); console.log(" --host HOST Server host (default: 0.0.0.0)"); console.log(" --no-browser Do not open a browser"); }
66
67
  function paths() { if (platform === "win32") return { task: "Super Pi" }; if (platform === "darwin") return { file: join(home, "Library", "LaunchAgents", "com.evandu.super-pi.plist") }; return { file: join(process.env.XDG_CONFIG_HOME ?? join(home, ".config"), "systemd", "user", "super-pi.service") }; }
67
68
  async function system(command, args) { if (process.env.SUPER_PI_AUTOSTART_DRY_RUN === "1") { console.log(`[dry-run] ${command} ${args.join(" ")}`); return; } await execFileAsync(command, args, { windowsHide: true }); }
68
69
  async function enable(value) {
@@ -84,6 +85,25 @@ try {
84
85
  if (command === "help" || command === "--help" || command === "-h") { usage(); process.exit(0); }
85
86
  if (!command) { const value = options(args); await start(value); const url = `http://127.0.0.1:${value.port}`; if (value.browser) openBrowser(url); console.log(value.browser ? `Opened ${url}\n${runningBanner(url)}` : runningBanner(url)); process.exit(0); }
86
87
  if (command === "autostart" || command === "startup") { const action = args.shift()?.toLowerCase() ?? "status"; const value = options(args); if (action === "enable") await enable(value); else if (action === "disable") await disable(); else if (action === "status") await status(); else throw new Error("Autostart action must be enable, disable, or status."); process.exit(0); }
88
+ if (command === "diagnose" || command === "diagnose-latency") {
89
+ // MEASURE WHERE THE TIME GOES, ON THE MACHINE THAT IS COMPLAINING.
90
+ //
91
+ // It runs a throwaway server on its own port with its own data directory, so it never disturbs a
92
+ // session someone is in the middle of and never writes into their real conversations. What it prints
93
+ // is three spans that belong to DIFFERENT OWNERS - Super Pi, Super Pi, and the model - because
94
+ // conflating them is how two wrong root causes got written down before.
95
+ const turnsArg = args.find((a) => /^\d+$/.test(a));
96
+ const { diagnose, renderReport } = await import(pathToFileURL(join(root, "scripts", "diagnose.mjs")).href);
97
+ console.log(`Super Pi v${ownVersion().version} — diagnosing on ${process.platform}, node ${process.versions.node}`);
98
+ const result = await diagnose({
99
+ repositoryRoot: root,
100
+ turns: turnsArg ? Number.parseInt(turnsArg, 10) : 2,
101
+ onProgress: (line) => console.log(` ${line}`),
102
+ });
103
+ console.log("");
104
+ console.log(renderReport(result));
105
+ process.exit(result.error ? 1 : 0);
106
+ }
87
107
  if (!["start", "stop", "restart", "update"].includes(command)) throw new Error(`Unknown command: ${command}`);
88
108
  if (command === "update") { const npm = platform === "win32" ? "npm.cmd" : "npm"; const args = prefix ? ["install", "--prefix", prefix, "@evandu/super-pi"] : ["install", "-g", "@evandu/super-pi"]; const child = spawn(npm, args, { stdio: "inherit", shell: platform === "win32", env: process.env }); const code = await new Promise((resolve) => child.on("close", resolve)); if (code !== 0) process.exit(code ?? 1); }
89
109
  const value = options(args);
@@ -24,5 +24,5 @@
24
24
  "src/client/types.ts": "6487df00c164370550b5977f9b23b17e22aff4bfb59dfe1e52200042340e3769",
25
25
  "src/client/vite-env.d.ts": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5"
26
26
  },
27
- "recordedAt": "2026-10-21T02:50:44.453Z"
27
+ "recordedAt": "2026-10-21T05:29:09.656Z"
28
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.98",
3
+ "version": "0.1.100",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,7 +20,8 @@
20
20
  "scripts/restart-server.mjs",
21
21
  "README.md",
22
22
  "assets/knowledge-model",
23
- "scripts/patch-brace-expansion.mjs"
23
+ "scripts/patch-brace-expansion.mjs",
24
+ "scripts/diagnose.mjs"
24
25
  ],
25
26
  "engines": {
26
27
  "node": ">=22.19.0"
@@ -42,6 +43,7 @@
42
43
  "scripts/stream-checkpoint.test.mjs",
43
44
  "scripts/stream-transport.test.mjs",
44
45
  "scripts/pi-session.test.mjs",
46
+ "scripts/diagnose-cli.test.mjs",
45
47
  "scripts/lan-access.test.mjs",
46
48
  "scripts/terminal-pty.test.mjs",
47
49
  "scripts/persistence-repository.test.mjs",
@@ -0,0 +1,331 @@
1
+ // `super_pi diagnose` — where the time goes when a message is slow.
2
+ //
3
+ // WHY THIS EXISTS AND WHY IT IS A COMMAND. The complaint that produced it was "it is slow, take this
4
+ // many seconds, here are three of them" — and answering it from the developer's machine was wrong
5
+ // twice, because the developer machine is fast and a wall clock does not transfer. So the measurement
6
+ // has to run where the problem is, which means it has to ship with the product.
7
+ //
8
+ // WHAT IT PRINTS, AND WHY THAT SHAPE. Three spans, because they belong to DIFFERENT OWNERS and
9
+ // conflating them is how two wrong root causes got written down:
10
+ //
11
+ // 1. picker data -> Super Pi (the catalog read)
12
+ // 2. send -> waiting -> Super Pi (session build + first prompt)
13
+ // 3. waiting -> text -> the MODEL (not ours, and it grows with the conversation)
14
+ //
15
+ // And under each, the STRUCTURE — how many Pi processes were started, how many sessions were built,
16
+ // how many were reused. Structure is the part that survives the difference between a fast machine and a
17
+ // slow one; seconds are not. If the process count is zero and a span is still long, the time is not in
18
+ // Super Pi, and this says so rather than leaving it to be guessed at.
19
+ import { spawn } from "node:child_process";
20
+ import { mkdtempSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { pathToFileURL } from "node:url";
24
+
25
+ /** The Node version Pi requires. Below it, module loading and jiti transpiling are measurably slower. */
26
+ const PI_NODE_REQUIREMENT = "22.19.0";
27
+
28
+ /**
29
+ * Compares two dotted versions.
30
+ *
31
+ * `localeCompare` would do this lexically and get "22.9.0" > "22.19.0" wrong - 9 sorts after 19 as
32
+ * characters - and a person whose Node is 22.9 would be told they satisfy a requirement they do not.
33
+ */
34
+ function atLeast(actual, required) {
35
+ const left = String(actual).split(".").map((part) => Number.parseInt(part, 10) || 0);
36
+ const right = String(required).split(".").map((part) => Number.parseInt(part, 10) || 0);
37
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
38
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
39
+ if (difference !== 0) return difference >= 0;
40
+ }
41
+ return true;
42
+ }
43
+
44
+ /**
45
+ * Runs the diagnosis against a throwaway server and returns what it measured.
46
+ *
47
+ * A THROWAWAY SERVER, not the running one: it starts its own on a private port with its own data
48
+ * directory, so this never disturbs a session someone is in the middle of, and never writes into
49
+ * their real conversations. It also means the numbers describe a COLD machine's first conversation,
50
+ * which is the case being complained about.
51
+ */
52
+ export async function diagnose({ repositoryRoot, turns = 2, onProgress = () => {} } = {}) {
53
+ const dataDir = mkdtempSync(join(tmpdir(), "super-pi-diagnose-"));
54
+ const port = 35000 + Math.floor(Math.random() * 4000);
55
+ const server = spawn(process.execPath, [join(repositoryRoot, "dist/server/index.js")], {
56
+ cwd: repositoryRoot,
57
+ env: {
58
+ ...process.env,
59
+ SUPER_PI_PORT: String(port),
60
+ SUPER_PI_HOST: "127.0.0.1",
61
+ SUPER_PI_RUNTIME: "pi",
62
+ SUPER_PI_DATA_DIR: dataDir,
63
+ SUPER_PI_PERSISTENCE: "json",
64
+ // The latency trace is what turns "it was slow" into "this step was slow".
65
+ SUPER_PI_TRACE_LATENCY: "1",
66
+ },
67
+ stdio: ["ignore", "pipe", "pipe"],
68
+ });
69
+ let log = "";
70
+ server.stdout.on("data", (chunk) => { log += chunk.toString(); });
71
+ server.stderr.on("data", (chunk) => { log += chunk.toString(); });
72
+ const stop = () => { try { server.kill(); } catch { /* already gone */ } };
73
+
74
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
75
+ const result = {
76
+ node: process.versions.node,
77
+ nodeOk: atLeast(process.versions.node, PI_NODE_REQUIREMENT),
78
+ required: PI_NODE_REQUIREMENT,
79
+ };
80
+ try {
81
+ onProgress("starting a throwaway server...");
82
+ const bootStart = Date.now();
83
+ let healthy = false;
84
+ for (let attempt = 0; attempt < 600; attempt += 1) {
85
+ try { if ((await fetch(`http://127.0.0.1:${port}/api/health`)).ok) { healthy = true; break; } } catch { /* not up yet */ }
86
+ await sleep(50);
87
+ }
88
+ result.bootMs = Date.now() - bootStart;
89
+ if (!healthy) {
90
+ result.error = "the server did not become healthy";
91
+ result.log = log.slice(-2000);
92
+ return result;
93
+ }
94
+
95
+ const { WebSocket } = await import("ws");
96
+ const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`);
97
+ await new Promise((resolve, reject) => { socket.once("open", resolve); socket.once("error", reject); });
98
+ const events = [];
99
+ socket.on("message", (raw) => { try { events.push(JSON.parse(raw.toString())); } catch { /* not json */ } });
100
+ const send = (command) => socket.send(JSON.stringify(command));
101
+ const waitFor = async (predicate, label, timeoutMs = 240000) => {
102
+ const deadline = Date.now() + timeoutMs;
103
+ while (Date.now() < deadline) {
104
+ const hit = events.find(predicate);
105
+ if (hit) return hit;
106
+ await sleep(15);
107
+ }
108
+ throw new Error(`timed out waiting for ${label}`);
109
+ };
110
+
111
+ onProgress("creating a conversation...");
112
+ send({ type: "conversation.create", workspaceId: "workspace_default" });
113
+ await waitFor((e) => e.type === "state" && e.state.conversations.length > 0, "a conversation");
114
+ const conversation = events.filter((e) => e.type === "state").at(-1).state.conversations[0];
115
+
116
+ // Span 1: the picker.
117
+ const pickerStart = Date.now();
118
+ send({ type: "catalog.fetch", conversationId: conversation.id });
119
+ const catalog = await waitFor((e) => e.type === "model.catalog", "the model catalog");
120
+ result.pickerMs = Date.now() - pickerStart;
121
+ result.catalogSource = catalog.catalog?.source ?? catalog.source ?? "unknown";
122
+ result.models = (catalog.catalog?.models ?? catalog.models ?? []).length;
123
+
124
+ // Span 1b: THE PICKER AFTER A WORKSPACE DEFAULT IS SET.
125
+ //
126
+ // THIS IS THE PATH THE FIRST MEASUREMENT MISSED, and it is the one most people are actually on.
127
+ // `createConversation` copies the workspace's default model onto the new conversation, and it
128
+ // VALIDATES that default against the catalog first - so creating a conversation with a default
129
+ // model READS THE CATALOG, and a picker opened afterwards asks for the LEVELS of that model, which
130
+ // is a different catalog key. A measurement that never sets a default model never sees any of it.
131
+ //
132
+ // The report says which path it measured, because a number for a path nobody is on is worse than
133
+ // no number at all.
134
+ const defaultModel = (catalog.catalog?.models ?? catalog.models ?? []).find((m) => m.modelId)
135
+ ?? (catalog.catalog?.defaultModel ?? catalog.defaultModel);
136
+ if (defaultModel) {
137
+ onProgress("setting a workspace default model, then creating a conversation...");
138
+ send({
139
+ type: "workspace.settings",
140
+ workspaceId: "workspace_default",
141
+ model: { provider: defaultModel.provider, modelId: defaultModel.modelId },
142
+ thinkingLevel: null,
143
+ });
144
+ await sleep(400);
145
+ const withDefaultStart = Date.now();
146
+ send({ type: "conversation.create", workspaceId: "workspace_default" });
147
+ const withDefault = await waitFor((e) => e.type === "state"
148
+ && e.state.conversations.length >= 2, "a conversation with the workspace default");
149
+ result.conversationWithDefaultMs = Date.now() - withDefaultStart;
150
+ const withDefaultConversation = events.filter((e) => e.type === "state").at(-1).state.conversations[0];
151
+ // And the picker for THAT conversation, which is the one a person actually opens.
152
+ const defaultPickerStart = Date.now();
153
+ send({ type: "catalog.fetch", conversationId: withDefaultConversation.id });
154
+ // The conversation id is a SIBLING of the catalog, not inside it - `broadcastCatalog` sends
155
+ // `{ type, catalog, conversationId }` - and reading it from the wrong place times out on a
156
+ // catalog that has already arrived.
157
+ await waitFor((e) => e.type === "model.catalog" && e.conversationId === withDefaultConversation.id,
158
+ "the catalog for the conversation with a default model");
159
+ result.pickerWithDefaultMs = Date.now() - defaultPickerStart;
160
+ }
161
+
162
+ // Spans 2 and 3, per turn.
163
+ result.turns = [];
164
+ for (let turn = 1; turn <= turns; turn += 1) {
165
+ onProgress(`sending turn ${turn} of ${turns}...`);
166
+ const mark = events.length;
167
+ const logMark = log.length;
168
+ const sent = Date.now();
169
+ send({ type: "message.send", conversationId: conversation.id, content: `第${turn}问:请简短回答。` });
170
+ await waitFor((e, i) => i >= mark && e.type === "state"
171
+ && e.state.conversations.find((c) => c.id === conversation.id)?.status === "waiting", `turn ${turn} to report waiting`);
172
+ const waiting = Date.now();
173
+ await waitFor((e, i) => i >= mark && e.type === "message.delta" && e.conversationId === conversation.id, `turn ${turn}'s first character`);
174
+ const text = Date.now();
175
+ // Let the turn finish so the next one is a new turn rather than a queued one.
176
+ while (Date.now() < text + 120000) {
177
+ const done = events.slice(mark).some((e) => e.type === "state"
178
+ && e.state.conversations.find((c) => c.id === conversation.id)?.status === "completed");
179
+ if (done) break;
180
+ await sleep(30);
181
+ }
182
+ await sleep(250);
183
+ const added = log.slice(logMark);
184
+ result.turns.push({
185
+ turn,
186
+ toWaitingMs: waiting - sent,
187
+ toTextMs: text - sent,
188
+ modelMs: text - waiting,
189
+ spawned: (added.match(/phase=pi\.spawned/g) ?? []).length,
190
+ built: (added.match(/phase=session\.build\.begin/g) ?? []).length,
191
+ reused: (added.match(/phase=session\.reused/g) ?? []).length,
192
+ });
193
+ }
194
+ // Span 4: THE INTERRUPT.
195
+ //
196
+ // The second thing people report is "打断并回答 → 开始回答还要好十秒", and it is a DIFFERENT
197
+ // chain from sending a message: it waits for the running turn to unwind, waits for its slot, and
198
+ // only then asks the model. Those first two waits are Super Pi's, and they are the ones that can
199
+ // be slow; the model's share is the same as any other turn.
200
+ //
201
+ // So it is measured as its own span, with a tool running so there is something to interrupt.
202
+ onProgress("measuring the interrupt...");
203
+ const interruptMark = events.length;
204
+ const interruptLogMark = log.length;
205
+ send({
206
+ type: "message.send",
207
+ conversationId: conversation.id,
208
+ content: '用 bash 运行:node -e "setTimeout(()=>{},20000)"。等它跑完再回复。',
209
+ });
210
+ let toolStarted = false;
211
+ const toolDeadline = Date.now() + 60000;
212
+ while (!toolStarted && Date.now() < toolDeadline) {
213
+ toolStarted = events.slice(interruptMark).some((e) => e.type === "message.tools");
214
+ if (!toolStarted) await sleep(30);
215
+ }
216
+ if (toolStarted) {
217
+ // Queue the message that the interrupt will answer.
218
+ send({ type: "message.send", conversationId: conversation.id, content: "被打断的那条:只回答「第二」两个字。" });
219
+ let queued = null;
220
+ const queueDeadline = Date.now() + 60000;
221
+ while (!queued && Date.now() < queueDeadline) {
222
+ queued = events.filter((e) => e.type === "state").at(-1)?.state?.conversations
223
+ ?.find((c) => c.id === conversation.id)?.messages
224
+ ?.find((m) => m.content?.includes("被打断的那条") && m.delivery === "waiting");
225
+ if (!queued) await sleep(30);
226
+ }
227
+ if (queued) {
228
+ await sleep(800);
229
+ const clicked = Date.now();
230
+ send({ type: "message.interrupt", conversationId: conversation.id, messageId: queued.id });
231
+ let interruptWaiting = 0;
232
+ try {
233
+ await waitFor((e, i) => i >= interruptMark && e.type === "state"
234
+ && e.state.conversations.find((c) => c.id === conversation.id)?.status === "waiting"
235
+ && Date.now() > clicked, "the interrupted turn to report waiting", 60000);
236
+ interruptWaiting = Date.now() - clicked;
237
+ await waitFor((e, i) => i >= interruptMark && e.type === "message.delta" && e.conversationId === conversation.id
238
+ && Date.now() > clicked + 50, "the interrupted turn's first character", 180000);
239
+ } catch { /* reported as not measured below */ }
240
+ const interruptText = Date.now() - clicked;
241
+ const interruptLog = log.slice(interruptLogMark);
242
+ result.interrupt = {
243
+ toWaitingMs: interruptWaiting || undefined,
244
+ toTextMs: interruptText,
245
+ modelMs: interruptText - (interruptWaiting || 0),
246
+ spawned: (interruptLog.match(/phase=pi\.spawned/g) ?? []).length,
247
+ built: (interruptLog.match(/phase=session\.build\.begin/g) ?? []).length,
248
+ reused: (interruptLog.match(/phase=session\.reused/g) ?? []).length,
249
+ };
250
+ }
251
+ }
252
+
253
+ socket.close();
254
+ } catch (error) {
255
+ result.error = error instanceof Error ? error.message : String(error);
256
+ result.log = log.slice(-2000);
257
+ } finally {
258
+ stop();
259
+ await sleep(200);
260
+ rmSync(dataDir, { recursive: true, force: true });
261
+ }
262
+ return result;
263
+ }
264
+
265
+ /** Renders a diagnosis as the report a person reads. */
266
+ export function renderReport(result) {
267
+ const lines = [];
268
+ const ms = (value) => (typeof value === "number" ? `${String(value).padStart(6)}ms` : " ?");
269
+
270
+ lines.push(`Super Pi 延迟诊断 — ${new Date().toISOString()}`);
271
+ lines.push("");
272
+ lines.push(` Node ${result.node} (Pi 要求 >= ${result.required}:${result.nodeOk ? "满足" : "过低"})`);
273
+ lines.push(` server 就绪耗时 ${ms(result.bootMs)}`);
274
+
275
+ if (result.error) {
276
+ lines.push("");
277
+ lines.push(` 失败:${result.error}`);
278
+ if (result.log) lines.push(result.log.split("\n").map((l) => ` | ${l}`).join("\n"));
279
+ return lines.join("\n");
280
+ }
281
+
282
+ lines.push("");
283
+ lines.push(" 每一段耗时 耗时 归属");
284
+ lines.push(` 1. picker 有数据 ${ms(result.pickerMs)} Super Pi (catalog 读取)`);
285
+ if (typeof result.conversationWithDefaultMs === "number") {
286
+ lines.push(` 1b. 建会话(工作区有默认模型) ${ms(result.conversationWithDefaultMs)} Super Pi (建会话要校验默认模型,会再读一次 catalog)`);
287
+ lines.push(` 1c. 该会话的 picker ${ms(result.pickerWithDefaultMs)} Super Pi (带默认模型时读的是另一份 catalog)`);
288
+ } else {
289
+ lines.push(" 1b/1c. 未测:这次没有可用的模型来设工作区默认模型");
290
+ }
291
+ for (const turn of result.turns ?? []) {
292
+ lines.push(` 第${turn.turn}轮 发送 -> 等待中 ${ms(turn.toWaitingMs)} Super Pi (建会话 + 首个 prompt)`);
293
+ lines.push(` 第${turn.turn}轮 等待中 -> 出字 ${ms(turn.modelMs)} 模型 (不是 Super Pi)`);
294
+ }
295
+ if (result.interrupt) {
296
+ lines.push("");
297
+ lines.push(" 打断(点「打断并回答」之后):");
298
+ lines.push(` 点击 -> 新一轮报等待中 ${ms(result.interrupt.toWaitingMs)} Super Pi (等旧轮退完 + 等槽位)`);
299
+ lines.push(` 点击 -> 出字 ${ms(result.interrupt.toTextMs)} 整条链`);
300
+ lines.push(` 其中模型 ${ms(result.interrupt.modelMs)} 模型 (不是 Super Pi)`);
301
+ lines.push(` 打断后启动的 Pi 进程数: ${result.interrupt.spawned}`);
302
+ } else {
303
+ lines.push("");
304
+ lines.push(" 打断:未测(需要一次正在跑工具的轮次)");
305
+ }
306
+ lines.push("");
307
+ lines.push(` catalog: ${result.catalogSource},${result.models} 个模型`);
308
+
309
+ const spawned = (result.turns ?? []).reduce((sum, t) => sum + t.spawned, 0);
310
+ const built = (result.turns ?? []).reduce((sum, t) => sum + t.built, 0);
311
+ const reused = (result.turns ?? []).reduce((sum, t) => sum + t.reused, 0);
312
+ lines.push("");
313
+ lines.push(" 结构计数 —— 快机器和慢机器上一样的那部分:");
314
+ lines.push(` 启动的 Pi 进程数: ${spawned}`);
315
+ lines.push(` 建立的会话数: ${built}`);
316
+ lines.push(` 复用的会话数: ${reused}`);
317
+
318
+ lines.push("");
319
+ if (!result.nodeOk) {
320
+ lines.push(` Node 版本低于 Pi 的要求(>= ${result.required})。这一条本身就可能解释了前面几段的`);
321
+ lines.push(" 大部分时间:模块加载和 jiti 转译都会更慢。请先升级 Node 再跑一次——否则这份报告");
322
+ lines.push(" 描述的是一台 Pi 本来就不该在上面运行的机器。");
323
+ lines.push("");
324
+ }
325
+ lines.push(" 怎么看这份报告:");
326
+ lines.push(" - 「启动的 Pi 进程数」是最关键的那个数。它是 0,说明 Super Pi 正在用 Pi 的 SDK,");
327
+ lines.push(" 会话被复用了;如果每一轮都很大,说明会话层根本没生效。");
328
+ lines.push(" - 第 1、2 段是 Super Pi 自己的。如果这两段是秒级,这份报告就是该发出来的东西。");
329
+ lines.push(" - 第 3 段是模型,会随对话变长。Super Pi 改不了它。");
330
+ return lines.join("\n");
331
+ }