@evandu/super-pi 0.1.132 → 0.1.135

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.
Files changed (69) hide show
  1. package/dist/.super-pi-check-inputs.json +4 -0
  2. package/dist/client/.super-pi-client-sources.json +8 -8
  3. package/dist/client/assets/{CodeMirrorEditor-ktyL2PcX.js → CodeMirrorEditor-D6awRUZO.js} +2 -2
  4. package/dist/client/assets/{dist--igDAzRS.js → dist-1jSyapdW.js} +1 -1
  5. package/dist/client/assets/{dist-CPLCQDqy.js → dist-BGgidsaF.js} +1 -1
  6. package/dist/client/assets/{dist-DNZMXYNq.js → dist-BYMYC_2I.js} +1 -1
  7. package/dist/client/assets/{dist-vsMBLoxV.js → dist-BkVaPA_q.js} +1 -1
  8. package/dist/client/assets/{dist-B8OZ-n_C.js → dist-BoeM5xW7.js} +1 -1
  9. package/dist/client/assets/{dist-D_Zdnhm8.js → dist-C46474ys.js} +1 -1
  10. package/dist/client/assets/{dist-Cu75NyQ0.js → dist-CCkdCH6a.js} +1 -1
  11. package/dist/client/assets/{dist-BJi5HmH4.js → dist-CNuKauOq.js} +1 -1
  12. package/dist/client/assets/{dist-BAJCrbnX.js → dist-CubYGjAk.js} +1 -1
  13. package/dist/client/assets/{dist-8pdzh6h1.js → dist-Cwb-Lb5-.js} +1 -1
  14. package/dist/client/assets/{dist-BRjGckmQ.js → dist-D0Iw5XQA.js} +1 -1
  15. package/dist/client/assets/{dist-BjZUYxLd.js → dist-DCiuVvGU.js} +1 -1
  16. package/dist/client/assets/{dist-Hj_dWgPr.js → dist-DHBWUQhM.js} +1 -1
  17. package/dist/client/assets/{dist-CoszOk5k.js → dist-DOwrSiuY.js} +1 -1
  18. package/dist/client/assets/{dist-Bg9zLfex.js → dist-DeZsNbFx.js} +1 -1
  19. package/dist/client/assets/{dist-C6iAHGvb.js → dist-Dk3e6RfY.js} +1 -1
  20. package/dist/client/assets/{dist-Bv6P2Rcp.js → dist-DoDmoWTn.js} +1 -1
  21. package/dist/client/assets/{dist-CgmISAMr.js → dist-WkVUFX28.js} +1 -1
  22. package/dist/client/assets/{dist-DvHghuGd.js → dist-lZUWPQbT.js} +1 -1
  23. package/dist/client/assets/{dist-Zhdkfvng.js → dist-mPR8uuzk.js} +1 -1
  24. package/dist/client/assets/{dist-60usx__o.js → dist-spB2Q2CG.js} +1 -1
  25. package/dist/client/assets/{dist-CMscy2Nn.js → dist-yTTCht0J.js} +1 -1
  26. package/dist/client/assets/index-Dyta-HkQ.js +100 -0
  27. package/dist/client/assets/{index-D3MEG8uH.css → index-V93Kzott.css} +1 -1
  28. package/dist/client/index.html +2 -2
  29. package/dist/server/active-memory.d.ts +1 -0
  30. package/dist/server/active-memory.js +6 -2
  31. package/dist/server/agents.js +21 -1
  32. package/dist/server/application/feishu-service.d.ts +57 -10
  33. package/dist/server/application/feishu-service.js +118 -24
  34. package/dist/server/application/run-service.d.ts +4 -0
  35. package/dist/server/application/run-service.js +123 -88
  36. package/dist/server/application/workspace-service.d.ts +8 -4
  37. package/dist/server/application/workspace-service.js +11 -10
  38. package/dist/server/failure-text.d.ts +18 -0
  39. package/dist/server/failure-text.js +44 -0
  40. package/dist/server/feishu-cli.d.ts +18 -2
  41. package/dist/server/feishu-cli.js +46 -12
  42. package/dist/server/feishu-config.d.ts +68 -0
  43. package/dist/server/feishu-config.js +84 -0
  44. package/dist/server/feishu.d.ts +187 -0
  45. package/dist/server/feishu.js +695 -0
  46. package/dist/server/http-server.js +36 -5
  47. package/dist/server/index.js +5 -2
  48. package/dist/server/knowledge-embedding-worker.d.ts +1 -0
  49. package/dist/server/knowledge-embedding-worker.js +27 -0
  50. package/dist/server/knowledge-vectors.d.ts +5 -3
  51. package/dist/server/knowledge-vectors.js +49 -47
  52. package/dist/server/knowledge.d.ts +1 -0
  53. package/dist/server/knowledge.js +6 -2
  54. package/dist/server/persistence/sqlite-state-repository.d.ts +0 -1
  55. package/dist/server/persistence/sqlite-state-repository.js +55 -1
  56. package/dist/server/pi-session-events.js +10 -2
  57. package/dist/server/pi-session-pool.d.ts +33 -1
  58. package/dist/server/pi-session-pool.js +20 -2
  59. package/dist/server/providers.d.ts +13 -0
  60. package/dist/server/providers.js +27 -2
  61. package/dist/server/runtime.d.ts +26 -1
  62. package/dist/server/runtime.js +98 -9
  63. package/dist/server/store.d.ts +61 -0
  64. package/dist/server/store.js +112 -14
  65. package/dist/server/types.d.ts +10 -0
  66. package/dist/server/ws-server.d.ts +3 -0
  67. package/dist/server/ws-server.js +35 -6
  68. package/package.json +25 -2
  69. package/dist/client/assets/index-ChyemF2I.js +0 -100
