@evandu/super-pi 0.1.84 → 0.1.86
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/agents/super-pi-knowledge/index.ts +40 -0
- package/agents/super-pi-task/index.ts +2 -2
- package/assets/knowledge-model/README.md +37 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/config.json +31 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/onnx/model_quantized.onnx +0 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/special_tokens_map.json +7 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer.json +21278 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer_config.json +15 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/vocab.txt +21128 -0
- package/dist/.super-pi-check-inputs.json +4 -0
- package/dist/client/.super-pi-client-sources.json +10 -7
- package/dist/client/assets/index-B3QVoYJY.css +1 -0
- package/dist/client/assets/index-C_giUkvT.js +72 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.d.ts +50 -11
- package/dist/server/application/run-service.js +206 -77
- package/dist/server/command-handler.js +16 -4
- package/dist/server/http-server.js +218 -8
- package/dist/server/index.js +15 -1
- package/dist/server/knowledge-vectors.d.ts +133 -0
- package/dist/server/knowledge-vectors.js +302 -0
- package/dist/server/knowledge.d.ts +263 -0
- package/dist/server/knowledge.js +743 -0
- package/dist/server/persistence/app-state-repository.d.ts +25 -1
- package/dist/server/persistence/sqlite-state-repository.js +47 -4
- package/dist/server/runtime.d.ts +82 -1
- package/dist/server/runtime.js +128 -7
- package/dist/server/server-context.d.ts +3 -0
- package/dist/server/store.d.ts +153 -68
- package/dist/server/store.js +405 -187
- package/dist/server/tool-llm.d.ts +65 -0
- package/dist/server/tool-llm.js +139 -0
- package/dist/server/tool-registry.d.ts +65 -0
- package/dist/server/tool-registry.js +129 -6
- package/dist/server/tool-runner.d.ts +70 -0
- package/dist/server/tool-runner.js +210 -10
- package/dist/server/types.d.ts +97 -65
- package/package.json +166 -155
- package/dist/client/assets/index-8Pf3MG2d.js +0 -72
- package/dist/client/assets/index-Jnj15dhT.css +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createReadStream, existsSync } from "node:fs";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { readdir, rm, stat } from "node:fs/promises";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { homedir, networkInterfaces } from "node:os";
|
|
6
6
|
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
@@ -9,6 +9,8 @@ import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
|
|
|
9
9
|
import { checkDiskVersion } from "./version.js";
|
|
10
10
|
import { gitDiff, gitLog, gitStatus } from "./git.js";
|
|
11
11
|
import { verifyCapability } from "./capability.js";
|
|
12
|
+
import { MAX_KNOWLEDGE_REQUEST_BYTES } from "./knowledge.js";
|
|
13
|
+
import { MAX_TOOL_BODY_BYTES } from "./tool-registry.js";
|
|
12
14
|
import { packageUpdateArgs } from "./update.js";
|
|
13
15
|
const MIME_TYPES = {
|
|
14
16
|
".css": "text/css; charset=utf-8",
|
|
@@ -49,6 +51,28 @@ function toolBrowserSdk() {
|
|
|
49
51
|
}
|
|
50
52
|
},
|
|
51
53
|
runAndWait: async (params = {}, options) => { const run = await api.run(params); return api.wait(run.id, options); },
|
|
54
|
+
// THE MODEL, ASKED ON THE TOOL'S BEHALF. One call, one answer, no client to write: Super Pi owns the
|
|
55
|
+
// credentials, the model choice and the ceilings, and the tool only ever sees the text. It is a
|
|
56
|
+
// completion, not an agent - the model that answers has no tools, no files and no session history.
|
|
57
|
+
// Only a tool registered with permissions.llm may call it; otherwise the server refuses.
|
|
58
|
+
llm: (input = {}) => request("./api/llm", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input) }),
|
|
59
|
+
llmGet: (callId) => request("./api/llm/" + encodeURIComponent(callId), { method: "GET" }),
|
|
60
|
+
llmCancel: (callId) => request("./api/llm/" + encodeURIComponent(callId), { method: "DELETE" }),
|
|
61
|
+
llmWait: async (callId, { intervalMs = 400, signal } = {}) => {
|
|
62
|
+
for (;;) {
|
|
63
|
+
if (signal && signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
64
|
+
const call = await api.llmGet(callId);
|
|
65
|
+
if (call.status !== "running") {
|
|
66
|
+
if (call.status !== "succeeded") throw new Error(call.error || "Super Pi's model call did not succeed");
|
|
67
|
+
return call;
|
|
68
|
+
}
|
|
69
|
+
await new Promise((resolve, reject) => {
|
|
70
|
+
const timer = setTimeout(resolve, Math.max(50, intervalMs));
|
|
71
|
+
if (signal) signal.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("Aborted", "AbortError")); }, { once: true });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
llmText: async (input = {}, options) => { const call = await api.llm(input); return (await api.llmWait(call.id, options)).text; },
|
|
52
76
|
};
|
|
53
77
|
Object.freeze(api);
|
|
54
78
|
Object.defineProperty(window, "SuperPiTool", { value: api, writable: false, configurable: false });
|
|
@@ -164,6 +188,20 @@ export function parseServerArgs(argv) {
|
|
|
164
188
|
export function isAuthorized(request, accessToken) {
|
|
165
189
|
return !accessToken || request.headers.authorization === `Bearer ${accessToken}`;
|
|
166
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* A signal that fires when the caller goes away, so work started on its behalf can stop.
|
|
193
|
+
*
|
|
194
|
+
* WHY IT EXISTS HERE. A knowledge search may spawn a short-lived model call to re-rank its own
|
|
195
|
+
* candidates. If the agent aborted the turn that made the search, that work has no consumer left, and
|
|
196
|
+
* without this it would run to its timeout inside a server that is already shutting the turn down.
|
|
197
|
+
*/
|
|
198
|
+
function abortedAs(request) {
|
|
199
|
+
const controller = new AbortController();
|
|
200
|
+
request.once("aborted", () => controller.abort());
|
|
201
|
+
request.once("close", () => { if (!request.readableEnded)
|
|
202
|
+
controller.abort(); });
|
|
203
|
+
return controller.signal;
|
|
204
|
+
}
|
|
167
205
|
/**
|
|
168
206
|
* Creates the HTTP server providing static client assets and all `/api/*` endpoints.
|
|
169
207
|
*/
|
|
@@ -179,17 +217,47 @@ export function createHttpServer(options) {
|
|
|
179
217
|
// the registry have no such second channel, so they are NOT exempted here and fall through to the
|
|
180
218
|
// ordinary token check below - exempting the whole path is what let any local process list and
|
|
181
219
|
// delete a person's mounted tools on a server that had `SUPER_PI_ACCESS_TOKEN` set.
|
|
182
|
-
|
|
220
|
+
//
|
|
221
|
+
// `/api/knowledge/tool` is the same shape as `/api/memory/tool` and carries the same exemption for
|
|
222
|
+
// the same reason: it is reached only by the Pi process of one turn, carrying that turn's
|
|
223
|
+
// capability in a header. The MANAGEMENT half (`/api/knowledge`) is deliberately NOT exempted - it
|
|
224
|
+
// imports and deletes documents, so it stays behind the ordinary token.
|
|
225
|
+
if (requestPath !== "/api/memory/tool" && requestPath !== "/api/knowledge/tool" && (requestPath !== "/api/tools" || request.method !== "POST") && !requestPath.startsWith("/tools/") && !isAuthorized(request, accessToken)) {
|
|
183
226
|
response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
|
|
184
227
|
return;
|
|
185
228
|
}
|
|
186
229
|
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
187
230
|
const remote = request.socket.remoteAddress ?? "";
|
|
188
231
|
const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(JSON.stringify(body)); };
|
|
232
|
+
if (url.pathname === "/api/tools/export") {
|
|
233
|
+
// NOT EXEMPT FROM THE TOKEN CHECK ABOVE, and that is deliberate: an export hands out a tool's
|
|
234
|
+
// whole source, so it is a management read exactly like GET and DELETE, not a capability call.
|
|
235
|
+
try {
|
|
236
|
+
const id = url.searchParams.get("id");
|
|
237
|
+
if (!id) {
|
|
238
|
+
json(400, { error: "id required" });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const exported = await toolRegistry.exportBundle(id);
|
|
242
|
+
if (!exported) {
|
|
243
|
+
json(404, { error: "tool not found" });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
json(200, exported);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
json(400, { error: error instanceof Error ? error.message : "tool export failed" });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
189
254
|
if (url.pathname === "/api/tools") {
|
|
190
255
|
try {
|
|
191
256
|
if (request.method === "GET") {
|
|
192
|
-
|
|
257
|
+
// The environment state is DERIVED per request and deliberately not stored: it changes when
|
|
258
|
+
// the user installs Python, when a tool's venv is built, and when nothing at all happens.
|
|
259
|
+
const tools = await toolRegistry.list();
|
|
260
|
+
json(200, { tools: await Promise.all(tools.map(async (tool) => ({ ...tool, environmentState: await toolRunner.environmentState(tool) }))) });
|
|
193
261
|
return;
|
|
194
262
|
}
|
|
195
263
|
if (request.method === "DELETE") {
|
|
@@ -199,6 +267,11 @@ export function createHttpServer(options) {
|
|
|
199
267
|
return;
|
|
200
268
|
}
|
|
201
269
|
const removed = await toolRegistry.remove(id);
|
|
270
|
+
// The venv is machine-local state that belongs to that tool and nothing else, so it goes with
|
|
271
|
+
// it. Leaving it behind would mean a re-registered tool of the same slug silently inherits a
|
|
272
|
+
// stale environment.
|
|
273
|
+
if (removed)
|
|
274
|
+
await rm(toolRunner.envDir(id), { recursive: true, force: true });
|
|
202
275
|
if (removed)
|
|
203
276
|
broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
|
|
204
277
|
json(removed ? 200 : 404, { ok: removed });
|
|
@@ -208,13 +281,18 @@ export function createHttpServer(options) {
|
|
|
208
281
|
json(405, { error: "method" });
|
|
209
282
|
return;
|
|
210
283
|
}
|
|
211
|
-
|
|
284
|
+
// BYTES, NOT CHARACTERS, AND FROM THE REGISTRY'S OWN CONSTANT. The old cap counted UTF-16 code
|
|
285
|
+
// units at 256,000, below the registry's own 512,000-byte asset limit, so JSON escaping decided
|
|
286
|
+
// where the real ceiling was and an exported bundle could be refused on the way back in.
|
|
287
|
+
let raw = "", bytes = 0;
|
|
212
288
|
for await (const chunk of request) {
|
|
213
|
-
|
|
214
|
-
|
|
289
|
+
const piece = chunk;
|
|
290
|
+
bytes += piece.length;
|
|
291
|
+
if (bytes > MAX_TOOL_BODY_BYTES) {
|
|
215
292
|
json(413, { error: "request too large" });
|
|
216
293
|
return;
|
|
217
294
|
}
|
|
295
|
+
raw += piece.toString("utf8");
|
|
218
296
|
}
|
|
219
297
|
const body = JSON.parse(raw);
|
|
220
298
|
if (!isAuthorized(request, accessToken)) {
|
|
@@ -232,7 +310,16 @@ export function createHttpServer(options) {
|
|
|
232
310
|
json(400, { error: "slug, name and files are required" });
|
|
233
311
|
return;
|
|
234
312
|
}
|
|
235
|
-
|
|
313
|
+
// AN IMPORTED BUNDLE KEEPS THE EXPORTER'S FINGERPRINT. It describes where the tool was written
|
|
314
|
+
// and verified, and overwriting it with the importer's own machine would erase the very
|
|
315
|
+
// difference the tool list exists to show. A tool with no fingerprint gets one measured here.
|
|
316
|
+
let environment = body.environment;
|
|
317
|
+
if (environment && !environment.fingerprint) {
|
|
318
|
+
const local = await toolRunner.describeLocalPython();
|
|
319
|
+
if (local)
|
|
320
|
+
environment = { ...environment, fingerprint: { python: local.python, platform: local.platform, packages: environment.requires } };
|
|
321
|
+
}
|
|
322
|
+
const tool = await toolRegistry.register({ slug: body.slug, name: body.name, description: body.description, entrypoint: body.entrypoint, script: body.script, environment, permissions: body.permissions, files: body.files });
|
|
236
323
|
broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
|
|
237
324
|
json(201, tool);
|
|
238
325
|
return;
|
|
@@ -296,7 +383,7 @@ export function createHttpServer(options) {
|
|
|
296
383
|
toolApi(404, { error: "python script is not registered" });
|
|
297
384
|
return;
|
|
298
385
|
}
|
|
299
|
-
const run = toolRunner.start({ toolId: id, script: resolve(resolved.root, resolved.tool.script.path), cwd: resolved.root, definition: resolved.tool.script, params: body.params ?? {} });
|
|
386
|
+
const run = toolRunner.start({ toolId: id, script: resolve(resolved.root, resolved.tool.script.path), cwd: resolved.root, definition: resolved.tool.script, params: body.params ?? {}, environment: resolved.tool.environment });
|
|
300
387
|
toolApi(202, run);
|
|
301
388
|
}
|
|
302
389
|
catch (error) {
|
|
@@ -343,6 +430,91 @@ export function createHttpServer(options) {
|
|
|
343
430
|
toolApi(405, { error: "method" });
|
|
344
431
|
return;
|
|
345
432
|
}
|
|
433
|
+
// ---- THE MODEL, ON THE TOOL'S OWN BEHALF ---------------------------------------------------
|
|
434
|
+
// A tool asking the model a question, over the same relative API as its Python jobs, so the frame
|
|
435
|
+
// still never learns an id, a path or a credential. Three gates, in this order:
|
|
436
|
+
// 1. the opaque sandbox origin - identical to every other tool API here;
|
|
437
|
+
// 2. the tool must CARRY the grant (`permissions.llm`), which is the thing the review card showed
|
|
438
|
+
// the person before they accepted the file. A tool that quietly started asking the model
|
|
439
|
+
// would be spending the user's quota from inside a sandbox they were told was isolated;
|
|
440
|
+
// 3. the run is bound to this tool id, so one frame cannot read or cancel another's answer.
|
|
441
|
+
const llmStart = /^api\/llm$/.exec(resource);
|
|
442
|
+
const llmRun = /^api\/llm\/([^/]+)$/.exec(resource);
|
|
443
|
+
if (llmStart || llmRun) {
|
|
444
|
+
if (!toolOrigin) {
|
|
445
|
+
toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
// GRANT FIRST, THEN RUNTIME. What a tool is allowed to ask is a fact about the tool and does not
|
|
449
|
+
// depend on whether this machine happens to have a model, so a tool with no grant gets the same
|
|
450
|
+
// 403 whether or not Pi is installed.
|
|
451
|
+
if (!listed.permissions?.llm) {
|
|
452
|
+
toolApi(403, { error: "这个工具没有调用模型的权限;注册或导入时它必须声明 permissions: { llm: true }。" });
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (!context.toolLlm?.available) {
|
|
456
|
+
toolApi(503, { error: "这台机器上没有可用的 Pi 运行时,工具无法调用模型。" });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (llmStart) {
|
|
460
|
+
if (request.method === "OPTIONS") {
|
|
461
|
+
response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (request.method !== "POST") {
|
|
465
|
+
toolApi(405, { error: "method" });
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
let raw = "";
|
|
469
|
+
for await (const chunk of request) {
|
|
470
|
+
raw += chunk;
|
|
471
|
+
if (raw.length > 64_000) {
|
|
472
|
+
toolApi(413, { error: "request too large" });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
const body = JSON.parse(raw);
|
|
478
|
+
toolApi(202, context.toolLlm.start({ toolId: id, prompt: body.prompt ?? "", system: body.system, timeoutMs: body.timeoutMs }));
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
toolApi(400, { error: error instanceof Error ? error.message : "model call failed" });
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (request.method === "OPTIONS") {
|
|
486
|
+
response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
let llmId;
|
|
490
|
+
try {
|
|
491
|
+
llmId = decodeURIComponent(llmRun[1]);
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
toolApi(400, { error: "Malformed tool resource" });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (request.method === "GET") {
|
|
498
|
+
const run = context.toolLlm.get(llmId, id);
|
|
499
|
+
if (!run) {
|
|
500
|
+
toolApi(404, { error: "model call not found" });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
toolApi(200, run);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (request.method === "DELETE") {
|
|
507
|
+
const run = context.toolLlm.cancel(llmId, id);
|
|
508
|
+
if (!run) {
|
|
509
|
+
toolApi(404, { error: "model call not found" });
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
toolApi(200, run);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
toolApi(405, { error: "method" });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
346
518
|
if (resource === "super-pi-tool.js") {
|
|
347
519
|
response.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" }).end(toolBrowserSdk());
|
|
348
520
|
return;
|
|
@@ -409,6 +581,44 @@ export function createHttpServer(options) {
|
|
|
409
581
|
}
|
|
410
582
|
return;
|
|
411
583
|
}
|
|
584
|
+
if (url.pathname === "/api/knowledge/tool" || url.pathname === "/api/knowledge") {
|
|
585
|
+
if (!context.knowledge) {
|
|
586
|
+
response.writeHead(503).end();
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if ((url.pathname.endsWith("/tool") && request.method !== "POST") || !["GET", "POST"].includes(request.method ?? "")) {
|
|
590
|
+
response.writeHead(405).end();
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const origin = request.headers.origin;
|
|
594
|
+
if (origin && origin !== url.origin) {
|
|
595
|
+
response.writeHead(403).end("Origin not allowed");
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
let raw = "";
|
|
600
|
+
let totalBytes = 0;
|
|
601
|
+
// A pasted document rides in the body, so the cap is the document cap - past it the request is
|
|
602
|
+
// refused outright rather than truncated into a document that silently lost its tail.
|
|
603
|
+
for await (const chunk of request) {
|
|
604
|
+
totalBytes += chunk.length;
|
|
605
|
+
if (totalBytes > MAX_KNOWLEDGE_REQUEST_BYTES) {
|
|
606
|
+
response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "知识库请求过大" }));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
raw += chunk;
|
|
610
|
+
}
|
|
611
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
612
|
+
const result = url.pathname.endsWith("/tool")
|
|
613
|
+
? await context.knowledge.tool(String(request.headers["x-knowledge-capability"] ?? ""), body, abortedAs(request))
|
|
614
|
+
: await context.knowledge.manage(url.searchParams.get("workspaceId") ?? "", body);
|
|
615
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result));
|
|
616
|
+
}
|
|
617
|
+
catch (error) {
|
|
618
|
+
response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
412
622
|
if (url.pathname === "/api/shutdown") {
|
|
413
623
|
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
414
624
|
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Shutdown requires a loopback or private network POST");
|
package/dist/server/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { validateConversationModel } from "./model-filter.js";
|
|
2
2
|
import { ActiveMemory } from "./active-memory.js";
|
|
3
|
+
import { KnowledgeBase } from "./knowledge.js";
|
|
4
|
+
import { VectorIndex } from "./knowledge-vectors.js";
|
|
3
5
|
import { existsSync } from "node:fs";
|
|
4
6
|
import { mkdir } from "node:fs/promises";
|
|
5
7
|
import { homedir } from "node:os";
|
|
@@ -24,6 +26,7 @@ import { WsBroadcaster, WsServer } from "./ws-server.js";
|
|
|
24
26
|
import { initRunningVersion } from "./version.js";
|
|
25
27
|
import { ToolRegistry } from "./tool-registry.js";
|
|
26
28
|
import { ToolRunner } from "./tool-runner.js";
|
|
29
|
+
import { ToolLlmService } from "./tool-llm.js";
|
|
27
30
|
// ------------------------------------------------------------- Environment & paths
|
|
28
31
|
const rootDir = resolve(import.meta.dirname, "../..");
|
|
29
32
|
initRunningVersion(rootDir);
|
|
@@ -52,7 +55,9 @@ const toolsEndpoint = `http://${internalHost}:${port}/api/tools`;
|
|
|
52
55
|
// ------------------------------------------------------------- Core singletons
|
|
53
56
|
const store = await openStateStore(dataDir, process.env.SUPER_PI_PERSISTENCE ?? "sqlite");
|
|
54
57
|
const toolRegistry = new ToolRegistry(dataDir);
|
|
55
|
-
|
|
58
|
+
// The data dir is what makes a per-tool virtual environment possible: it lives at
|
|
59
|
+
// `<dataDir>/tool-envs/<toolId>`, beside the tools rather than inside one.
|
|
60
|
+
const toolRunner = new ToolRunner(dataDir);
|
|
56
61
|
const initialAgentDirs = resolveAgentDirectories({
|
|
57
62
|
customDirs: store.snapshot().customAgentDirs,
|
|
58
63
|
rootDir,
|
|
@@ -75,6 +80,13 @@ const memory = new ActiveMemory(join(dataDir, "memories"), store, runtime);
|
|
|
75
80
|
process.env.SUPER_PI_MEMORY_ENDPOINT = `http://${internalHost}:${port}/api/memory/tool`;
|
|
76
81
|
if (runtime instanceof PiRpcRuntime)
|
|
77
82
|
runtime.setMemory(memory);
|
|
83
|
+
// The model a mounted tool may ask. It IS Pi: `PiRpcRuntime.askOnce` spawns its own child with nothing
|
|
84
|
+
// attached but Pi, and a runtime without one reports itself unavailable rather than faking an answer.
|
|
85
|
+
const toolLlm = new ToolLlmService(runtime, rootDir);
|
|
86
|
+
const knowledge = new KnowledgeBase(join(dataDir, "knowledge"), store, runtime, new VectorIndex(join(dataDir, "knowledge")));
|
|
87
|
+
process.env.SUPER_PI_KNOWLEDGE_ENDPOINT = `http://${internalHost}:${port}/api/knowledge/tool`;
|
|
88
|
+
if (runtime instanceof PiRpcRuntime)
|
|
89
|
+
runtime.setKnowledge(knowledge);
|
|
78
90
|
const extensionUiService = new ExtensionUiService(broadcaster);
|
|
79
91
|
const supervisor = new SubagentSupervisor(join(dataDir, "pi-sessions"), undefined, {
|
|
80
92
|
dir: initialAgentDirs,
|
|
@@ -148,6 +160,7 @@ const execService = new ExecService((event) => broadcaster.broadcast(event), (se
|
|
|
148
160
|
runService.setExecService(execService);
|
|
149
161
|
const serverContext = {
|
|
150
162
|
memory,
|
|
163
|
+
knowledge,
|
|
151
164
|
paths,
|
|
152
165
|
store,
|
|
153
166
|
runtime,
|
|
@@ -162,6 +175,7 @@ const serverContext = {
|
|
|
162
175
|
execService,
|
|
163
176
|
toolRegistry,
|
|
164
177
|
toolRunner,
|
|
178
|
+
toolLlm,
|
|
165
179
|
terminalEnabled: () => terminalService.enabled,
|
|
166
180
|
terminalDisabledReason: () => terminalDisabledReason(),
|
|
167
181
|
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model, and the only one this feature can use.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS ONE. `bge-small-zh` scores 0.618 nDCG@10 averaged over the Chinese retrieval tasks it is
|
|
5
|
+
* benchmarked on - against 0.695 for `bge-base-zh`, which is four times the download. `gte-multilingual-base`
|
|
6
|
+
* scores higher on both Chinese and English, at 324 MB against this one's 23 MB. For a workspace-sized
|
|
7
|
+
* corpus the next thing that would help is not a bigger model but fusing this one with the lexical
|
|
8
|
+
* channels, which costs nothing and is what `KnowledgeBase.search` does.
|
|
9
|
+
*/
|
|
10
|
+
export declare const KNOWLEDGE_MODEL = "Xenova/bge-small-zh-v1.5";
|
|
11
|
+
/**
|
|
12
|
+
* HOW BIG ONE EMBEDDED PIECE MAY BE, in characters.
|
|
13
|
+
*
|
|
14
|
+
* THIS NUMBER IS A MODEL LIMIT, NOT A TUNING KNOB, and getting it wrong is invisible. The model
|
|
15
|
+
* declares `max_position_embeddings: 512`, and a Chinese character costs roughly one token, so a piece
|
|
16
|
+
* is a little under half the window: 300 characters leaves room for the `[CLS]`/`[SEP]` pair and for
|
|
17
|
+
* tokenisation that splits a few characters into two. The retrieval chunks are 1200 characters - more
|
|
18
|
+
* than double this - which is fine for something a MODEL reads and fatal for something a MODEL
|
|
19
|
+
* ENCODES: handed a 1200-character chunk, roughly 60% of it is silently discarded past the window.
|
|
20
|
+
* English would survive it, so the degradation would land only on Chinese: the language this project
|
|
21
|
+
* is written in.
|
|
22
|
+
*/
|
|
23
|
+
export declare const EMBED_PIECE_CHARS = 300;
|
|
24
|
+
export type VectorRecord = {
|
|
25
|
+
chunk: number;
|
|
26
|
+
values: number[];
|
|
27
|
+
};
|
|
28
|
+
/** What the settings panel shows, so "off" and "broken here" are never the same silent nothing. */
|
|
29
|
+
export type VectorStatus = {
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
ready: boolean;
|
|
32
|
+
indexed: number;
|
|
33
|
+
error?: string;
|
|
34
|
+
bundled?: boolean;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Splits a retrieval chunk into pieces small enough for the model's window.
|
|
38
|
+
*
|
|
39
|
+
* WHY NOT SHRINK THE RETRIEVAL CHUNKS INSTEAD. 1200 characters is the right size for a passage a model
|
|
40
|
+
* READS - it holds enough context to answer from. Encoding is a different constraint from reading, and
|
|
41
|
+
* conflating them would trade retrieval quality for a convenience the encoder does not need.
|
|
42
|
+
*/
|
|
43
|
+
export declare function embedPieces(text: string): string[];
|
|
44
|
+
/** Element-wise max over the pieces, then unit length. See `pool` for why max and not mean. */
|
|
45
|
+
export declare function pool(vectors: number[][]): number[];
|
|
46
|
+
export declare function cosine(a: number[], b: number[]): number;
|
|
47
|
+
/** One document's vectors, on disk next to the document they describe. */
|
|
48
|
+
export type VectorDocument = {
|
|
49
|
+
digest: string;
|
|
50
|
+
records: VectorRecord[];
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* The optional vector half of retrieval.
|
|
54
|
+
*
|
|
55
|
+
* EVERY FAILURE HERE IS A FALLBACK TO LEXICAL, NEVER AN ERROR. The model is an optional npm package
|
|
56
|
+
* that a skipped optional install can take away, and it is a 512-token window
|
|
57
|
+
* wrapped around a Chinese corpus. Any of those failing is a reason to answer from the lexical
|
|
58
|
+
* channels and say so - never a reason to fail a search that the lexical channels can already serve.
|
|
59
|
+
*/
|
|
60
|
+
export declare class VectorIndex {
|
|
61
|
+
private readonly directory;
|
|
62
|
+
private extractor?;
|
|
63
|
+
private loading?;
|
|
64
|
+
private failure?;
|
|
65
|
+
private cache;
|
|
66
|
+
private pending;
|
|
67
|
+
private queue;
|
|
68
|
+
private background?;
|
|
69
|
+
constructor(directory: string);
|
|
70
|
+
/**
|
|
71
|
+
* Where the shipped weights live, relative to this module.
|
|
72
|
+
*
|
|
73
|
+
* `dist/server/knowledge-vectors.js` is two levels below the package root both in a published
|
|
74
|
+
* package and in this repository's own build - the same `../../assets/...` shape `runtime.ts` uses to
|
|
75
|
+
* reach `agents/`. The directory keeps the upstream id in its path because that is how the library
|
|
76
|
+
* looks the model up under `localModelPath`.
|
|
77
|
+
*/
|
|
78
|
+
bundledModel(): string;
|
|
79
|
+
enabled(): boolean;
|
|
80
|
+
status(indexed: number): VectorStatus;
|
|
81
|
+
private workspace;
|
|
82
|
+
private folder;
|
|
83
|
+
private file;
|
|
84
|
+
/**
|
|
85
|
+
* Loads the model once, from the copy that ships with this package. The `dtype` is not optional: on
|
|
86
|
+
* Node the library defaults to fp32, which for this model is 90 MB rather than 23.
|
|
87
|
+
*
|
|
88
|
+
* THERE IS NO NETWORK HERE, and that is the whole point of vendoring the weights. The first version
|
|
89
|
+
* downloaded the model on first use, which put a 23 MB fetch behind a host the machine may not be
|
|
90
|
+
* able to reach, on the first search, for every person who installed the package. Shipping the
|
|
91
|
+
* weights turns a failure mode into a file, and makes the feature work on a machine that has never
|
|
92
|
+
* been online since it was installed.
|
|
93
|
+
*/
|
|
94
|
+
private load;
|
|
95
|
+
private embed;
|
|
96
|
+
/** Vectors already stored for one workspace, keyed by document then chunk index. */
|
|
97
|
+
private read;
|
|
98
|
+
count(workspaceId: string): Promise<number>;
|
|
99
|
+
/** Drops a document's vectors. Called whenever its content changes or it is deleted. */
|
|
100
|
+
forget(workspaceId: string, documentId: string): Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* Embeds anything not already stored, WITHOUT waiting for it.
|
|
103
|
+
*
|
|
104
|
+
* WHY IT IS NOT AWAITED. This runs after an import or on the first search of a session, and the
|
|
105
|
+
* first run of it downloads a model. Neither should make a person wait: the import returns as soon
|
|
106
|
+
* as the document is stored and the search returns lexical hits, and a later search is simply better
|
|
107
|
+
* because by then the vectors exist.
|
|
108
|
+
*
|
|
109
|
+
* WHY REQUESTS QUEUE RATHER THAN DROP. One job runs at a time - two would load the same model twice
|
|
110
|
+
* and race each other's writes - but a request arriving mid-job has to be remembered. Dropping it
|
|
111
|
+
* would mean a document imported while a background index was in flight stayed unindexed until the
|
|
112
|
+
* next restart, which is the kind of bug that only shows up on a busy machine weeks later.
|
|
113
|
+
*/
|
|
114
|
+
schedule(workspaceId: string, documents: Array<{
|
|
115
|
+
id: string;
|
|
116
|
+
digest: string;
|
|
117
|
+
chunks: Array<{
|
|
118
|
+
index: number;
|
|
119
|
+
text: string;
|
|
120
|
+
}>;
|
|
121
|
+
}>): void;
|
|
122
|
+
/**
|
|
123
|
+
* Cosine of every stored chunk against the query, best first.
|
|
124
|
+
*
|
|
125
|
+
* WHY THE RAW COSINE IS NOT RE-NORMALISED BY THE BEST HIT. Every lexical channel is divided by its
|
|
126
|
+
* own maximum, and that is right for them because BM25 scores are unbounded and have no absolute
|
|
127
|
+
* meaning. Cosine is the opposite: it is already a bounded, interpretable quantity, and dividing it
|
|
128
|
+
* by the best hit of the query would turn "nothing here is close" into "the best one is 1.0". The
|
|
129
|
+
* knowledge base's most useful property is that it can say it does not know, and rescaling is exactly
|
|
130
|
+
* how that gets spent.
|
|
131
|
+
*/
|
|
132
|
+
search(workspaceId: string, query: string, keys: Array<[string, string]>): Promise<Map<string, number> | null>;
|
|
133
|
+
}
|