@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:
|
|
27
|
+
"recordedAt": "2026-10-21T05:59:50.882Z"
|
|
28
28
|
}
|
package/dist/server/store.js
CHANGED
|
@@ -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
|
-
|
|
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
package/scripts/diagnose.mjs
CHANGED
|
@@ -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})。这一条本身就可能解释了前面几段的`);
|