@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.
- package/dist/.super-pi-check-inputs.json +4 -0
- package/dist/client/.super-pi-client-sources.json +8 -8
- package/dist/client/assets/{CodeMirrorEditor-ktyL2PcX.js → CodeMirrorEditor-D6awRUZO.js} +2 -2
- package/dist/client/assets/{dist--igDAzRS.js → dist-1jSyapdW.js} +1 -1
- package/dist/client/assets/{dist-CPLCQDqy.js → dist-BGgidsaF.js} +1 -1
- package/dist/client/assets/{dist-DNZMXYNq.js → dist-BYMYC_2I.js} +1 -1
- package/dist/client/assets/{dist-vsMBLoxV.js → dist-BkVaPA_q.js} +1 -1
- package/dist/client/assets/{dist-B8OZ-n_C.js → dist-BoeM5xW7.js} +1 -1
- package/dist/client/assets/{dist-D_Zdnhm8.js → dist-C46474ys.js} +1 -1
- package/dist/client/assets/{dist-Cu75NyQ0.js → dist-CCkdCH6a.js} +1 -1
- package/dist/client/assets/{dist-BJi5HmH4.js → dist-CNuKauOq.js} +1 -1
- package/dist/client/assets/{dist-BAJCrbnX.js → dist-CubYGjAk.js} +1 -1
- package/dist/client/assets/{dist-8pdzh6h1.js → dist-Cwb-Lb5-.js} +1 -1
- package/dist/client/assets/{dist-BRjGckmQ.js → dist-D0Iw5XQA.js} +1 -1
- package/dist/client/assets/{dist-BjZUYxLd.js → dist-DCiuVvGU.js} +1 -1
- package/dist/client/assets/{dist-Hj_dWgPr.js → dist-DHBWUQhM.js} +1 -1
- package/dist/client/assets/{dist-CoszOk5k.js → dist-DOwrSiuY.js} +1 -1
- package/dist/client/assets/{dist-Bg9zLfex.js → dist-DeZsNbFx.js} +1 -1
- package/dist/client/assets/{dist-C6iAHGvb.js → dist-Dk3e6RfY.js} +1 -1
- package/dist/client/assets/{dist-Bv6P2Rcp.js → dist-DoDmoWTn.js} +1 -1
- package/dist/client/assets/{dist-CgmISAMr.js → dist-WkVUFX28.js} +1 -1
- package/dist/client/assets/{dist-DvHghuGd.js → dist-lZUWPQbT.js} +1 -1
- package/dist/client/assets/{dist-Zhdkfvng.js → dist-mPR8uuzk.js} +1 -1
- package/dist/client/assets/{dist-60usx__o.js → dist-spB2Q2CG.js} +1 -1
- package/dist/client/assets/{dist-CMscy2Nn.js → dist-yTTCht0J.js} +1 -1
- package/dist/client/assets/index-Dyta-HkQ.js +100 -0
- package/dist/client/assets/{index-D3MEG8uH.css → index-V93Kzott.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/active-memory.d.ts +1 -0
- package/dist/server/active-memory.js +6 -2
- package/dist/server/agents.js +21 -1
- package/dist/server/application/feishu-service.d.ts +57 -10
- package/dist/server/application/feishu-service.js +118 -24
- package/dist/server/application/run-service.d.ts +4 -0
- package/dist/server/application/run-service.js +123 -88
- package/dist/server/application/workspace-service.d.ts +8 -4
- package/dist/server/application/workspace-service.js +11 -10
- package/dist/server/failure-text.d.ts +18 -0
- package/dist/server/failure-text.js +44 -0
- package/dist/server/feishu-cli.d.ts +18 -2
- package/dist/server/feishu-cli.js +46 -12
- package/dist/server/feishu-config.d.ts +68 -0
- package/dist/server/feishu-config.js +84 -0
- package/dist/server/feishu.d.ts +187 -0
- package/dist/server/feishu.js +695 -0
- package/dist/server/http-server.js +36 -5
- package/dist/server/index.js +5 -2
- package/dist/server/knowledge-embedding-worker.d.ts +1 -0
- package/dist/server/knowledge-embedding-worker.js +27 -0
- package/dist/server/knowledge-vectors.d.ts +5 -3
- package/dist/server/knowledge-vectors.js +49 -47
- package/dist/server/knowledge.d.ts +1 -0
- package/dist/server/knowledge.js +6 -2
- package/dist/server/persistence/sqlite-state-repository.d.ts +0 -1
- package/dist/server/persistence/sqlite-state-repository.js +55 -1
- package/dist/server/pi-session-events.js +10 -2
- package/dist/server/pi-session-pool.d.ts +33 -1
- package/dist/server/pi-session-pool.js +20 -2
- package/dist/server/providers.d.ts +13 -0
- package/dist/server/providers.js +27 -2
- package/dist/server/runtime.d.ts +26 -1
- package/dist/server/runtime.js +98 -9
- package/dist/server/store.d.ts +61 -0
- package/dist/server/store.js +112 -14
- package/dist/server/types.d.ts +10 -0
- package/dist/server/ws-server.d.ts +3 -0
- package/dist/server/ws-server.js +35 -6
- package/package.json +25 -2
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1431
|
+
response.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: describeThrown(error) || "问答失败,没有拿到失败原因。" }));
|
|
1401
1432
|
}
|
|
1402
1433
|
return;
|
|
1403
1434
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
63
|
-
private
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
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:
|
|
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
|
-
|
|
123
|
-
if (this.
|
|
124
|
-
return
|
|
125
|
+
ensureWorker() {
|
|
126
|
+
if (this.worker)
|
|
127
|
+
return this.worker;
|
|
125
128
|
if (this.failure)
|
|
126
|
-
return
|
|
127
|
-
this.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|
161
|
-
if (!
|
|
167
|
+
const worker = this.ensureWorker();
|
|
168
|
+
if (!worker)
|
|
162
169
|
return null;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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. */
|
package/dist/server/knowledge.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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[];
|
package/dist/server/providers.js
CHANGED
|
@@ -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
|
-
|
|
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)
|