@evandu/super-pi 0.1.98 → 0.1.99

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:09:03.121Z"
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.99",
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,217 @@
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
+ // Spans 2 and 3, per turn.
125
+ result.turns = [];
126
+ for (let turn = 1; turn <= turns; turn += 1) {
127
+ onProgress(`sending turn ${turn} of ${turns}...`);
128
+ const mark = events.length;
129
+ const logMark = log.length;
130
+ const sent = Date.now();
131
+ send({ type: "message.send", conversationId: conversation.id, content: `第${turn}问:请简短回答。` });
132
+ await waitFor((e, i) => i >= mark && e.type === "state"
133
+ && e.state.conversations.find((c) => c.id === conversation.id)?.status === "waiting", `turn ${turn} to report waiting`);
134
+ const waiting = Date.now();
135
+ await waitFor((e, i) => i >= mark && e.type === "message.delta" && e.conversationId === conversation.id, `turn ${turn}'s first character`);
136
+ const text = Date.now();
137
+ // Let the turn finish so the next one is a new turn rather than a queued one.
138
+ while (Date.now() < text + 120000) {
139
+ const done = events.slice(mark).some((e) => e.type === "state"
140
+ && e.state.conversations.find((c) => c.id === conversation.id)?.status === "completed");
141
+ if (done) break;
142
+ await sleep(30);
143
+ }
144
+ await sleep(250);
145
+ const added = log.slice(logMark);
146
+ result.turns.push({
147
+ turn,
148
+ toWaitingMs: waiting - sent,
149
+ toTextMs: text - sent,
150
+ modelMs: text - waiting,
151
+ spawned: (added.match(/phase=pi\.spawned/g) ?? []).length,
152
+ built: (added.match(/phase=session\.build\.begin/g) ?? []).length,
153
+ reused: (added.match(/phase=session\.reused/g) ?? []).length,
154
+ });
155
+ }
156
+ socket.close();
157
+ } catch (error) {
158
+ result.error = error instanceof Error ? error.message : String(error);
159
+ result.log = log.slice(-2000);
160
+ } finally {
161
+ stop();
162
+ await sleep(200);
163
+ rmSync(dataDir, { recursive: true, force: true });
164
+ }
165
+ return result;
166
+ }
167
+
168
+ /** Renders a diagnosis as the report a person reads. */
169
+ export function renderReport(result) {
170
+ const lines = [];
171
+ const ms = (value) => (typeof value === "number" ? `${String(value).padStart(6)}ms` : " ?");
172
+
173
+ lines.push(`Super Pi 延迟诊断 — ${new Date().toISOString()}`);
174
+ lines.push("");
175
+ lines.push(` Node ${result.node} (Pi 要求 >= ${result.required}:${result.nodeOk ? "满足" : "过低"})`);
176
+ lines.push(` server 就绪耗时 ${ms(result.bootMs)}`);
177
+
178
+ if (result.error) {
179
+ lines.push("");
180
+ lines.push(` 失败:${result.error}`);
181
+ if (result.log) lines.push(result.log.split("\n").map((l) => ` | ${l}`).join("\n"));
182
+ return lines.join("\n");
183
+ }
184
+
185
+ lines.push("");
186
+ lines.push(" 每一段耗时 耗时 归属");
187
+ lines.push(` 1. picker 有数据 ${ms(result.pickerMs)} Super Pi (catalog 读取)`);
188
+ for (const turn of result.turns ?? []) {
189
+ lines.push(` 第${turn.turn}轮 发送 -> 等待中 ${ms(turn.toWaitingMs)} Super Pi (建会话 + 首个 prompt)`);
190
+ lines.push(` 第${turn.turn}轮 等待中 -> 出字 ${ms(turn.modelMs)} 模型 (不是 Super Pi)`);
191
+ }
192
+ lines.push("");
193
+ lines.push(` catalog: ${result.catalogSource},${result.models} 个模型`);
194
+
195
+ const spawned = (result.turns ?? []).reduce((sum, t) => sum + t.spawned, 0);
196
+ const built = (result.turns ?? []).reduce((sum, t) => sum + t.built, 0);
197
+ const reused = (result.turns ?? []).reduce((sum, t) => sum + t.reused, 0);
198
+ lines.push("");
199
+ lines.push(" 结构计数 —— 快机器和慢机器上一样的那部分:");
200
+ lines.push(` 启动的 Pi 进程数: ${spawned}`);
201
+ lines.push(` 建立的会话数: ${built}`);
202
+ lines.push(` 复用的会话数: ${reused}`);
203
+
204
+ lines.push("");
205
+ if (!result.nodeOk) {
206
+ lines.push(` Node 版本低于 Pi 的要求(>= ${result.required})。这一条本身就可能解释了前面几段的`);
207
+ lines.push(" 大部分时间:模块加载和 jiti 转译都会更慢。请先升级 Node 再跑一次——否则这份报告");
208
+ lines.push(" 描述的是一台 Pi 本来就不该在上面运行的机器。");
209
+ lines.push("");
210
+ }
211
+ lines.push(" 怎么看这份报告:");
212
+ lines.push(" - 「启动的 Pi 进程数」是最关键的那个数。它是 0,说明 Super Pi 正在用 Pi 的 SDK,");
213
+ lines.push(" 会话被复用了;如果每一轮都很大,说明会话层根本没生效。");
214
+ lines.push(" - 第 1、2 段是 Super Pi 自己的。如果这两段是秒级,这份报告就是该发出来的东西。");
215
+ lines.push(" - 第 3 段是模型,会随对话变长。Super Pi 改不了它。");
216
+ return lines.join("\n");
217
+ }