@@ -6,6 +6,7 @@ import { homedir, networkInterfaces } from "node:os";
6
6
  import { basename, dirname, extname, join, relative, resolve } from "node:path";
7
7
  import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES, readWorkspaceFile, resolveInsideRoot, toRelative, writeWorkspaceFile, } from "./files.js";
8
8
  import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
9
+ import { describeThrown } from "./failure-text.js";
9
10
  import { checkDiskVersion } from "./version.js";
10
11
  import { gitBlame, gitDiff, gitLog, gitMutate, gitStatus } from "./git.js";
11
12
  import { computeFileDiff } from "./file-diff.js";
@@ -282,7 +283,7 @@ export function createHttpServer(options) {
282
283
  return;
283
284
  }
284
285
  catch (error) {
285
- json(400, { error: error instanceof Error ? error.message : "tool export failed" });
286
+ json(400, { error: describeThrown(error) || "导出工具失败,没有拿到失败原因。" });
286
287
  return;
287
288
  }
288
289
  }
@@ -360,7 +361,7 @@ export function createHttpServer(options) {
360
361
  return;
361
362
  }
362
363
  catch (error) {
363
- json(400, { error: error instanceof Error ? error.message : "tool request failed" });
364
+ json(400, { error: describeThrown(error) || "工具请求失败,没有拿到失败原因。" });
364
365
  return;
365
366
  }
366
367
  }
@@ -422,7 +423,7 @@ export function createHttpServer(options) {
422
423
  toolApi(202, run);
423
424
  }
424
425
  catch (error) {
425
- toolApi(400, { error: error instanceof Error ? error.message : "tool run failed" });
426
+ toolApi(400, { error: describeThrown(error) || "工具运行失败,没有拿到失败原因。" });
426
427
  }
427
428
  return;
428
429
  }
@@ -513,7 +514,7 @@ export function createHttpServer(options) {
513
514
  toolApi(202, context.toolLlm.start({ toolId: id, prompt: body.prompt ?? "", system: body.system, timeoutMs: body.timeoutMs }));
514
515
  }
515
516
  catch (error) {
516
- toolApi(400, { error: error instanceof Error ? error.message : "model call failed" });
517
+ toolApi(400, { error: describeThrown(error) || "模型调用失败,没有拿到失败原因。" });
517
518
  }
518
519
  return;
519
520
  }
@@ -913,6 +914,26 @@ export function createHttpServer(options) {
913
914
  }
914
915
  return;
915
916
  }
917
+ /**
918
+ * CHECKS THE CONNECTION, and says why it does not work when it does not.
919
+ *
920
+ * The status already verifies the credential against Feishu, so this is the same answer shaped
921
+ * for a button - the difference is that a failure comes back as a MESSAGE AND A HINT rather
922
+ * than as a state the panel has to interpret.
923
+ */
924
+ if (url.pathname === "/api/feishu/verify" && request.method === "POST") {
925
+ if (!context.feishu) {
926
+ response.writeHead(503).end();
927
+ return;
928
+ }
929
+ if (!isLocalOrPrivateAddress(remote)) {
930
+ json(403, { error: "测试飞书连接只允许来自本机或局域网的请求。" });
931
+ return;
932
+ }
933
+ const result = await context.feishu.verify();
934
+ json(result.ok ? 200 : 400, result);
935
+ return;
936
+ }
916
937
  if (url.pathname === "/api/shutdown") {
917
938
  if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
918
939
  response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Shutdown requires a loopback or private network POST");
@@ -1019,6 +1040,16 @@ export function createHttpServer(options) {
1019
1040
  return;
1020
1041
  }
1021
1042
  if (request.method === "POST") {
1043
+ // A GIT WRITE IS A WRITE, AND IT GETS THE SAME GATE AS EVERY OTHER WRITE. `gitMutate` can
1044
+ // stage, DISCARD (`git restore -- <path>`, which throws away uncommitted work), commit,
1045
+ // pull and PUSH - so an endpoint that skips the address predicate hands a public caller the
1046
+ // keys to somebody's repository. The server listens on 0.0.0.0 by default and the global
1047
+ // access token is OPTIONAL (`isAuthorized` passes everyone when there is none), so this
1048
+ // predicate IS the boundary for every other write endpoint in this file (ledger TEST-042).
1049
+ if (!isLocalOrPrivateAddress(remote)) {
1050
+ json(403, { error: "Git 写操作只允许来自本机或局域网的请求。" });
1051
+ return;
1052
+ }
1022
1053
  let raw = "";
1023
1054
  for await (const chunk of request)
1024
1055
  raw += chunk;
@@ -1397,7 +1428,7 @@ export function createHttpServer(options) {
1397
1428
  response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(result));
1398
1429
  }
1399
1430
  catch (error) {
1400
- response.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: error instanceof Error ? error.message : "question failed" }));
1431
+ response.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: describeThrown(error) || "问答失败,没有拿到失败原因。" }));
1401
1432
  }
