@evandu/super-pi 0.1.101 → 0.1.103

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.
@@ -78,6 +78,18 @@ export declare class Store {
78
78
  /** Opens an injected repository, retaining domain reconciliation here. */
79
79
  static openRepository(repository: AppStateRepository): Promise<Store>;
80
80
  setModelFilterRules(rules: import("./types.js").ModelFilterRule[]): Promise<void>;
81
+ /**
82
+ * Sets (or clears) the app-wide default model.
83
+ *
84
+ * `undefined` DELETES the key, for the same reason the workspace defaults do it that way: "follow
85
+ * Pi" has to be the ABSENCE of a value, not a frozen copy of whatever Pi's default was when the
86
+ * setting was saved. A stored copy would keep answering with a model the user had already
87
+ * removed, and Pi's own default may have changed underneath us in the meantime.
88
+ *
89
+ * The pair is written field by field, never as a spread, because the type declares exactly three
90
+ * fields and a spread would quietly start persisting whatever is added to `ModelSelection` next.
91
+ */
92
+ setDefaultModel(model?: ModelSelection): Promise<void>;
81
93
  snapshot(): AppState;
82
94
  createWorkspace(name: string, path?: string): Promise<Workspace>;
83
95
  renameWorkspace(workspaceId: string, name: string): Promise<Workspace>;
@@ -533,8 +545,14 @@ export declare class Store {
533
545
  * was written into the transcript when the user pressed send, long before Pi ever saw it - and it stays
534
546
  * exactly where it was, now with the agent answering it. Deleting it is what made a steered reply appear
535
547
  * with no question above it.
548
+ *
549
+ * `messageId` says WHICH message Pi picked up, for the case where position cannot answer it. A message
550
+ * handed to Pi's follow-up queue was never CLAIMED here - it is still `waiting`, because Super Pi keeps
551
+ * it in its own list so the user can still withdraw it - and a conversation can hold a `waiting` message
552
+ * and a `sending` one at the same time. The caller resolves the delivery by the text Pi announced,
553
+ * which is the same text Pi itself matched on.
536
554
  */
537
- deliverWaitingMessage(conversationId: string, agentIdentity?: Message["agentIdentity"]): Promise<{
555
+ deliverWaitingMessage(conversationId: string, agentIdentity?: Message["agentIdentity"], messageId?: string): Promise<{
538
556
  user: Message;
539
557
  assistant: Message;
540
558
  } | undefined>;
@@ -596,6 +596,31 @@ export class Store {
596
596
  throw error;
597
597
  }
598
598
  }
599
+ /**
600
+ * Sets (or clears) the app-wide default model.
601
+ *
602
+ * `undefined` DELETES the key, for the same reason the workspace defaults do it that way: "follow
603
+ * Pi" has to be the ABSENCE of a value, not a frozen copy of whatever Pi's default was when the
604
+ * setting was saved. A stored copy would keep answering with a model the user had already
605
+ * removed, and Pi's own default may have changed underneath us in the meantime.
606
+ *
607
+ * The pair is written field by field, never as a spread, because the type declares exactly three
608
+ * fields and a spread would quietly start persisting whatever is added to `ModelSelection` next.
609
+ */
610
+ async setDefaultModel(model) {
611
+ const previous = this.state.defaultModel;
612
+ if (model === undefined)
613
+ delete this.state.defaultModel;
614
+ else
615
+ this.state.defaultModel = { provider: model.provider, modelId: model.modelId, label: model.label };
616
+ try {
617
+ await this.persist();
618
+ }
619
+ catch (error) {
620
+ this.state.defaultModel = previous;
621
+ throw error;
622
+ }
623
+ }
599
624
  snapshot() { return structuredClone(this.state); }
600
625
  async createWorkspace(name, path) {
601
626
  const cleanName = name.trim();
@@ -1609,17 +1634,27 @@ export class Store {
1609
1634
  * was written into the transcript when the user pressed send, long before Pi ever saw it - and it stays
1610
1635
  * exactly where it was, now with the agent answering it. Deleting it is what made a steered reply appear
1611
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.
1612
1643
  */
1613
- async deliverWaitingMessage(conversationId, agentIdentity) {
1644
+ async deliverWaitingMessage(conversationId, agentIdentity, messageId) {
1614
1645
  const conversation = this.requireConversation(conversationId);
1615
1646
  // Looks for the single message Pi is currently answering. A `role: "tool"` 后台回传 that has been
1616
1647
  // steered or taken is on the same delivery state machine as a user message, so it is the answer
1617
1648
  // target too; the assistant placeholder still lands directly below it and the role/identity is read
1618
1649
  // from the conversation (not the message) when the turn opens.
1619
- 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");
1620
1653
  if (index < 0)
1621
1654
  return undefined;
1622
1655
  const user = conversation.messages[index];
1656
+ if (user.role !== "user" && user.role !== "tool")
1657
+ return undefined;
1623
1658
  delete user.delivery;
1624
1659
  const assistant = { id: id("message"), role: "assistant", content: "", createdAt: now(), ...(agentIdentity ? { agentIdentity: structuredClone(agentIdentity) } : {}) };
1625
1660
  conversation.messages.splice(index + 1, 0, assistant);
@@ -730,8 +730,19 @@ export type BackgroundTaskSummary = {
730
730
  * import and is erased at compile time, so it does not close a runtime cycle with the rules that
731
731
  * construct them - the same relationship `SubagentRun` already has with `subagent-runs.ts`.
732
732
  */
733
+ /**
734
+ * `defaultModel` is the app-wide fallback model: the `provider/modelId` pair a NEW conversation gets
735
+ * when its workspace has no default of its own. It sits BELOW the workspace default and ABOVE Pi's own
736
+ * default, so the precedence at birth is workspace > app > Pi.
737
+ *
738
+ * It is a PAIR, never a provider and a model apart, for the same reason `ModelSelection` is a pair:
739
+ * Pi's `set_model` needs both halves, and half a selection is a selection Pi refuses. There is no
740
+ * separate "default provider" anywhere in this state: a provider is not a thing this app can be
741
+ * configured with, it is only the first half of a model id.
742
+ */
733
743
  export type AppState = {
734
744
  modelFilterRules?: ModelFilterRule[];
745
+ defaultModel?: ModelSelection;
735
746
  workspaces: Workspace[];
736
747
  conversations: Conversation[];
737
748
  subagentRuns: SubagentRun[];
@@ -862,6 +873,27 @@ export type ClientCommandBody =
862
873
  } | {
863
874
  type: "model.rules.set";
864
875
  rules: ModelFilterRule[];
876
+ }
877
+ /**
878
+ * Sets the app-wide default model. `null` clears it back to "Pi's own default" - the same
879
+ * "absence, not a frozen copy" convention the workspace defaults use, so removing the default
880
+ * really removes it instead of pinning whatever Pi's default happened to be when it was saved.
881
+ */
882
+ | {
883
+ type: "settings.defaultModel";
884
+ model: {
885
+ provider: string;
886
+ modelId: string;
887
+ } | null;
888
+ }
889
+ /**
890
+ * Asks for the model catalog WITHOUT a conversation, so the settings window can list every model Pi
891
+ * offers. A separate command from `catalog.fetch` because that one is scoped to a conversation and
892
+ * filtered by that workspace's model rules: reusing it here would either hide the models the panel
893
+ * must offer, or - worse - leak them into the composer picker, which is filtered for a reason.
894
+ */
895
+ | {
896
+ type: "catalog.fetchAll";
865
897
  } | {
866
898
  type: "workspace.create";
867
899
  name: string;
@@ -1287,6 +1319,17 @@ export type ServerEvent = {
1287
1319
  catalog: ModelCatalog;
1288
1320
  conversationId?: string;
1289
1321
  }
1322
+ /**
1323
+ * The UNFILTERED catalog, for the settings window's model panel. A different event type from
1324
+ * `model.catalog` on purpose: that one is scoped to a conversation and filtered by that
1325
+ * workspace's model rules, and the settings panel must be able to offer the models the rules
1326
+ * hide - while the composer picker must keep hiding them. One event carrying both would force
1327
+ * one of the two to be wrong.
1328
+ */
1329
+ | {
1330
+ type: "model.catalog.all";
1331
+ catalog: ModelCatalog;
1332
+ }
1290
1333
  /**
1291
1334
  * The answer to conversation.autoRename. Carries a title OR an error and never both, because the client
1292
1335
  * has exactly two things to say about it: here is the new name, or here is why there isnt one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.101",
3
+ "version": "0.1.103",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -75,6 +75,7 @@
75
75
  "scripts/agent-vis-records.test.mjs",
76
76
  "scripts/queue-store.test.mjs",
77
77
  "scripts/followup.test.mjs",
78
+ "scripts/pi-followup-queue.test.mjs",
78
79
  "scripts/md-render.test.mjs",
79
80
  "scripts/revert-session-format.test.mjs",
80
81
  "scripts/revert-session-file.test.mjs",
@@ -90,6 +91,7 @@
90
91
  "scripts/theme-no-hardcoded-colours.test.mjs",
91
92
  "scripts/agent-config-regression.test.mjs",
92
93
  "scripts/model-filter.test.mjs",
94
+ "scripts/settings-default-model.test.mjs",
93
95
  "scripts/active-memory.test.mjs",
94
96
  "scripts/knowledge-base.test.mjs",
95
97
  "scripts/tool-runner.test.mjs",
@@ -135,6 +137,7 @@
135
137
  "scripts/theme-contrast.test.mjs",
136
138
  "scripts/theme-switch-state.test.mjs",
137
139
  "scripts/settings-window-structure.test.mjs",
140
+ "scripts/settings-model-browser.test.mjs",
138
141
  "scripts/file-tab-selected-contrast.test.mjs",
139
142
  "scripts/help-window.test.mjs",
140
143
  "scripts/composer-regression-browser.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
  *
@@ -216,6 +302,8 @@ export async function diagnose({ repositoryRoot, turns = 2, onProgress = () => {
216
302
  }
217
303
  await sleep(250);
218
304
  const added = log.slice(logMark);
305
+ const promptSize = /systemPrompt=(\d+)B/.exec(added)?.[1];
306
+ if (promptSize) result.systemPromptBytes = Number(promptSize);
219
307
  result.turns.push({
220
308
  turn,
221
309
  toWaitingMs: waiting - sent,
@@ -274,6 +362,14 @@ export async function diagnose({ repositoryRoot, turns = 2, onProgress = () => {
274
362
  } catch { /* reported as not measured below */ }
275
363
  const interruptText = Date.now() - clicked;
276
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);
277
373
  result.interrupt = {
278
374
  toWaitingMs: interruptWaiting || undefined,
279
375
  toTextMs: interruptText,
@@ -328,6 +424,11 @@ export function renderReport(result) {
328
424
  lines.push(` 第${turn.turn}轮 等待中 -> 出字 ${ms(turn.modelMs)} 模型 (不是 Super Pi)`);
329
425
  }
330
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
+
331
432
  lines.push("");
332
433
  lines.push(" 打断(点「打断并回答」之后):");
333
434
  lines.push(` 点击 -> 新一轮报等待中 ${ms(result.interrupt.toWaitingMs)} Super Pi (等旧轮退完 + 等槽位)`);
@@ -350,6 +451,13 @@ export function renderReport(result) {
350
451
  lines.push(` 建立的会话数: ${built}`);
351
452
  lines.push(` 复用的会话数: ${reused}`);
352
453
 
454
+ if (result.systemPromptBytes) {
455
+ lines.push("");
456
+ lines.push(` 发给模型的系统提示:${(result.systemPromptBytes / 1024).toFixed(1)}KB`);
457
+ lines.push(" 这是每一轮都要先传过去的东西。模型的 provider 得先收下它才能回答,");
458
+ lines.push(" 所以在慢链路上,它直接就是首字之前的那几秒。");
459
+ }
460
+
353
461
  const real = result.realState;
354
462
  if (real && real.path) {
355
463
  lines.push("");
@@ -357,10 +465,16 @@ export function renderReport(result) {
357
465
  lines.push(` ${real.path}`);
358
466
  const megabytes = (real.bytes / 1024 / 1024).toFixed(2);
359
467
  const shape = real.conversations ? `,${real.conversations} 个会话,${real.messages} 条消息` : "";
360
- 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
+ }
361
475
  if (real.bytes > 1024 * 1024) {
362
- lines.push(" 这个体积的 state 如果每发一条消息都要全量写一遍,在慢机器上就是秒级——");
363
- lines.push(" 而那次写就挡在「服务端告诉浏览器回合开始了」之前。");
476
+ lines.push(" 这个体积的 state 每变一次就要整份克隆 + 整份序列化 + 浏览器整份解析,");
477
+ lines.push(" 在慢机器上每一次广播都是秒级,而一轮里会广播好几次。");
364
478
  }
365
479
  }
366
480