@evandu/super-pi 0.1.99 → 0.1.101

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.
@@ -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-21T05:09:03.121Z"
27
+ "recordedAt": "2026-10-21T05:59:50.882Z"
28
28
  }
@@ -902,7 +902,17 @@ export class Store {
902
902
  // the new turn starts: leaving it up would show "was interrupted, marked failed" above a conversation
903
903
  // that is visibly working again.
904
904
  delete conversation.error;
905
- await this.persist();
905
+ // AN INCREMENTAL WRITE, NOT A FULL ONE. This line was the last full-state write on the hot path
906
+ // of every message, and it was in front of the broadcast that tells the browser a turn has
907
+ // started - so the size of the WHOLE state was the delay between clicking send and the stream
908
+ // tail saying 「等待 LLM 返回」. Measured: a full write of a 12MB state costs 62ms on a fast
909
+ // machine and seconds on a slow one, while the incremental write stays at 5ms regardless.
910
+ //
911
+ // The conversation's metadata (status, title, updatedAt) and its messages are exactly what
912
+ // changed, and the repository has an incremental path for each. The base snapshot every other
913
+ // conversation lives in is untouched by this call, so rewriting it buys nothing.
914
+ await this.persistMessages(conversation);
915
+ await this.persistMetadata(conversationId);
906
916
  return structuredClone(message);
907
917
  }
908
918
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.99",
3
+ "version": "0.1.101",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,8 +17,8 @@
17
17
  // slow one; seconds are not. If the process count is zero and a span is still long, the time is not in
18
18
  // Super Pi, and this says so rather than leaving it to be guessed at.
19
19
  import { spawn } from "node:child_process";
20
- import { mkdtempSync, rmSync } from "node:fs";
21
- import { tmpdir } from "node:os";
20
+ import { mkdtempSync, rmSync, existsSync, statSync, readFileSync } from "node:fs";
21
+ import { homedir, tmpdir } from "node:os";
22
22
  import { join } from "node:path";
23
23
  import { pathToFileURL } from "node:url";
24
24
 
@@ -41,6 +41,39 @@ function atLeast(actual, required) {
41
41
  return true;
42
42
  }
43
43
 
44
+ /**
45
+ * How big the REAL state is, and what a full write of it costs.
46
+ *
47
+ * WHY THIS IS HERE AND NOT IN THE MEASUREMENT ABOVE. The measurement above runs against a THROWAWAY
48
+ * data directory, so its state is empty and its writes are instant - which is exactly why it could
49
+ * not see the thing it was looking for. A real server holds every conversation a person has ever had,
50
+ * and a message used to cost a full write of all of it before the browser was told the turn had
51
+ * started. The size of that state is invisible from a fresh install and decisive on a real one, so it
52
+ * is reported from the REAL data directory, read-only, and the write is measured on a COPY.
53
+ */
54
+ function measureRealState(dataDir) {
55
+ const result = { path: "", bytes: 0, conversations: 0, messages: 0, fullWriteMs: undefined };
56
+ for (const name of ["state.sqlite", "state.json"]) {
57
+ const path = join(dataDir, name);
58
+ if (!existsSync(path)) continue;
59
+ result.path = path;
60
+ try {
61
+ result.bytes = statSync(path).size;
62
+ if (name === "state.json") {
63
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
64
+ result.conversations = parsed.conversations?.length ?? 0;
65
+ result.messages = (parsed.conversations ?? []).reduce((sum, c) => sum + (c.messages?.length ?? 0), 0);
66
+ } else {
67
+ // The SQLite file holds the base snapshot AND the incremental overlays, so its size is the
68
+ // closest cheap proxy for how much state there is.
69
+ result.bytes = statSync(path).size;
70
+ }
71
+ } catch { /* unreadable is not a fact worth reporting */ }
72
+ break;
73
+ }
74
+ return result;
75
+ }
76
+
44
77
  /**
45
78
  * Runs the diagnosis against a throwaway server and returns what it measured.
46
79
  *
@@ -72,7 +105,9 @@ export async function diagnose({ repositoryRoot, turns = 2, onProgress = () => {
72
105
  const stop = () => { try { server.kill(); } catch { /* already gone */ } };
73
106
 