1402
1433
  return;
1403
1434
  }
@@ -137,7 +137,10 @@ const paths = {
137
137
  const runtime = process.env.SUPER_PI_RUNTIME === "welcome"
138
138
  ? new WelcomeRuntime()
139
139
  : new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: initialAgentDirs, promptDir: agentPromptDir }, { endpoint: taskEndpoint, execEndpoint, todoEndpoint, workflowEndpoint, questionEndpoint, toolsEndpoint, documentsEndpoint, scheduledTaskEndpoint, uploadsDir });
140
- const broadcaster = new WsBroadcaster(() => store.snapshot());
140
+ // THE CLIENT GETS THE VISIBLE STATE, NOT THE WHOLE STORE. `clientState()` is `snapshot()` minus the
141
+ // workspaces that were taken out of the sidebar - they are still in the store (that is what lets the
142
+ // same folder be added back with its history), but they are not part of the app any more.
143
+ const broadcaster = new WsBroadcaster(() => store.clientState());
141
144
  const memory = new ActiveMemory(join(dataDir, "memories"), store, runtime);
142
145
  process.env.SUPER_PI_MEMORY_ENDPOINT = `http://${internalHost}:${port}/api/memory/tool`;
143
146
  if (runtime instanceof PiRpcRuntime)
@@ -243,7 +246,7 @@ const nameIndexes = new Map();
243
246
  runService.setNameIndexes(nameIndexes);
244
247
  const conversationService = new ConversationService(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, initialAgentDirs);
245
248
  conversationService.setMemory(memory);
246
- const workspaceService = new WorkspaceService(store, runtime, broadcaster, catalogService, runService, extensionUiService, uploadsDir, initialAgentDirs);
249
+ const workspaceService = new WorkspaceService(store, runtime, broadcaster, catalogService, runService, extensionUiService, initialAgentDirs);
247
250
  /**
248
251
  * The terminal, constructed before `serverContext` because that object carries the two predicates
249
252
  * `/api/terminal` answers with.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import { parentPort } from "node:worker_threads";
2
+ let extractor;
3
+ let loading;
4
+ async function load(request) {
5
+ if (extractor)
6
+ return extractor;
7
+ loading ??= (async () => {
8
+ const specifier = "@huggingface/transformers";
9
+ const transformers = await import(specifier);
10
+ transformers.env.localModelPath = request.modelRoot;
11
+ transformers.env.allowRemoteModels = false;
12
+ extractor = await transformers.pipeline("feature-extraction", request.model, { dtype: "q8" });
13
+ return extractor;
14
+ })();
15
+ return loading;
16
+ }
17
+ parentPort?.on("message", async (request) => {
18
+ try {
19
+ const run = await load(request);
20
+ const output = await run(request.texts, { pooling: "cls", normalize: true });
21
+ const buffer = output.data.buffer;
22
+ parentPort?.postMessage({ id: request.id, data: output.data, dims: output.dims }, [buffer]);
23
+ }
24
+ catch (error) {
25
+ parentPort?.postMessage({ id: request.id, error: error instanceof Error ? error.message : String(error) });
26
+ }
27
+ });
@@ -59,8 +59,10 @@ export type VectorDocument = {
59
59
  */
