@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.
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +3 -3
- package/dist/client/assets/{index-pD4FaDWr.js → index-mAu39Bra.js} +2 -2
- package/dist/client/index.html +1 -1
- package/dist/server/application/conversation-service.d.ts +9 -0
- package/dist/server/application/conversation-service.js +13 -0
- package/dist/server/application/run-service.d.ts +51 -0
- package/dist/server/application/run-service.js +157 -7
- package/dist/server/command-handler.js +13 -2
- package/dist/server/pi-session-events.d.ts +34 -1
- package/dist/server/pi-session-events.js +78 -20
- package/dist/server/pi-session-pool.js +12 -2
- package/dist/server/pi-session.d.ts +54 -1
- package/dist/server/pi-session.js +26 -2
- package/dist/server/runtime.d.ts +71 -7
- package/dist/server/runtime.js +108 -12
- package/dist/server/store.d.ts +7 -1
- package/dist/server/store.js +12 -2
- package/package.json +4 -1
- package/scripts/diagnose.mjs +113 -8
package/dist/server/store.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
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",
|
package/scripts/diagnose.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
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(`
|
|
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
|
|