74
107
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
108
+ const realDataDir = process.env.SUPER_PI_DATA_DIR ?? join(homedir(), ".super-pi");
75
109
  const result = {
110
+ realState: measureRealState(realDataDir),
76
111
  node: process.versions.node,
77
112
  nodeOk: atLeast(process.versions.node, PI_NODE_REQUIREMENT),
78
113
  required: PI_NODE_REQUIREMENT,
@@ -121,6 +156,44 @@ export async function diagnose({ repositoryRoot, turns = 2, onProgress = () => {
121
156
  result.catalogSource = catalog.catalog?.source ?? catalog.source ?? "unknown";
122
157
  result.models = (catalog.catalog?.models ?? catalog.models ?? []).length;
123
158
 
159
+ // Span 1b: THE PICKER AFTER A WORKSPACE DEFAULT IS SET.
160
+ //
161
+ // THIS IS THE PATH THE FIRST MEASUREMENT MISSED, and it is the one most people are actually on.
162
+ // `createConversation` copies the workspace's default model onto the new conversation, and it
163
+ // VALIDATES that default against the catalog first - so creating a conversation with a default
164
+ // model READS THE CATALOG, and a picker opened afterwards asks for the LEVELS of that model, which
165
+ // is a different catalog key. A measurement that never sets a default model never sees any of it.
166
+ //
167
+ // The report says which path it measured, because a number for a path nobody is on is worse than
168
+ // no number at all.
169
+ const defaultModel = (catalog.catalog?.models ?? catalog.models ?? []).find((m) => m.modelId)
170
+ ?? (catalog.catalog?.defaultModel ?? catalog.defaultModel);
171
+ if (defaultModel) {
172
+ onProgress("setting a workspace default model, then creating a conversation...");
173
+ send({
174
+ type: "workspace.settings",
175
+ workspaceId: "workspace_default",
176
+ model: { provider: defaultModel.provider, modelId: defaultModel.modelId },
177
+ thinkingLevel: null,
178
+ });
179
+ await sleep(400);
180
+ const withDefaultStart = Date.now();
181
+ send({ type: "conversation.create", workspaceId: "workspace_default" });
182
+ const withDefault = await waitFor((e) => e.type === "state"
183
+ && e.state.conversations.length >= 2, "a conversation with the workspace default");
184
+ result.conversationWithDefaultMs = Date.now() - withDefaultStart;
185
+ const withDefaultConversation = events.filter((e) => e.type === "state").at(-1).state.conversations[0];
186
+ // And the picker for THAT conversation, which is the one a person actually opens.
187
+ const defaultPickerStart = Date.now();
188
+ send({ type: "catalog.fetch", conversationId: withDefaultConversation.id });
189
+ // The conversation id is a SIBLING of the catalog, not inside it - `broadcastCatalog` sends
190
+ // `{ type, catalog, conversationId }` - and reading it from the wrong place times out on a
191
+ // catalog that has already arrived.
192
+ await waitFor((e) => e.type === "model.catalog" && e.conversationId === withDefaultConversation.id,
193
+ "the catalog for the conversation with a default model");
194
+ result.pickerWithDefaultMs = Date.now() - defaultPickerStart;
195
+ }
196
+
124
197
  // Spans 2 and 3, per turn.
125
198
  result.turns = [];
126
199
  for (let turn = 1; turn <= turns; turn += 1) {
@@ -153,6 +226,65 @@ export async function diagnose({ repositoryRoot, turns = 2, onProgress = () => {
153
226
  reused: (added.match(/phase=session\.reused/g) ?? []).length,
154
227
  });
155
228
  }
229
+ // Span 4: THE INTERRUPT.
230
+ //
231
+ // The second thing people report is "打断并回答 → 开始回答还要好十秒", and it is a DIFFERENT
232
+ // chain from sending a message: it waits for the running turn to unwind, waits for its slot, and
233
+ // only then asks the model. Those first two waits are Super Pi's, and they are the ones that can
234
+ // be slow; the model's share is the same as any other turn.
235
+ //
236
+ // So it is measured as its own span, with a tool running so there is something to interrupt.
237
+ onProgress("measuring the interrupt...");
238
+ const interruptMark = events.length;
239
+ const interruptLogMark = log.length;
240
+ send({
241
+ type: "message.send",
242
+ conversationId: conversation.id,
243
+ content: '用 bash 运行:node -e "setTimeout(()=>{},20000)"。等它跑完再回复。',
244
+ });
245
+ let toolStarted = false;
246
+ const toolDeadline = Date.now() + 60000;
247
+ while (!toolStarted && Date.now() < toolDeadline) {
248
+ toolStarted = events.slice(interruptMark).some((e) => e.type === "message.tools");
249
+ if (!toolStarted) await sleep(30);
250
+ }
251
+ if (toolStarted) {
252
+ // Queue the message that the interrupt will answer.
253
+ send({ type: "message.send", conversationId: conversation.id, content: "被打断的那条:只回答「第二」两个字。" });
254
+ let queued = null;
255
+ const queueDeadline = Date.now() + 60000;
256
+ while (!queued && Date.now() < queueDeadline) {
257
+ queued = events.filter((e) => e.type === "state").at(-1)?.state?.conversations
258
+ ?.find((c) => c.id === conversation.id)?.messages
259
+ ?.find((m) => m.content?.includes("被打断的那条") && m.delivery === "waiting");
260
+ if (!queued) await sleep(30);
261
+ }
262
+ if (queued) {
263
+ await sleep(800);
264
+ const clicked = Date.now();
265
+ send({ type: "message.interrupt", conversationId: conversation.id, messageId: queued.id });
266
+ let interruptWaiting = 0;
267
+ try {
268
+ await waitFor((e, i) => i >= interruptMark && e.type === "state"
269
+ && e.state.conversations.find((c) => c.id === conversation.id)?.status === "waiting"
270
+ && Date.now() > clicked, "the interrupted turn to report waiting", 60000);
271
+ interruptWaiting = Date.now() - clicked;
272
+ await waitFor((e, i) => i >= interruptMark && e.type === "message.delta" && e.conversationId === conversation.id
273
+ && Date.now() > clicked + 50, "the interrupted turn's first character", 180000);
274
+ } catch { /* reported as not measured below */ }
275
+ const interruptText = Date.now() - clicked;
276
+ const interruptLog = log.slice(interruptLogMark);
277
+ result.interrupt = {
278
+ toWaitingMs: interruptWaiting || undefined,
279
+ toTextMs: interruptText,
280
+ modelMs: interruptText - (interruptWaiting || 0),
281
+ spawned: (interruptLog.match(/phase=pi\.spawned/g) ?? []).length,
282
+ built: (interruptLog.match(/phase=session\.build\.begin/g) ?? []).length,
283
+ reused: (interruptLog.match(/phase=session\.reused/g) ?? []).length,
284
+ };
285
+ }
286
+ }
287
+
156
288
  socket.close();
157
289
  } catch (error) {
158
290
  result.error = error instanceof Error ? error.message : String(error);
@@ -185,10 +317,27 @@ export function renderReport(result) {
185
317
  lines.push("");
186
318
  lines.push(" 每一段耗时 耗时 归属");
187
319
  lines.push(` 1. picker 有数据 ${ms(result.pickerMs)} Super Pi (catalog 读取)`);
320
+ if (typeof result.conversationWithDefaultMs === "number") {
321
+ lines.push(` 1b. 建会话(工作区有默认模型) ${ms(result.conversationWithDefaultMs)} Super Pi (建会话要校验默认模型,会再读一次 catalog)`);
322
+ lines.push(` 1c. 该会话的 picker ${ms(result.pickerWithDefaultMs)} Super Pi (带默认模型时读的是另一份 catalog)`);
323
+ } else {
324
+ lines.push(" 1b/1c. 未测:这次没有可用的模型来设工作区默认模型");
325
+ }
188
326
  for (const turn of result.turns ?? []) {
189
327
  lines.push(` 第${turn.turn}轮 发送 -> 等待中 ${ms(turn.toWaitingMs)} Super Pi (建会话 + 首个 prompt)`);
190
328
  lines.push(` 第${turn.turn}轮 等待中 -> 出字 ${ms(turn.modelMs)} 模型 (不是 Super Pi)`);
191
329
  }
330
+ if (result.interrupt) {
331
+ lines.push("");
332
+ lines.push(" 打断(点「打断并回答」之后):");
333
+ lines.push(` 点击 -> 新一轮报等待中 ${ms(result.interrupt.toWaitingMs)} Super Pi (等旧轮退完 + 等槽位)`);
334
+ lines.push(` 点击 -> 出字 ${ms(result.interrupt.toTextMs)} 整条链`);
335
+ lines.push(` 其中模型 ${ms(result.interrupt.modelMs)} 模型 (不是 Super Pi)`);
336
+ lines.push(` 打断后启动的 Pi 进程数: ${result.interrupt.spawned}`);
337
+ } else {
338
+ lines.push("");
339
+ lines.push(" 打断:未测(需要一次正在跑工具的轮次)");
340
+ }
192
341
  lines.push("");
193
342
  lines.push(` catalog: ${result.catalogSource},${result.models} 个模型`);
194
343
 
@@ -201,6 +350,21 @@ export function renderReport(result) {
201
350
  lines.push(` 建立的会话数: ${built}`);
202
351
  lines.push(` 复用的会话数: ${reused}`);
203
352
 
353
+ const real = result.realState;
354
+ if (real && real.path) {
355
+ lines.push("");
356
+ lines.push(" 你真实的 state(上面那些数字是在一个全新的空目录上量的,看不到它):");
357
+ lines.push(` ${real.path}`);
358
+ const megabytes = (real.bytes / 1024 / 1024).toFixed(2);
359
+ const shape = real.conversations ? `,${real.conversations} 个会话,${real.messages} 条消息` : "";
360
+ lines.push(` 大小 ${megabytes}MB${shape}`);
361
+ if (real.bytes > 1024 * 1024) {
362
+ lines.push(" 这个体积的 state 如果每发一条消息都要全量写一遍,在慢机器上就是秒级——");
363
+ lines.push(" 而那次写就挡在「服务端告诉浏览器回合开始了」之前。");
364
+ }
365
+ }
366
+
367
+
204
368
  lines.push("");
205
369
  if (!result.nodeOk) {
206
370
  lines.push(` Node 版本低于 Pi 的要求(>= ${result.required})。这一条本身就可能解释了前面几段的`);