@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.
Files changed (40) hide show
  1. package/agents/super-pi-knowledge/index.ts +40 -0
  2. package/agents/super-pi-task/index.ts +2 -2
  3. package/assets/knowledge-model/README.md +37 -0
  4. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/config.json +31 -0
  5. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/onnx/model_quantized.onnx +0 -0
  6. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/special_tokens_map.json +7 -0
  7. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer.json +21278 -0
  8. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer_config.json +15 -0
  9. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/vocab.txt +21128 -0
  10. package/dist/.super-pi-check-inputs.json +4 -0
  11. package/dist/client/.super-pi-client-sources.json +10 -7
  12. package/dist/client/assets/index-B3QVoYJY.css +1 -0
  13. package/dist/client/assets/index-C_giUkvT.js +72 -0
  14. package/dist/client/index.html +2 -2
  15. package/dist/server/application/run-service.d.ts +50 -11
  16. package/dist/server/application/run-service.js +206 -77
  17. package/dist/server/command-handler.js +16 -4
  18. package/dist/server/http-server.js +218 -8
  19. package/dist/server/index.js +15 -1
  20. package/dist/server/knowledge-vectors.d.ts +133 -0
  21. package/dist/server/knowledge-vectors.js +302 -0
  22. package/dist/server/knowledge.d.ts +263 -0
  23. package/dist/server/knowledge.js +743 -0
  24. package/dist/server/persistence/app-state-repository.d.ts +25 -1
  25. package/dist/server/persistence/sqlite-state-repository.js +47 -4
  26. package/dist/server/runtime.d.ts +82 -1
  27. package/dist/server/runtime.js +128 -7
  28. package/dist/server/server-context.d.ts +3 -0
  29. package/dist/server/store.d.ts +153 -68
  30. package/dist/server/store.js +405 -187
  31. package/dist/server/tool-llm.d.ts +65 -0
  32. package/dist/server/tool-llm.js +139 -0
  33. package/dist/server/tool-registry.d.ts +65 -0
  34. package/dist/server/tool-registry.js +129 -6
  35. package/dist/server/tool-runner.d.ts +70 -0
  36. package/dist/server/tool-runner.js +210 -10
  37. package/dist/server/types.d.ts +97 -65
  38. package/package.json +166 -155
  39. package/dist/client/assets/index-8Pf3MG2d.js +0 -72
  40. 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
- if (requestPath !== "/api/memory/tool" && (requestPath !== "/api/tools" || request.method !== "POST") && !requestPath.startsWith("/tools/") && !isAuthorized(request, accessToken)) {
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
- json(200, { tools: await toolRegistry.list() });
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
- let raw = "";
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
- raw += chunk;
214
- if (raw.length > 256_000) {
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
- const tool = await toolRegistry.register({ slug: body.slug, name: body.name, description: body.description, entrypoint: body.entrypoint, script: body.script, files: body.files });
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");
@@ -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
- const toolRunner = new ToolRunner();
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
+ }