60
60
  export declare class VectorIndex {
61
61
  private readonly directory;
62
- private extractor?;
63
- private loading?;
62
+ private worker?;
63
+ private workerSequence;
64
+ private workerReady;
65
+ private workerPending;
64
66
  private failure?;
65
67
  private cache;
66
68
  private pending;
@@ -91,7 +93,7 @@ export declare class VectorIndex {
91
93
  * weights turns a failure mode into a file, and makes the feature work on a machine that has never
92
94
  * been online since it was installed.
93
95
  */
94
- private load;
96
+ private ensureWorker;
95
97
  private embed;
96
98
  /** Vectors already stored for one workspace, keyed by document then chunk index. */
97
99
  private read;
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
3
  import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
4
4
  import { join, resolve } from "node:path";
5
+ import { Worker } from "node:worker_threads";
5
6
  /**
6
7
  * The model, and the only one this feature can use.
7
8
  *
@@ -78,8 +79,10 @@ export function cosine(a, b) {
78
79
  */
79
80
  export class VectorIndex {
80
81
  directory;
81
- extractor;
82
- loading;
82
+ worker;
83
+ workerSequence = 0;
84
+ workerReady = false;
85
+ workerPending = new Map();
83
86
  failure;
84
87
  cache = new Map();
85
88
  pending = new Set();
@@ -100,7 +103,7 @@ export class VectorIndex {
100
103
  enabled() { return process.env.SUPER_PI_KNOWLEDGE_EMBEDDINGS !== "0"; }
101
104
  status(indexed) {
102
105
  return {
103
- enabled: this.enabled(), ready: Boolean(this.extractor), indexed, error: this.failure,
106
+ enabled: this.enabled(), ready: this.workerReady, indexed, error: this.failure,
104
107
  // Whether the shipped weights are on disk, reported before the model is ever loaded: "the
105
108
  // package was built without its assets" and "the model failed to load" are different problems.
106
109
  bundled: existsSync(this.bundledModel()),
@@ -119,57 +122,56 @@ export class VectorIndex {
119
122
  * weights turns a failure mode into a file, and makes the feature work on a machine that has never
120
123
  * been online since it was installed.
121
124
  */
122
- load() {
123
- if (this.extractor)
124
- return Promise.resolve(this.extractor);
125
+ ensureWorker() {
126
+ if (this.worker)
127
+ return this.worker;
125
128
  if (this.failure)
126
- return Promise.resolve(undefined);
127
- this.loading ??= (async () => {
128
- try {
129
- if (!existsSync(this.bundledModel()))
130
- throw new Error(`随包分发的模型缺失:${this.bundledModel()}`);
131
- // THE SPECIFIER GOES THROUGH A VARIABLE, and that is deliberate. Written as a literal,
132
- // `tsc` resolves it while compiling - so a machine whose optional install skipped this package
133
- // could not build the server at all, which makes an "optional" dependency mandatory in the
134
- // only sense that matters to a person who installed it. Node resolves the variable at RUN time
135
- // instead, where its absence is already handled: the throw below is the same branch that
136
- // handles a missing model.
137
- const specifier = "@huggingface/transformers";
138
- const { pipeline, env } = await import(specifier);
139
- // The library resolves `<localModelPath>/<model id>/onnx/...`, which is why the vendored tree
140
- // keeps the `Xenova/bge-small-zh-v1.5` directory rather than being flattened.
141
- env.localModelPath = this.bundledModel();
142
- env.allowRemoteModels = false;
143
- this.extractor = await pipeline("feature-extraction", KNOWLEDGE_MODEL, { dtype: "q8" });
144
- return this.extractor;
145
- }
146
- catch (error) {
147
- this.failure = error instanceof Error ? error.message : String(error);
148
- console.warn(`Super Pi: 语义检索不可用,将只用关键词检索:${this.failure}`);
149
- return undefined;
150
- }
151
- finally {
152
- this.loading = undefined;
129
+ return undefined;
130
+ if (!existsSync(this.bundledModel())) {
131
+ this.failure = `随包分发的模型缺失:${this.bundledModel()}`;
132
+ return undefined;
133
+ }
134
+ const worker = new Worker(new URL("./knowledge-embedding-worker.js", import.meta.url));
135
+ worker.unref();
136
+ worker.on("message", (message) => {
137
+ const pending = this.workerPending.get(message.id);
138
+ if (!pending)
139
+ return;
140
+ this.workerPending.delete(message.id);
141
+ if (message.error || !message.data || !message.dims) {
142
+ this.failure = message.error ?? "向量 worker 返回了无效结果";
143
+ pending.resolve(null);
144
+ return;
153
145
  }
154
- })();
155
- return this.loading;
146
+ this.workerReady = true;
147
+ const dimension = message.dims.at(-1) ?? 0;
148
+ pending.resolve(Array.from({ length: message.dims[0] ?? 0 }, (_, row) => Array.from(message.data.slice(row * dimension, (row + 1) * dimension))));
149
+ });
150
+ const fail = (error) => {
151
+ this.failure = error.message;
152
+ for (const pending of this.workerPending.values())
153
+ pending.resolve(null);
154
+ this.workerPending.clear();
155
+ this.worker = undefined;
156
+ this.workerReady = false;
157
+ };
158
+ worker.on("error", fail);
159
+ worker.on("exit", (code) => { if (code !== 0)
160
+ fail(new Error(`向量 worker 异常退出(${code})`)); });
161
+ this.worker = worker;
162
+ return worker;
156
163
  }
157
164
  async embed(texts) {
158
165
  if (!texts.length || !this.enabled())
159
166
  return null;
160
- const extractor = await this.load();
161
- if (!extractor)
167
+ const worker = this.ensureWorker();
168
+ if (!worker)
162
169
  return null;
163
- try {
164
- const output = await extractor(texts, { pooling: "cls", normalize: true });
165
- const dimension = output.dims[output.dims.length - 1];
166
- return Array.from({ length: output.dims[0] }, (_, row) => Array.from(output.data.slice(row * dimension, (row + 1) * dimension)));
167
- }
168
- catch (error) {
169
- this.failure = error instanceof Error ? error.message : String(error);
170
- console.warn(`Super Pi: 生成向量失败,将只用关键词检索:${this.failure}`);
171
- return null;
172
- }
170
+ const id = ++this.workerSequence;
171
+ return new Promise((resolvePromise) => {
172
+ this.workerPending.set(id, { resolve: resolvePromise });
173
+ worker.postMessage({ id, texts, modelRoot: this.bundledModel(), model: KNOWLEDGE_MODEL });
174
+ });
173
175
  }
174
176
  /** Vectors already stored for one workspace, keyed by document then chunk index. */
175
177
  async read(workspaceId, documentId) {
@@ -125,6 +125,7 @@ export declare class KnowledgeBase {
125
125
  private indexes;
126
126
  private serial;
127
127
  constructor(directory: string, store: Store, runtime?: KnowledgeRuntime | undefined, vectors?: VectorIndex | undefined);
128
+ private hasWorkspace;
128
129
  /** `SUPER_PI_KNOWLEDGE_ENABLED=0` turns the whole feature off, the same way memory turns off. */
129
130
  enabled(): boolean;
130
131
  /** The opaque workspace identity on disk, encoded. Paths never come from the model or the request. */
@@ -253,13 +253,17 @@ export class KnowledgeBase {
253
253
  this.runtime = runtime;
254
254
  this.vectors = vectors;
255
255
  }
256
+ hasWorkspace(workspaceId) {
257
+ const store = this.store;
258
+ return store.hasWorkspace ? store.hasWorkspace(workspaceId) : store.snapshot().workspaces.some(workspace => workspace.id === workspaceId);
259
+ }
256
260
  /** `SUPER_PI_KNOWLEDGE_ENABLED=0` turns the whole feature off, the same way memory turns off. */
257
261
  enabled() { return process.env.SUPER_PI_KNOWLEDGE_ENABLED !== "0"; }
258
262
  /** The opaque workspace identity on disk, encoded. Paths never come from the model or the request. */
259
263
  folder(workspaceId) { return join(this.directory, Buffer.from(workspaceId).toString("hex")); }
260
264
  file(workspaceId, documentId) { return join(this.folder(workspaceId), `${documentId}.json`); }
261
265
  requireWorkspace(workspaceId) {
262
- if (!this.store.snapshot().workspaces.some(workspace => workspace.id === workspaceId))
266
+ if (!this.hasWorkspace(workspaceId))
263
267
  throw new Error("工作区不存在");
264
268
  }
265
269
  async locked(work) {
@@ -710,7 +714,7 @@ export class KnowledgeBase {
710
714
  const identity = this.capabilities.get(token);
711
715
  // The workspace is re-checked against the store, so a token minted before a workspace was deleted
712
716
  // cannot keep serving its documents afterwards.
713
- if (!identity || !this.store.snapshot().workspaces.some(workspace => workspace.id === identity.workspaceId))
717
+ if (!identity || !this.hasWorkspace(identity.workspaceId))
714
718
  throw new Error("知识库授权已失效");
715
719
  return identity;
716
720
  }
@@ -1,6 +1,5 @@
1
1
  import type { AppState } from "../types.js";
2
2
  import type { AppStateRepository, StateTransaction } from "./app-state-repository.js";
3
- /** SQLite snapshot adapter with row-level writes for streamed messages and runs. */
4
3
  export declare class SqliteStateRepository implements AppStateRepository {
5
4
  private readonly db;
6
5
  constructor(path: string);
@@ -1,5 +1,24 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
2
  /** SQLite snapshot adapter with row-level writes for streamed messages and runs. */
3
+ /**
4
+ * THE ROW THAT SAYS "THIS CONVERSATION'S ARRAY IS EMPTY".
5
+ *
6
+ * A missing row in `message_order` used to mean two different things: "this conversation was never
7
+ * written incrementally" AND "this conversation's array was emptied". The loader could only read the
8
+ * first one, so a conversation whose LAST message was withdrawn fell back to the base snapshot - and
9
+ * the withdrawn message came back, carrying `delivery: "waiting"`, to be handed to the model by the
10
+ * next turn. A withdrawal is the last act of "take back what I have not sent yet", and it did not
11
+ * survive a restart (ledger TEST-041).
12
+ *
13
+ * A sentinel row says the difference. It is marked by a NEGATIVE POSITION - a real message's
14
+ * position is never negative - so it is never mistaken for one, and the loader reads it as "empty"
15
+ * rather than as "not written". (The marker is the position and NOT the id on purpose: a NUL in the
16
+ * id would be truncated by SQLite's TEXT handling, and the row would stop being recognisable.)
17
+ */
18
+ const EMPTY_ARRAY_POSITION = -1;
19
+ /** The id that goes with the sentinel row. It never becomes a message: the loader drops every row
20
+ * whose position is negative, and this row's position IS negative. */
21
+ const EMPTY_ARRAY_SENTINEL_ID = "empty-array";
3
22
  export class SqliteStateRepository {
4
23
  db;
5
24
  constructor(path) {
@@ -58,7 +77,12 @@ export class SqliteStateRepository {
58
77
  // was last rewritten - the placeholder an assistant message was born with, not what it streamed.
59
78
  const ordered = this.db.prepare("SELECT id,position,json FROM message_order WHERE conversation=? ORDER BY position").all(conversation.id);
60
79
  if (ordered.length) {
61
- conversation.messages = ordered.map((row) => overrides.get(String(row.id)) ?? JSON.parse(String(row.json)));
80
+ // Rows EXIST, so this conversation was written incrementally and these rows are authoritative -
81
+ // including the sentinel, which says the array is empty. The sentinel is not a message and
82
+ // never becomes one.
83
+ conversation.messages = ordered
84
+ .filter((row) => Number(row.position) >= 0)
85
+ .map((row) => overrides.get(String(row.id)) ?? JSON.parse(String(row.json)));
62
86
  }
63
87
  else {
64
88
  if (!Array.isArray(conversation.messages))
@@ -120,6 +144,24 @@ export class SqliteStateRepository {
120
144
  }
121
145
  else if (transaction.kind === "collections.remove") {
122
146
  this.db.prepare("DELETE FROM collections WHERE name=?").run(transaction.name);
147
+ // AND THE BASE SNAPSHOT MUST STOP CARRYING IT TOO. Deleting only the overlay row was not
148
+ // enough: this loader treats an overlay as a PURE INCREMENT - "no row for this name" means
149
+ // "read it from the base snapshot" - so the base's stale array came back on the next load,
150
+ // and a scheduled task the user had deleted was resurrected and ran again on its old
151
+ // schedule (ledger TEST-038). "This collection is empty" is stored as the ABSENCE of the key
152
+ // (app-state-repository.ts), so the base has to lose the key as well - the row's deletion
153
+ // alone cannot express it, because the base is where the value would come from.
154
+ //
155
+ // The json backend has no incremental path and falls back to a whole-state save, which is
156
+ // why it was always right; that asymmetry is what makes this a defect and not a choice.
157
+ const base = this.db.prepare("SELECT json FROM state WHERE id=1").get();
158
+ if (base) {
159
+ const parsed = JSON.parse(String(base.json));
160
+ if (transaction.name in parsed) {
161
+ delete parsed[transaction.name];
162
+ this.db.prepare("UPDATE state SET json=? WHERE id=1").run(JSON.stringify(parsed));
163
+ }
164
+ }
123
165
  }
124
166
  else if (transaction.kind === "conversation.messages") {
125
167
  // A DIFF, NOT A REWRITE. This used to DELETE the conversation's rows and re-INSERT every
@@ -145,6 +187,12 @@ export class SqliteStateRepository {
145
187
  upsertMessage.run(conversationId, message.id, json);
146
188
  insertOrdered.run(conversationId, message.id, position, json);
147
189
  });
190
+ // An EMPTY ARRAY MUST BE WRITTEN DOWN, or "no rows" will read as "never written" and the
191
+ // loader will fall back to the base snapshot - which still holds the message the person
192
+ // just withdrew (ledger TEST-041).
193
+ if (transaction.messages.length === 0) {
194
+ insertOrdered.run(conversationId, EMPTY_ARRAY_SENTINEL_ID, EMPTY_ARRAY_POSITION, "[]");
195
+ }
148
196
  }
149
197
  else {
150
198
  const nextIds = new Set(transaction.messages.map((message) => message.id));
@@ -167,6 +215,12 @@ export class SqliteStateRepository {
167
215
  if (positions.get(message.id) !== position)
168
216
  upsertOrder.run(conversationId, message.id, position, json);
169
217
  });
218
+ // The LAST MESSAGE GONE leaves the order table with no rows, and no rows read as "never
219
+ // written" - so the loader would fall back to the base snapshot and hand the withdrawn
220
+ // message back to the model. The sentinel says the array is empty (ledger TEST-041).
221
+ if (transaction.messages.length === 0) {
222
+ upsertOrder.run(conversationId, EMPTY_ARRAY_SENTINEL_ID, EMPTY_ARRAY_POSITION, "[]");
223
+ }
170
224
  }
171
225
  // The stamp is WRITTEN, not cleared. Opening a turn moves `updatedAt`, and this is the only write
172
226
  // that can record it; clearing the overlay instead would leave the reopened conversation reporting
@@ -16,7 +16,7 @@
16
16
  * be announced as unhandled is still announced as unhandled - this bridge changes WHERE the events come
17
17
  * from, not what they mean.
18
18
  */
19
- import { describeToolCall, describeToolCallArgs, extractPartialResultText } from "./tool-calls.js";
19
+ import { describeToolCall, describeToolCallArgs, extractPartialResultText, readQuestionCall } from "./tool-calls.js";
20
20
  import { toolTargetPath } from "./file-changes.js";
21
21
  /** Keeps only real strings, so a malformed queue snapshot cannot put objects into a broadcast. */
22
22
  const textList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : undefined;
@@ -254,7 +254,15 @@ export function handleSessionEvent(event, queue, handlers, report, failure) {
254
254
  return true;
255
255
  const name = record.toolName ?? "unknown";
256
256
  try {
257
- handlers.onToolCall?.({ id, name, detail: describeToolCall(name, record.args), args: describeToolCallArgs(record.args) });
257
+ // A `question` CALL IS READ HERE, AT `tool_execution_start` - the same place the one-shot path
258
+ // reads it, and for the same reason: the card on screen must be the tool call the model is
259
+ // actually blocked in, with the same identity and the same lifecycle. This path left the
260
+ // question out, so the host never saw a pending question and the model was told the session
261
+ // had no question waiting for an answer - the `question` tool was simply dead on the SDK
262
+ // session path, which is the PRODUCTION default (ledger TEST-037). Two paths must give the
263
+ // same answer to "what is a question call".
264
+ const question = readQuestionCall(name, record.args);
265
+ handlers.onToolCall?.({ id, name, detail: describeToolCall(name, record.args), args: describeToolCallArgs(record.args), ...(question ? { question } : {}) });
258
266
  }
259
267
  catch (error) {
260
268
  console.warn(`PiRpcRuntime: the tool call handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
@@ -87,6 +87,22 @@ export declare class PiSessionPool {
87
87
  * where that gets fixed.
88
88
  */
89
89
  private readonly modelEpoch;
90
+ /**
91
+ * Mints the OTHER per-session credentials the extensions need, at the moment the session is born.
92
+ *
93
+ * WHY A SECOND CALLBACK AND NOT PART OF THE CAPABILITY. The memory and knowledge extensions each
94
+ * need their own credential - the memory one even carries per-caller flags (read-only for a
95
+ * sub-agent) that a single session capability cannot express. The one-shot path has always minted
96
+ * them per turn; the SESSION path minted none, so those two extensions returned before registering
97
+ * a single tool or injecting a single line of prompt, and both features were absent in production -
98
+ * the SDK session path is the default backend, and only the test scripts use the one-shot path
99
+ * (ledger TEST-039).
100
+ *
101
+ * They are minted HERE, where the session is born, for the same reason the capability is: the
102
+ * extension reads its environment ONCE, when it is loaded, so a token minted per turn would be
103
+ * replaced before the second turn and the extension would still be holding the first turn's value.
104
+ */
105
+ private readonly mintExtensionTokens;
90
106
  private readonly sessions;
91
107
  constructor(
92
108
  /** Where Pi keeps its session files. */
@@ -122,7 +138,23 @@ export declare class PiSessionPool {
122
138
  * settings window makes every live session unable to reach the new model, and the next turn is
123
139
  * where that gets fixed.
124
140
  */
125
- modelEpoch?: () => number);
141
+ modelEpoch?: () => number,
142
+ /**
143
+ * Mints the OTHER per-session credentials the extensions need, at the moment the session is born.
144
+ *
145
+ * WHY A SECOND CALLBACK AND NOT PART OF THE CAPABILITY. The memory and knowledge extensions each
146
+ * need their own credential - the memory one even carries per-caller flags (read-only for a
147
+ * sub-agent) that a single session capability cannot express. The one-shot path has always minted
148
+ * them per turn; the SESSION path minted none, so those two extensions returned before registering
149
+ * a single tool or injecting a single line of prompt, and both features were absent in production -
150
+ * the SDK session path is the default backend, and only the test scripts use the one-shot path
151
+ * (ledger TEST-039).
152
+ *
153
+ * They are minted HERE, where the session is born, for the same reason the capability is: the
154
+ * extension reads its environment ONCE, when it is loaded, so a token minted per turn would be
155
+ * replaced before the second turn and the extension would still be holding the first turn's value.
156
+ */
157
+ mintExtensionTokens?: (conversationId: string) => Promise<Record<string, string>> | Record<string, string>);
126
158
  /**
127
159
  * The session for a conversation, built if there is none and REPLACED if the live one is stale.
128
160
  *
@@ -49,6 +49,7 @@ export class PiSessionPool {
49
49
  factory;
50
50
  mintCapability;
51
51
  modelEpoch;
52
+ mintExtensionTokens;
52
53
  sessions = new Map();
53
54
  constructor(
54
55
  /** Where Pi keeps its session files. */
@@ -84,7 +85,23 @@ export class PiSessionPool {
84
85
  * settings window makes every live session unable to reach the new model, and the next turn is
85
86
  * where that gets fixed.
86
87
  */
87
- modelEpoch = () => 0) {
88
+ modelEpoch = () => 0,
89
+ /**
90
+ * Mints the OTHER per-session credentials the extensions need, at the moment the session is born.
91
+ *
92
+ * WHY A SECOND CALLBACK AND NOT PART OF THE CAPABILITY. The memory and knowledge extensions each
93
+ * need their own credential - the memory one even carries per-caller flags (read-only for a
94
+ * sub-agent) that a single session capability cannot express. The one-shot path has always minted
95
+ * them per turn; the SESSION path minted none, so those two extensions returned before registering
96
+ * a single tool or injecting a single line of prompt, and both features were absent in production -
97
+ * the SDK session path is the default backend, and only the test scripts use the one-shot path
98
+ * (ledger TEST-039).
99
+ *
100
+ * They are minted HERE, where the session is born, for the same reason the capability is: the
101
+ * extension reads its environment ONCE, when it is loaded, so a token minted per turn would be
102
+ * replaced before the second turn and the extension would still be holding the first turn's value.
103
+ */
104
+ mintExtensionTokens = () => ({})) {
88
105
  this.sessionDir = sessionDir;
89
106
  this.repositoryRoot = repositoryRoot;
90
107
  this.agentDir = agentDir;
@@ -92,6 +109,7 @@ export class PiSessionPool {
92
109
  this.factory = factory;
93
110
  this.mintCapability = mintCapability;
94
111
  this.modelEpoch = modelEpoch;
112
+ this.mintExtensionTokens = mintExtensionTokens;
95
113
  }
96
114
  /**
97
115
  * The session for a conversation, built if there is none and REPLACED if the live one is stale.
@@ -138,7 +156,7 @@ export class PiSessionPool {
138
156
  // server separately requires that a turn is RUNNING for the token to be usable, which is what keeps a
139
157
  // longer-lived token from being a weaker credential than a per-turn one.
140
158
  const capability = this.mintCapability(request.conversationId);
141
- const extensionEnv = { ...request.extensionEnv, [CAPABILITY_ENV]: capability, [TOOLS_CAPABILITY_ENV]: capability };
159
+ const extensionEnv = { ...request.extensionEnv, [CAPABILITY_ENV]: capability, [TOOLS_CAPABILITY_ENV]: capability, ...await this.mintExtensionTokens(request.conversationId) };
142
160
  const building = this.build({ ...request, extensionEnv }, fingerprint);
143
161
  if (process.env.SUPER_PI_TRACE_LATENCY === "1")
144
162
  console.warn(`[super-pi latency] conversation=${request.conversationId} phase=session.build.begin`);
@@ -284,3 +284,16 @@ export declare function fetchRemoteModels(input: {
284
284
  apiKey?: string;
285
285
  api?: string;
286
286
  }): Promise<DiscoveredModel[]>;
287
+ /**
288
+ * THE ANSWER, READ.
289
+ *
290
+ * Split out of `fetchRemoteModels` so that the part worth asserting - what a provider's answer MEANS -
291
+ * can be tested without a network in between. A remote provider answers in three shapes (a bare array,
292
+ * `{data: [...]}` for OpenAI, `{models: [...]}` for Ollama), and each entry names itself with `id`,
293
+ * `name` or `model`; what the panel shows next to it - whether the model reasons, whether it takes
294
+ * images - is inferred from the name, because that is all most providers tell us.
295
+ *
296
+ * `fetchRemoteModels` is the transport; this is the reading. Keeping them apart means the reading can
297
+ * be pinned exactly, and a slow machine can never turn a parsing question into a timeout.
298
+ */
299
+ export declare function parseRemoteModels(payload: unknown): DiscoveredModel[];
@@ -741,6 +741,7 @@ export async function fetchRemoteModels(input) {
741
741
  candidates.push(`${cleanBase}/v1/models`, `${cleanBase}/models`);
742
742
  }
743
743
  let lastError = "";
744
+ let lastCause;
744
745
  let payload = null;
745
746
  for (const candidate of candidates) {
746
747
  try {
@@ -758,11 +759,35 @@ export async function fetchRemoteModels(input) {
758
759
  }
759
760
  catch (error) {
760
761
  lastError = error instanceof Error ? error.message : String(error);
762
+ lastCause = error;
761
763
  }
762
764
  }
763
765
  if (!payload) {
764
- throw new Error(`无法从远程服务获取模型列表 (${lastError || "请求超时或无法连接"})`);
765
- }
766
+ // THE CAUSE, NOT JUST "fetch failed". `fetch` reports every transport-level problem - a refused
767
+ // connection, a reset, an aborted timeout - as the same one-word "fetch failed", so a user staring
768
+ // at 「无法从远程服务获取模型列表 (fetch failed)」 learns nothing and neither does the log. The
769
+ // cause is where the truth is: "The operation was aborted due to timeout" and "connect
770
+ // ECONNREFUSED" are two different problems with two different fixes.
771
+ const cause = lastCause instanceof Error
772
+ ? (lastCause.cause instanceof Error ? lastCause.cause.message : lastCause.cause instanceof Object && lastCause.cause !== null ? String(lastCause.cause.code ?? "") : undefined)
773
+ : undefined;
774
+ throw new Error(`无法从远程服务获取模型列表 (${lastError || "请求超时或无法连接"}${cause ? `:${cause}` : ""})`);
775
+ }
776
+ return parseRemoteModels(payload);
777
+ }
778
+ /**
779
+ * THE ANSWER, READ.
780
+ *
781
+ * Split out of `fetchRemoteModels` so that the part worth asserting - what a provider's answer MEANS -
782
+ * can be tested without a network in between. A remote provider answers in three shapes (a bare array,
783
+ * `{data: [...]}` for OpenAI, `{models: [...]}` for Ollama), and each entry names itself with `id`,
784
+ * `name` or `model`; what the panel shows next to it - whether the model reasons, whether it takes
785
+ * images - is inferred from the name, because that is all most providers tell us.
786
+ *
787
+ * `fetchRemoteModels` is the transport; this is the reading. Keeping them apart means the reading can
788
+ * be pinned exactly, and a slow machine can never turn a parsing question into a timeout.
789
+ */
790
+ export function parseRemoteModels(payload) {
766
791
  const rawList = Array.isArray(payload)
767
792
  ? payload
768
793
  : Array.isArray(payload.data)