@evandu/super-pi 0.1.133 → 0.1.136

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 (63) hide show
  1. package/dist/.super-pi-check-inputs.json +2 -2
  2. package/dist/client/.super-pi-client-sources.json +6 -6
  3. package/dist/client/assets/{CodeMirrorEditor-CZZfGD0U.js → CodeMirrorEditor-CWN8JIx4.js} +2 -2
  4. package/dist/client/assets/{dist-D4x1XTQb.js → dist-0vYGgCC5.js} +1 -1
  5. package/dist/client/assets/{dist-CFPXj4ws.js → dist-B0vHKLg1.js} +1 -1
  6. package/dist/client/assets/{dist-4Pts9xPO.js → dist-BA6BeFaf.js} +1 -1
  7. package/dist/client/assets/{dist-BtTFCNVQ.js → dist-BQjtlfpY.js} +1 -1
  8. package/dist/client/assets/{dist-CUKaDTyO.js → dist-BSOBu3lQ.js} +1 -1
  9. package/dist/client/assets/{dist-CYD2SqmG.js → dist-BtNKV30Z.js} +1 -1
  10. package/dist/client/assets/{dist-DHqqpxo4.js → dist-CTXz1R9C.js} +1 -1
  11. package/dist/client/assets/{dist-BffE00vI.js → dist-CV9QN-qU.js} +1 -1
  12. package/dist/client/assets/{dist-azDN8a65.js → dist-CYLUz40X.js} +1 -1
  13. package/dist/client/assets/{dist-aL2NGji_.js → dist-CwNZuQ4i.js} +1 -1
  14. package/dist/client/assets/{dist-CApAttfj.js → dist-DCuAT0n-.js} +1 -1
  15. package/dist/client/assets/{dist-DOYWHMIq.js → dist-DErmxG4Q.js} +1 -1
  16. package/dist/client/assets/{dist-DQf5nNxa.js → dist-DHTyhm7w.js} +1 -1
  17. package/dist/client/assets/{dist-fYp8Xwrx.js → dist-DMDxxHpj.js} +1 -1
  18. package/dist/client/assets/{dist-B5ruVtUG.js → dist-Dalb5yHq.js} +1 -1
  19. package/dist/client/assets/{dist-0FH-oFco.js → dist-OKarkelj.js} +1 -1
  20. package/dist/client/assets/{dist-DbbBgSwL.js → dist-Qc0xt1y7.js} +1 -1
  21. package/dist/client/assets/{dist-CxsunljU.js → dist-WkcafMsD.js} +1 -1
  22. package/dist/client/assets/{dist-CE5BnwJa.js → dist-ZvUavnTX.js} +1 -1
  23. package/dist/client/assets/{dist-BJinarhP.js → dist-q1T0uV1c.js} +1 -1
  24. package/dist/client/assets/{dist-C2nuBCP4.js → dist-t0oHfWqq.js} +1 -1
  25. package/dist/client/assets/{dist-LFfokYJs.js → dist-yOveRczL.js} +1 -1
  26. package/dist/client/assets/{index-YEN4ysHF.js → index-oDRgSKPX.js} +37 -37
  27. package/dist/client/assets/index-r-L_XCYs.css +1 -0
  28. package/dist/client/index.html +2 -2
  29. package/dist/server/active-memory.d.ts +18 -2
  30. package/dist/server/active-memory.js +73 -8
  31. package/dist/server/application/conversation-service.js +4 -2
  32. package/dist/server/application/run-service.d.ts +4 -0
  33. package/dist/server/application/run-service.js +114 -93
  34. package/dist/server/application/scheduled-task-service.d.ts +7 -0
  35. package/dist/server/application/scheduled-task-service.js +14 -5
  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/files.js +11 -1
  41. package/dist/server/http-server.js +16 -5
  42. package/dist/server/index.js +5 -2
  43. package/dist/server/knowledge-embedding-worker.d.ts +1 -0
  44. package/dist/server/knowledge-embedding-worker.js +27 -0
  45. package/dist/server/knowledge-vectors.d.ts +5 -3
  46. package/dist/server/knowledge-vectors.js +49 -47
  47. package/dist/server/knowledge.d.ts +1 -0
  48. package/dist/server/knowledge.js +6 -2
  49. package/dist/server/model-filter.d.ts +13 -3
  50. package/dist/server/model-filter.js +7 -5
  51. package/dist/server/persistence/sqlite-state-repository.d.ts +0 -1
  52. package/dist/server/persistence/sqlite-state-repository.js +37 -1
  53. package/dist/server/providers.d.ts +13 -0
  54. package/dist/server/providers.js +27 -2
  55. package/dist/server/runtime.js +46 -0
  56. package/dist/server/store.d.ts +83 -0
  57. package/dist/server/store.js +140 -14
  58. package/dist/server/subagents.js +10 -2
  59. package/dist/server/types.d.ts +10 -0
  60. package/dist/server/ws-server.d.ts +3 -0
  61. package/dist/server/ws-server.js +35 -6
  62. package/package.json +21 -2
  63. package/dist/client/assets/index-D3MEG8uH.css +0 -1
