@evandu/super-pi 0.1.102 → 0.1.104

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.
@@ -1634,17 +1634,27 @@ export class Store {
1634
1634
  * was written into the transcript when the user pressed send, long before Pi ever saw it - and it stays
1635
1635
  * exactly where it was, now with the agent answering it. Deleting it is what made a steered reply appear
1636
1636
  * with no question above it.
1637
+ *
1638
+ * `messageId` says WHICH message Pi picked up, for the case where position cannot answer it. A message
1639
+ * handed to Pi's follow-up queue was never CLAIMED here - it is still `waiting`, because Super Pi keeps
1640
+ * it in its own list so the user can still withdraw it - and a conversation can hold a `waiting` message
1641
+ * and a `sending` one at the same time. The caller resolves the delivery by the text Pi announced,
1642
+ * which is the same text Pi itself matched on.
1637
1643
  */
1638
- async deliverWaitingMessage(conversationId, agentIdentity) {
1644
+ async deliverWaitingMessage(conversationId, agentIdentity, messageId) {
1639
1645
  const conversation = this.requireConversation(conversationId);
1640
1646
  // Looks for the single message Pi is currently answering. A `role: "tool"` 后台回传 that has been
1641
1647
  // steered or taken is on the same delivery state machine as a user message, so it is the answer
1642
1648
  // target too; the assistant placeholder still lands directly below it and the role/identity is read
1643
1649
  // from the conversation (not the message) when the turn opens.
1644
- const index = conversation.messages.findIndex((item) => (item.role === "user" || item.role === "tool") && item.delivery === "sending");
1650
+ const index = messageId
1651
+ ? conversation.messages.findIndex((item) => item.id === messageId)
1652
+ : conversation.messages.findIndex((item) => (item.role === "user" || item.role === "tool") && item.delivery === "sending");
1645
1653
  if (index < 0)
1646
1654
  return undefined;
1647
1655
  const user = conversation.messages[index];
1656
+ if (user.role !== "user" && user.role !== "tool")
1657
+ return undefined;
1648
1658
  delete user.delivery;
1649
1659
  const assistant = { id: id("message"), role: "assistant", content: "", createdAt: now(), ...(agentIdentity ? { agentIdentity: structuredClone(agentIdentity) } : {}) };
1650
1660
  conversation.messages.splice(index + 1, 0, assistant);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.102",
3
+ "version": "0.1.104",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,6 +43,8 @@
43
43
  "scripts/stream-checkpoint.test.mjs",
44
44
  "scripts/stream-transport.test.mjs",
45
45
  "scripts/pi-session.test.mjs",
46
+ "scripts/pi-model-switch.test.mjs",
47
+ "scripts/pi-thinking-levels.test.mjs",
46
48
  "scripts/diagnose-cli.test.mjs",
47
49
  "scripts/lan-access.test.mjs",
48
50
  "scripts/terminal-pty.test.mjs",
@@ -75,6 +77,7 @@
75
77
  "scripts/agent-vis-records.test.mjs",
76
78
  "scripts/queue-store.test.mjs",
77
79
  "scripts/followup.test.mjs",
80
+ "scripts/pi-followup-queue.test.mjs",
78
81
  "scripts/md-render.test.mjs",
79
82
  "scripts/revert-session-format.test.mjs",
80
83
  "scripts/revert-session-file.test.mjs",
@@ -21,6 +21,7 @@ import { mkdtempSync, rmSync, existsSync, statSync, readFileSync } from "node:fs
21
21
  import { homedir, tmpdir } from "node:os";
22
22
  import { join } from "node:path";
23
23
  import { pathToFileURL } from "node:url";
24
+ import { createRequire } from "node:module";
24
25
 
25
26
  /** The Node version Pi requires. Below it, module loading and jiti transpiling are measurably slower. */
26
27
  const PI_NODE_REQUIREMENT = "22.19.0";
@@ -50,23 +51,42 @@ function atLeast(actual, required) {
50
51
  * and a message used to cost a full write of all of it before the browser was told the turn had
51
52
  * started. The size of that state is invisible from a fresh install and decisive on a real one, so it
52
53
  * is reported from the REAL data directory, read-only, and the write is measured on a COPY.
54
+ *
55
+ * THE NUMBER THAT MATTERS IS THE STATE, NOT THE FILE. An earlier version of this report called the
56
+ * SQLite FILE size "the state", and that is a different number: the file holds the base snapshot AND
57
+ * the incremental overlay tables, and the overlay is never pruned, so the file grows with every write
58
+ * while the state the BROWSER RECEIVES does not grow the same way. The number that costs time is the
59
+ * one that is cloned (`store.snapshot()`), serialised (`JSON.stringify`) and parsed (`JSON.parse` in
60
+ * the browser) on every state broadcast - so that is what is measured here, by rebuilding the state
61
+ * out of the database and measuring its JSON.
53
62
  */
54
63
  function measureRealState(dataDir) {
55
- const result = { path: "", bytes: 0, conversations: 0, messages: 0, fullWriteMs: undefined };
64
+ const result = { path: "", bytes: 0, fileBytes: 0, conversations: 0, messages: 0, fullWriteMs: undefined };
56
65
  for (const name of ["state.sqlite", "state.json"]) {
57
66
  const path = join(dataDir, name);
58
67
  if (!existsSync(path)) continue;
59
68
  result.path = path;
60
69
  try {
61
- result.bytes = statSync(path).size;
70
+ result.fileBytes = statSync(path).size;
62
71
  if (name === "state.json") {
63
72
  const parsed = JSON.parse(readFileSync(path, "utf8"));
64
73
  result.conversations = parsed.conversations?.length ?? 0;
65
74
  result.messages = (parsed.conversations ?? []).reduce((sum, c) => sum + (c.messages?.length ?? 0), 0);
75
+ // THE SIZE THAT IS ACTUALLY SENT. The browser gets this JSON, not the file.
76
+ result.bytes = Buffer.byteLength(readFileSync(path, "utf8"), "utf8");
66
77
  } 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;
78
+ // THE SQLITE STATE, REBUILT AND MEASURED. The repository's own loader is the authority on what
79
+ // the state IS, so the report does not guess at it: the database is opened READ-ONLY, the
80
+ // overlays are folded onto the base snapshot the way `load()` folds them, and the result is
81
+ // measured as JSON - the same bytes a state broadcast carries.
82
+ const measured = measureSqliteState(path);
83
+ if (measured.bytes > 0) {
84
+ result.bytes = measured.bytes;
85
+ result.conversations = measured.conversations;
86
+ result.messages = measured.messages;
87
+ } else {
88
+ result.bytes = result.fileBytes;
89
+ }
70
90
  }
71
91
  } catch { /* unreadable is not a fact worth reporting */ }
72
92
  break;
@@ -74,6 +94,72 @@ function measureRealState(dataDir) {
74
94
  return result;
75
95
  }
76
96
 
97
+ /**
98
+ * The size of the state a SQLite store holds, measured as the JSON a broadcast would carry.
99
+ *
100
+ * READ-ONLY, and it opens its OWN connection rather than the server's: a report must not disturb the
101
+ * session someone is in the middle of, and a second read-only connection sees a consistent snapshot
102
+ * without taking any lock the server needs.
103
+ */
104
+ function measureSqliteState(path) {
105
+ let db;
106
+ const empty = { bytes: 0, conversations: 0, messages: 0 };
107
+ try {
108
+ const require = createRequire(import.meta.url);
109
+ const { DatabaseSync } = require("node:sqlite");
110
+ db = new DatabaseSync(path, { readOnly: true });
111
+ const row = db.prepare("SELECT json FROM state WHERE id=1").get();
112
+ if (!row) return empty;
113
+ const state = JSON.parse(String(row.json));
114
+ if (!Array.isArray(state.conversations)) state.conversations = [];
115
+ // THE OVERLAYS, folded the way `load()` folds them: a conversation's metadata replaces its fields
116
+ // except its messages, and its messages are the union of the base array and the overlay rows - with
117
+ // the authoritative order rows winning when they are there.
118
+ const metadata = db.prepare("SELECT conversation,json FROM metadata").all();
119
+ const overrides = db.prepare("SELECT conversation,id,json FROM messages").all();
120
+ const ordered = db.prepare("SELECT conversation,id,position,json FROM message_order").all();
121
+ const byConversation = new Map();
122
+ for (const item of overrides) {
123
+ const list = byConversation.get(String(item.conversation)) ?? new Map();
124
+ list.set(String(item.id), JSON.parse(String(item.json)));
125
+ byConversation.set(String(item.conversation), list);
126
+ }
127
+ const orderByConversation = new Map();
128
+ for (const item of ordered) {
129
+ const list = orderByConversation.get(String(item.conversation)) ?? [];
130
+ list.push({ position: Number(item.position), message: JSON.parse(String(item.json)) });
131
+ orderByConversation.set(String(item.conversation), list);
132
+ }
133
+ for (const item of metadata) {
134
+ const conversation = state.conversations.find((entry) => entry.id === String(item.conversation));
135
+ if (!conversation) continue;
136
+ const messages = conversation.messages;
137
+ for (const key of Object.keys(conversation)) Reflect.deleteProperty(conversation, key);
138
+ Object.assign(conversation, JSON.parse(String(item.json)), { messages });
139
+ }
140
+ for (const conversation of state.conversations) {
141
+ const map = byConversation.get(conversation.id) ?? new Map();
142
+ const order = orderByConversation.get(conversation.id) ?? [];
143
+ if (order.length) {
144
+ conversation.messages = order.sort((a, b) => a.position - b.position).map((entry) => entry.message);
145
+ } else if (Array.isArray(conversation.messages)) {
146
+ conversation.messages = conversation.messages.map((message) => map.get(message.id) ?? message);
147
+ } else {
148
+ conversation.messages = [];
149
+ }
150
+ }
151
+ return {
152
+ bytes: Buffer.byteLength(JSON.stringify(state), "utf8"),
153
+ conversations: state.conversations.length,
154
+ messages: state.conversations.reduce((sum, conversation) => sum + (conversation.messages?.length ?? 0), 0),
155
+ };
156
+ } catch {
157
+ return empty;
158
+ } finally {
159
+ try { db?.close(); } catch { /* already closed */ }
160
+ }
161
+ }
162
+
77
163
  /**
78
164
  * Runs the diagnosis against a throwaway server and returns what it measured.
79
165
  *
@@ -276,6 +362,14 @@ export async function diagnose({ repositoryRoot, turns = 2, onProgress = () => {
276
362
  } catch { /* reported as not measured below */ }
277
363
  const interruptText = Date.now() - clicked;
278
364
  const interruptLog = log.slice(interruptLogMark);
365
+ // The whole trace of the interrupt, in order. It is kept because the interrupt is the one path
366
+ // whose cost has turned out to be surprising, and a number without the phases around it cannot
367
+ // say which phase the number belongs to.
368
+ result.interruptTrace = interruptLog
369
+ .split("\n")
370
+ .filter((line) => line.includes("[super-pi latency]"))
371
+ .map((line) => line.replace(/.*\[super-pi latency\] /, "").trim())
372
+ .slice(0, 24);
279
373
  result.interrupt = {
280
374
  toWaitingMs: interruptWaiting || undefined,
281
375
  toTextMs: interruptText,
@@ -330,6 +424,11 @@ export function renderReport(result) {
330
424
  lines.push(` 第${turn.turn}轮 等待中 -> 出字 ${ms(turn.modelMs)} 模型 (不是 Super Pi)`);
331
425
  }
332
426
  if (result.interrupt) {
427
+ if (Array.isArray(result.interruptTrace) && result.interruptTrace.length) {
428
+ lines.push(" 打断时的完整阶段(按顺序):");
429
+ for (const entry of result.interruptTrace) lines.push(` ${entry}`);
430
+ }
431
+
333
432
  lines.push("");
334
433
  lines.push(" 打断(点「打断并回答」之后):");
335
434
  lines.push(` 点击 -> 新一轮报等待中 ${ms(result.interrupt.toWaitingMs)} Super Pi (等旧轮退完 + 等槽位)`);
@@ -366,10 +465,16 @@ export function renderReport(result) {
366
465
  lines.push(` ${real.path}`);
367
466
  const megabytes = (real.bytes / 1024 / 1024).toFixed(2);
368
467
  const shape = real.conversations ? `,${real.conversations} 个会话,${real.messages} 条消息` : "";
369
- lines.push(` 大小 ${megabytes}MB${shape}`);
468
+ lines.push(` 浏览器每次收到的那份 state:${megabytes}MB${shape}`);
469
+ if (real.fileBytes && real.fileBytes !== real.bytes) {
470
+ lines.push(` 数据库文件本身:${(real.fileBytes / 1024 / 1024).toFixed(2)}MB`);
471
+ lines.push(" (文件比 state 大是因为它还存着增量写的那几层,那些层从不被清理;");
472
+ lines.push(" 真正花时间的是上面那个数字——每次广播都要克隆它、序列化它、");
473
+ lines.push(" 再在浏览器里解析一遍。)");
474
+ }
370
475
  if (real.bytes > 1024 * 1024) {
371
- lines.push(" 这个体积的 state 如果每发一条消息都要全量写一遍,在慢机器上就是秒级——");
372
- lines.push(" 而那次写就挡在「服务端告诉浏览器回合开始了」之前。");
476
+ lines.push(" 这个体积的 state 每变一次就要整份克隆 + 整份序列化 + 浏览器整份解析,");
477
+ lines.push(" 在慢机器上每一次广播都是秒级,而一轮里会广播好几次。");
373
478
  }
374
479
  }
375
480