@@ -1,5 +1,4 @@
1
1
  import { assertModelAllowed } from "../model-filter.js";
2
- import { deleteConversationUploads } from "../upload.js";
3
2
  /**
4
3
  * Service managing workspaces: creation, rename, cascade deletion, and defaults configuration.
5
4
  */
@@ -10,16 +9,14 @@ export class WorkspaceService {
10
9
  catalogService;
11
10
  runService;
12
11
  extensionUiService;
13
- uploadsDir;
14
12
  agentsDir;
15
- constructor(store, runtime, broadcaster, catalogService, runService, extensionUiService, uploadsDir, agentsDir) {
13
+ constructor(store, runtime, broadcaster, catalogService, runService, extensionUiService, agentsDir) {
16
14
  this.store = store;
17
15
  this.runtime = runtime;
18
16
  this.broadcaster = broadcaster;
19
17
  this.catalogService = catalogService;
20
18
  this.runService = runService;
21
19
  this.extensionUiService = extensionUiService;
22
- this.uploadsDir = uploadsDir;
23
20
  this.agentsDir = agentsDir;
24
21
  }
25
22
  setAgentsDir(dir) {
@@ -41,21 +38,25 @@ export class WorkspaceService {
41
38
  this.broadcaster.broadcastState();
42
39
  }
43
40
  /**
44
- * Deletes a workspace and cascades cleanup to active runs, subagent runs,
45
- * uploaded files, and stored conversations.
41
+ * Takes a workspace out of the sidebar. NOTHING IS DELETED.
42
+ *
43
+ * The conversations under it, the uploads attached to them and the workspace's own defaults all
44
+ * stay on disk: removing a workspace from the list is a soft unmount, and adding the same
45
+ * folder back brings the whole history with it. What does stop is the WORK: a turn that was still
46
+ * running cannot keep a model session alive behind a workspace the user can no longer see, and a
47
+ * pending question would wait for an answer the sidebar can no longer reach.
46
48
  */
47
49
  async deleteWorkspace(workspaceId) {
48
50
  const snapshot = this.store.snapshot();
49
- const doomed = snapshot.conversations.filter((c) => c.workspaceId === workspaceId);
50
- for (const c of doomed) {
51
+ const affected = snapshot.conversations.filter((c) => c.workspaceId === workspaceId);
52
+ for (const c of affected) {
51
53
  this.extensionUiService.cancelPendingUi(c.id, "aborted");
52
54
  if (this.runService.isRunning(c.id)) {
53
55
  await this.runService.stopRun(c.id);
54
56
  }
55
57
  this.runService.stopConversationSubagents(c.id);
56
58
  }
57
- const doomedIds = await this.store.deleteWorkspace(workspaceId);
58
- await Promise.all(doomedIds.map((id) => deleteConversationUploads(this.uploadsDir, id)));
59
+ await this.store.deleteWorkspace(workspaceId);
59
60
  this.broadcaster.broadcastState();
60
61
  }
61
62
  /**
@@ -0,0 +1,18 @@
1
+ /**
2
+ * THE TEXT OF A FAILURE.
3
+ *
4
+ * A server failure reaches the browser as a sentence, and the reader is entitled to know what
5
+ * actually went wrong. The trouble is that what was thrown is not always an `Error`: it can be a
6
+ * string, a plain object, a rejected promise with no reason at all. The old code answered
7
+ * `command failed` in those cases - the one sentence that says nothing about anything - so a real
8
+ * cause that had reached the server was replaced by a sentence with no cause in it.
9
+ *
10
+ * This module is the one place that decides what a thrown value turns into. It never invents
11
+ * text: an `Error` gives its message, a string gives itself, an object gives its `message` or its
12
+ * JSON, and a value that carries no information at all gives an EMPTY STRING - which the client
13
+ * reads as "no reason was given", and says so out loud instead of pretending.
14
+ */
15
+ /** The failure as a person should read it. Empty when the thrown value carries no information. */
16
+ export declare function describeThrown(error: unknown): string;
17
+ /** The failure with its stack, for the log file. */
18
+ export declare function describeThrownWithStack(error: unknown): string;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * THE TEXT OF A FAILURE.
3
+ *
4
+ * A server failure reaches the browser as a sentence, and the reader is entitled to know what
5
+ * actually went wrong. The trouble is that what was thrown is not always an `Error`: it can be a
6
+ * string, a plain object, a rejected promise with no reason at all. The old code answered
7
+ * `command failed` in those cases - the one sentence that says nothing about anything - so a real
8
+ * cause that had reached the server was replaced by a sentence with no cause in it.
9
+ *
10
+ * This module is the one place that decides what a thrown value turns into. It never invents
11
+ * text: an `Error` gives its message, a string gives itself, an object gives its `message` or its
12
+ * JSON, and a value that carries no information at all gives an EMPTY STRING - which the client
13
+ * reads as "no reason was given", and says so out loud instead of pretending.
14
+ */
15
+ /** The failure as a person should read it. Empty when the thrown value carries no information. */
16
+ export function describeThrown(error) {
17
+ if (error instanceof Error)
18
+ return error.message || error.name;
19
+ if (typeof error === "string")
20
+ return error;
21
+ if (error === null || error === undefined)
22
+ return "";
23
+ if (typeof error === "object") {
24
+ const message = error.message;
25
+ if (typeof message === "string" && message.trim())
26
+ return message;
27
+ try {
28
+ const text = JSON.stringify(error);
29
+ if (text && text !== "{}")
30
+ return text;
31
+ }
32
+ catch {
33
+ // Circular or otherwise unserializable: there is nothing better to say than "no reason".
34
+ }
35
+ return "";
36
+ }
37
+ return String(error);
38
+ }
39
+ /** The failure with its stack, for the log file. */
40
+ export function describeThrownWithStack(error) {
41
+ if (error instanceof Error && error.stack)
42
+ return error.stack;
43
+ return describeThrown(error);
44
+ }
@@ -650,7 +650,17 @@ export async function writeWorkspaceFile(root, requested, content, baseEtag) {
650
650
  throw new FilesError(415, "拒绝写入:内容包含二进制字节(\\0)", "binary");
651
651
  const original = await readFile(target);
652
652
  const hadBom = original.length >= 3 && original[0] === 0xef && original[1] === 0xbb && original[2] === 0xbf;
653
- const output = hadBom ? `\uFEFF${content}` : content;
653
+ // THE LINE ENDINGS THE FILE HAD, NOT THE ONES THE EDITOR NORMALIZED.
654
+ //
655
+ // CodeMirror's document is always LF (it splits lines on `/\r\n?|\n/`), so a CRLF file comes back
656
+ // from the editor as LF text - and writing that back is how one edited character turns into a
657
+ // whole-file diff, on Windows, in every repository with `core.autocrlf=true`. The contract in this
658
+ // file already says it ("a CRLF file is saved back as CRLF instead of silently rewritten to LF");
659
+ // it just was not implemented HERE: the BOM's byte-level convention was honoured two lines above
660
+ // and the EOL's was dropped.
661
+ const eol = original.includes("\r\n") ? "\r\n" : "\n";
662
+ const body = eol === "\r\n" ? content.replace(/\r?\n/g, "\r\n") : content.replace(/\r\n/g, "\n");
663
+ const output = hadBom ? `\uFEFF${body}` : body;
654
664
  const temporary = await mkdtemp(`${target}.super-pi-${process.pid}-`);
655
665
  const temporaryFile = join(temporary, basename(target));
656
666
  try {
@@ -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
  }
@@ -1039,6 +1040,16 @@ export function createHttpServer(options) {
1039
1040
  return;
1040
1041
  }
1041
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
+ }
1042
1053
  let raw = "";
1043
1054
  for await (const chunk of request)
1044
1055
  raw += chunk;
@@ -1417,7 +1428,7 @@ export function createHttpServer(options) {
1417
1428
  response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(result));
1418
1429
  }
1419
1430
  catch (error) {
1420
- 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) || "问答失败,没有拿到失败原因。" }));
1421
1432
  }
1422
1433
  return;
1423
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
  }
@@ -6,8 +6,18 @@ export declare function applicableRules(rules: ModelFilterRule[] | undefined, wo
6
6
  export declare function assertModelAllowed(rules: ModelFilterRule[] | undefined, workspace: Workspace, model: ModelSelection | undefined): void;
7
7
  export declare function conversationWorkspace(state: AppState, conversationId: string): Workspace;
8
8
  /** Resolve Pi's actual global default when no model was selected; never guess a permitted replacement. */
9
- export declare function validateConversationModel(store: {
10
- snapshot(): AppState;
11
- }, runtime: {
9
+ /**
10
+ * THE STORE READS THIS NEEDS, AND NOTHING ELSE.
11
+ *
12
+ * The check wants two things: the rules, and the workspace the conversation is in. Both now have a
13
+ * narrow reader on the store (`modelFilterRules()` / `workspaceForConversation()`), so this path
14
+ * clones NOTHING - it used to deep-clone the whole application three times over, on the way to
15
+ * sending a message.
16
+ */
17
+ export interface ModelFilterStore {
18
+ modelFilterRules(): import("./types.js").ModelFilterRule[];
19
+ workspaceForConversation(conversationId: string): Workspace | undefined;
20
+ }
21
+ export declare function validateConversationModel(store: ModelFilterStore, runtime: {
12
22
  catalog(selection?: ModelSelection): Promise<import("./types.js").ModelCatalog>;
13
23
  }, conversationId: string, selection?: ModelSelection): Promise<void>;
@@ -29,18 +29,20 @@ export function conversationWorkspace(state, conversationId) {
29
29
  throw new Error("会话所属工作区不存在");
30
30
  return workspace;
31
31
  }
32
- /** Resolve Pi's actual global default when no model was selected; never guess a permitted replacement. */
33
32
  export async function validateConversationModel(store, runtime, conversationId, selection) {
34
- const state = store.snapshot();
35
- const workspace = conversationWorkspace(state, conversationId);
36
- if (!applicableRules(state.modelFilterRules, workspace).length)
33
+ const workspace = store.workspaceForConversation(conversationId);
34
+ if (!workspace)
35
+ throw new Error("会话所属工作区不存在");
36
+ if (!applicableRules(store.modelFilterRules(), workspace).length)
37
37
  return;
38
38
  const startedAt = performance.now();
39
39
  if (process.env.SUPER_PI_TRACE_LATENCY === "1")
40
40
  console.warn(`[super-pi latency] conversation=${conversationId} phase=model.validate.begin selection=${selection ? `${selection.provider}/${selection.modelId}` : "default"}`);
41
41
  const catalog = await runtime.catalog();
42
42
  const model = selection ? catalog.models.find(item => item.provider === selection.provider && item.modelId === selection.modelId) ?? selection : catalog.defaultModel;
43
- assertModelAllowed(store.snapshot().modelFilterRules, conversationWorkspace(store.snapshot(), conversationId), model);
43
+ // READ AGAIN, NOT CACHED: the catalog fetch above is an await, and a rule edited during it must
44
+ // still be enforced for this message. The narrow readers make that re-read cheap.
45
+ assertModelAllowed(store.modelFilterRules(), store.workspaceForConversation(conversationId), model);
44
46
  if (process.env.SUPER_PI_TRACE_LATENCY === "1")
45
47
  console.warn(`[super-pi latency] conversation=${conversationId} phase=model.validate.end elapsedMs=${Math.round(performance.now() - startedAt)}`);
46
48
  }
@@ -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))
@@ -163,6 +187,12 @@ export class SqliteStateRepository {
163
187
  upsertMessage.run(conversationId, message.id, json);
164
188
  insertOrdered.run(conversationId, message.id, position, json);
165
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
+ }
166
196
  }
167
197
  else {
168
198
  const nextIds = new Set(transaction.messages.map((message) => message.id));
@@ -185,6 +215,12 @@ export class SqliteStateRepository {
185
215
  if (positions.get(message.id) !== position)
186
216
  upsertOrder.run(conversationId, message.id, position, json);
187
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
+ }
188
224
  }
189
225
  // The stamp is WRITTEN, not cleared. Opening a turn moves `updatedAt`, and this is the only write
190
226
  // that can record it; clearing the overlay instead would leave the reopened conversation reporting
@@ -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[];