@evandu/super-pi 0.1.83 → 0.1.85

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 (33) hide show
  1. package/agents/super-pi-knowledge/index.ts +40 -0
  2. package/assets/knowledge-model/README.md +37 -0
  3. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/config.json +31 -0
  4. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/onnx/model_quantized.onnx +0 -0
  5. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/special_tokens_map.json +7 -0
  6. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer.json +21278 -0
  7. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer_config.json +15 -0
  8. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/vocab.txt +21128 -0
  9. package/dist/.super-pi-check-inputs.json +4 -0
  10. package/dist/client/.super-pi-client-sources.json +8 -7
  11. package/dist/client/assets/index-B_jOwjyp.js +72 -0
  12. package/dist/client/assets/index-CNNGl4eF.css +1 -0
  13. package/dist/client/index.html +2 -2
  14. package/dist/server/application/run-service.d.ts +50 -11
  15. package/dist/server/application/run-service.js +206 -77
  16. package/dist/server/command-handler.js +16 -4
  17. package/dist/server/http-server.js +59 -1
  18. package/dist/server/index.js +7 -0
  19. package/dist/server/knowledge-vectors.d.ts +133 -0
  20. package/dist/server/knowledge-vectors.js +302 -0
  21. package/dist/server/knowledge.d.ts +263 -0
  22. package/dist/server/knowledge.js +743 -0
  23. package/dist/server/persistence/app-state-repository.d.ts +25 -1
  24. package/dist/server/persistence/sqlite-state-repository.js +47 -4
  25. package/dist/server/runtime.d.ts +48 -1
  26. package/dist/server/runtime.js +83 -7
  27. package/dist/server/server-context.d.ts +1 -0
  28. package/dist/server/store.d.ts +153 -68
  29. package/dist/server/store.js +405 -187
  30. package/dist/server/types.d.ts +97 -65
  31. package/package.json +163 -154
  32. package/dist/client/assets/index-DhOfFh0G.js +0 -72
  33. package/dist/client/assets/index-v2n_FYv_.css +0 -1
@@ -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
+ }
@@ -0,0 +1,302 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
4
+ import { join, resolve } from "node:path";
5
+ /**
6
+ * The model, and the only one this feature can use.
7
+ *
8
+ * WHY THIS ONE. `bge-small-zh` scores 0.618 nDCG@10 averaged over the Chinese retrieval tasks it is
9
+ * benchmarked on - against 0.695 for `bge-base-zh`, which is four times the download. `gte-multilingual-base`
10
+ * scores higher on both Chinese and English, at 324 MB against this one's 23 MB. For a workspace-sized
11
+ * corpus the next thing that would help is not a bigger model but fusing this one with the lexical
12
+ * channels, which costs nothing and is what `KnowledgeBase.search` does.
13
+ */
14
+ export const KNOWLEDGE_MODEL = "Xenova/bge-small-zh-v1.5";
15
+ /**
16
+ * HOW BIG ONE EMBEDDED PIECE MAY BE, in characters.
17
+ *
18
+ * THIS NUMBER IS A MODEL LIMIT, NOT A TUNING KNOB, and getting it wrong is invisible. The model
19
+ * declares `max_position_embeddings: 512`, and a Chinese character costs roughly one token, so a piece
20
+ * is a little under half the window: 300 characters leaves room for the `[CLS]`/`[SEP]` pair and for
21
+ * tokenisation that splits a few characters into two. The retrieval chunks are 1200 characters - more
22
+ * than double this - which is fine for something a MODEL reads and fatal for something a MODEL
23
+ * ENCODES: handed a 1200-character chunk, roughly 60% of it is silently discarded past the window.
24
+ * English would survive it, so the degradation would land only on Chinese: the language this project
25
+ * is written in.
26
+ */
27
+ export const EMBED_PIECE_CHARS = 300;
28
+ const EMBED_PIECE_OVERLAP = 40;
29
+ /** Per query. Cosine similarity of unit vectors; beyond this the passage is noise. */
30
+ const MIN_COSINE = 0.15;
31
+ /**
32
+ * Splits a retrieval chunk into pieces small enough for the model's window.
33
+ *
34
+ * WHY NOT SHRINK THE RETRIEVAL CHUNKS INSTEAD. 1200 characters is the right size for a passage a model
35
+ * READS - it holds enough context to answer from. Encoding is a different constraint from reading, and
36
+ * conflating them would trade retrieval quality for a convenience the encoder does not need.
37
+ */
38
+ export function embedPieces(text) {
39
+ const pieces = [];
40
+ for (let offset = 0; offset < text.length; offset += EMBED_PIECE_CHARS - EMBED_PIECE_OVERLAP) {
41
+ const piece = text.slice(offset, offset + EMBED_PIECE_CHARS).trim();
42
+ if (piece)
43
+ pieces.push(piece);
44
+ if (offset + EMBED_PIECE_CHARS >= text.length)
45
+ break;
46
+ }
47
+ return pieces.length ? pieces : [text.slice(0, EMBED_PIECE_CHARS)];
48
+ }
49
+ /** Element-wise max over the pieces, then unit length. See `pool` for why max and not mean. */
50
+ export function pool(vectors) {
51
+ if (!vectors.length)
52
+ return [];
53
+ const pooled = new Array(vectors[0].length).fill(Number.NEGATIVE_INFINITY);
54
+ for (const vector of vectors) {
55
+ for (let i = 0; i < vector.length; i++)
56
+ if (vector[i] > pooled[i])
57
+ pooled[i] = vector[i];
58
+ }
59
+ let norm = 0;
60
+ for (const value of pooled)
61
+ norm += value * value;
62
+ norm = Math.sqrt(norm) || 1;
63
+ return pooled.map(value => value / norm);
64
+ }
65
+ export function cosine(a, b) {
66
+ let dot = 0;
67
+ for (let i = 0; i < a.length && i < b.length; i++)
68
+ dot += a[i] * b[i];
69
+ return dot;
70
+ }
71
+ /**
72
+ * The optional vector half of retrieval.
73
+ *
74
+ * EVERY FAILURE HERE IS A FALLBACK TO LEXICAL, NEVER AN ERROR. The model is an optional npm package
75
+ * that a skipped optional install can take away, and it is a 512-token window
76
+ * wrapped around a Chinese corpus. Any of those failing is a reason to answer from the lexical
77
+ * channels and say so - never a reason to fail a search that the lexical channels can already serve.
78
+ */
79
+ export class VectorIndex {
80
+ directory;
81
+ extractor;
82
+ loading;
83
+ failure;
84
+ cache = new Map();
85
+ pending = new Set();
86
+ queue = [];
87
+ background;
88
+ constructor(directory) {
89
+ this.directory = directory;
90
+ }
91
+ /**
92
+ * Where the shipped weights live, relative to this module.
93
+ *
94
+ * `dist/server/knowledge-vectors.js` is two levels below the package root both in a published
95
+ * package and in this repository's own build - the same `../../assets/...` shape `runtime.ts` uses to
96
+ * reach `agents/`. The directory keeps the upstream id in its path because that is how the library
97
+ * looks the model up under `localModelPath`.
98
+ */
99
+ bundledModel() { return resolve(import.meta.dirname, "../../assets/knowledge-model"); }
100
+ enabled() { return process.env.SUPER_PI_KNOWLEDGE_EMBEDDINGS !== "0"; }
101
+ status(indexed) {
102
+ return {
103
+ enabled: this.enabled(), ready: Boolean(this.extractor), indexed, error: this.failure,
104
+ // Whether the shipped weights are on disk, reported before the model is ever loaded: "the
105
+ // package was built without its assets" and "the model failed to load" are different problems.
106
+ bundled: existsSync(this.bundledModel()),
107
+ };
108
+ }
109
+ workspace(workspaceId) { return join(this.directory, Buffer.from(workspaceId).toString("hex")); }
110
+ folder(workspaceId) { return join(this.workspace(workspaceId), "vectors"); }
111
+ file(workspaceId, documentId) { return join(this.folder(workspaceId), `${documentId}.vec.json`); }
112
+ /**
113
+ * Loads the model once, from the copy that ships with this package. The `dtype` is not optional: on
114
+ * Node the library defaults to fp32, which for this model is 90 MB rather than 23.
115
+ *
116
+ * THERE IS NO NETWORK HERE, and that is the whole point of vendoring the weights. The first version
117
+ * downloaded the model on first use, which put a 23 MB fetch behind a host the machine may not be
118
+ * able to reach, on the first search, for every person who installed the package. Shipping the
119
+ * weights turns a failure mode into a file, and makes the feature work on a machine that has never
120
+ * been online since it was installed.
121
+ */
122
+ load() {
123
+ if (this.extractor)
124
+ return Promise.resolve(this.extractor);
125
+ if (this.failure)
126
+ return Promise.resolve(undefined);
127
+ this.loading ??= (async () => {
128
+ try {
129
+ if (!existsSync(this.bundledModel()))
130
+ throw new Error(`随包分发的模型缺失:${this.bundledModel()}`);
131
+ // THE SPECIFIER GOES THROUGH A VARIABLE, and that is deliberate. Written as a literal,
132
+ // `tsc` resolves it while compiling - so a machine whose optional install skipped this package
133
+ // could not build the server at all, which makes an "optional" dependency mandatory in the
134
+ // only sense that matters to a person who installed it. Node resolves the variable at RUN time
135
+ // instead, where its absence is already handled: the throw below is the same branch that
136
+ // handles a missing model.
137
+ const specifier = "@huggingface/transformers";
138
+ const { pipeline, env } = await import(specifier);
139
+ // The library resolves `<localModelPath>/<model id>/onnx/...`, which is why the vendored tree
140
+ // keeps the `Xenova/bge-small-zh-v1.5` directory rather than being flattened.
141
+ env.localModelPath = this.bundledModel();
142
+ env.allowRemoteModels = false;
143
+ this.extractor = await pipeline("feature-extraction", KNOWLEDGE_MODEL, { dtype: "q8" });
144
+ return this.extractor;
145
+ }
146
+ catch (error) {
147
+ this.failure = error instanceof Error ? error.message : String(error);
148
+ console.warn(`Super Pi: 语义检索不可用,将只用关键词检索:${this.failure}`);
149
+ return undefined;
150
+ }
151
+ finally {
152
+ this.loading = undefined;
153
+ }
154
+ })();
155
+ return this.loading;
156
+ }
157
+ async embed(texts) {
158
+ if (!texts.length || !this.enabled())
159
+ return null;
160
+ const extractor = await this.load();
161
+ if (!extractor)
162
+ return null;
163
+ try {
164
+ const output = await extractor(texts, { pooling: "cls", normalize: true });
165
+ const dimension = output.dims[output.dims.length - 1];
166
+ return Array.from({ length: output.dims[0] }, (_, row) => Array.from(output.data.slice(row * dimension, (row + 1) * dimension)));
167
+ }
168
+ catch (error) {
169
+ this.failure = error instanceof Error ? error.message : String(error);
170
+ console.warn(`Super Pi: 生成向量失败,将只用关键词检索:${this.failure}`);
171
+ return null;
172
+ }
173
+ }
174
+ /** Vectors already stored for one workspace, keyed by document then chunk index. */
175
+ async read(workspaceId, documentId) {
176
+ try {
177
+ const document = JSON.parse(await readFile(this.file(workspaceId, documentId), "utf8"));
178
+ return Array.isArray(document?.records) ? document : null;
179
+ }
180
+ catch (error) {
181
+ if (error.code === "ENOENT")
182
+ return null;
183
+ return null;
184
+ }
185
+ }
186
+ async count(workspaceId) {
187
+ try {
188
+ const names = (await readdir(this.folder(workspaceId))).filter(name => name.endsWith(".vec.json"));
189
+ let total = 0;
190
+ for (const name of names) {
191
+ const document = await this.read(workspaceId, name.slice(0, -".vec.json".length));
192
+ total += document?.records.length ?? 0;
193
+ }
194
+ return total;
195
+ }
196
+ catch {
197
+ return 0;
198
+ }
199
+ }
200
+ /** Drops a document's vectors. Called whenever its content changes or it is deleted. */
201
+ async forget(workspaceId, documentId) {
202
+ this.cache.delete(`${workspaceId}/${documentId}`);
203
+ await unlink(this.file(workspaceId, documentId)).catch(() => { });
204
+ }
205
+ /**
206
+ * Embeds anything not already stored, WITHOUT waiting for it.
207
+ *
208
+ * WHY IT IS NOT AWAITED. This runs after an import or on the first search of a session, and the
209
+ * first run of it downloads a model. Neither should make a person wait: the import returns as soon
210
+ * as the document is stored and the search returns lexical hits, and a later search is simply better
211
+ * because by then the vectors exist.
212
+ *
213
+ * WHY REQUESTS QUEUE RATHER THAN DROP. One job runs at a time - two would load the same model twice
214
+ * and race each other's writes - but a request arriving mid-job has to be remembered. Dropping it
215
+ * would mean a document imported while a background index was in flight stayed unindexed until the
216
+ * next restart, which is the kind of bug that only shows up on a busy machine weeks later.
217
+ */
218
+ schedule(workspaceId, documents) {
219
+ if (!this.enabled() || !documents.length)
220
+ return;
221
+ this.queue.push({ workspaceId, documents });
222
+ if (this.background)
223
+ return;
224
+ this.background = (async () => {
225
+ while (this.queue.length) {
226
+ const next = this.queue.shift();
227
+ try {
228
+ for (const document of next.documents) {
229
+ const key = `${next.workspaceId}/${document.id}`;
230
+ if (this.pending.has(key))
231
+ continue;
232
+ this.pending.add(key);
233
+ const stored = await this.read(next.workspaceId, document.id);
234
+ if (stored?.digest === document.digest && stored.records.length === document.chunks.length) {
235
+ this.pending.delete(key);
236
+ continue;
237
+ }
238
+ const vectors = [];
239
+ for (const chunk of document.chunks) {
240
+ const embedded = await this.embed(embedPieces(chunk.text));
241
+ if (!embedded) {
242
+ this.pending.delete(key);
243
+ return;
244
+ }
245
+ vectors.push({ chunk: chunk.index, values: pool(embedded) });
246
+ }
247
+ const folder = this.folder(next.workspaceId);
248
+ await mkdir(folder, { recursive: true });
249
+ const file = this.file(next.workspaceId, document.id), temporary = `${file}.${randomUUID()}.tmp`;
250
+ await writeFile(temporary, JSON.stringify({ digest: document.digest, records: vectors }), { encoding: "utf8", mode: 0o600 });
251
+ await rename(temporary, file);
252
+ this.cache.set(key, new Map(vectors.map(record => [record.chunk, record])));
253
+ this.pending.delete(key);
254
+ }
255
+ }
256
+ catch (error) {
257
+ console.warn(`Super Pi: 语义索引失败,将只用关键词检索:${error instanceof Error ? error.message : String(error)}`);
258
+ }
259
+ }
260
+ })().finally(() => { this.background = undefined; });
261
+ }
262
+ /**
263
+ * Cosine of every stored chunk against the query, best first.
264
+ *
265
+ * WHY THE RAW COSINE IS NOT RE-NORMALISED BY THE BEST HIT. Every lexical channel is divided by its
266
+ * own maximum, and that is right for them because BM25 scores are unbounded and have no absolute
267
+ * meaning. Cosine is the opposite: it is already a bounded, interpretable quantity, and dividing it
268
+ * by the best hit of the query would turn "nothing here is close" into "the best one is 1.0". The
269
+ * knowledge base's most useful property is that it can say it does not know, and rescaling is exactly
270
+ * how that gets spent.
271
+ */
272
+ async search(workspaceId, query, keys) {
273
+ if (!this.enabled() || !keys.length)
274
+ return null;
275
+ const documentIds = new Set(keys.map(([, documentId]) => documentId));
276
+ // First query of the session: read what was indexed last time rather than assuming nothing is.
277
+ for (const documentId of documentIds) {
278
+ if (this.cache.has(`${workspaceId}/${documentId}`))
279
+ continue;
280
+ const document = await this.read(workspaceId, documentId);
281
+ if (document)
282
+ this.cache.set(`${workspaceId}/${documentId}`, new Map(document.records.map(record => [record.chunk, record])));
283
+ }
284
+ const available = keys.filter(([, documentId]) => this.cache.get(`${workspaceId}/${documentId}`)?.size);
285
+ if (!available.length)
286
+ return null;
287
+ const vector = await this.embed([query]);
288
+ if (!vector?.[0])
289
+ return null;
290
+ const scores = new Map();
291
+ for (const [key, documentId] of available) {
292
+ const chunk = Number(key.split("#")[1]);
293
+ const record = this.cache.get(`${workspaceId}/${documentId}`)?.get(chunk);
294
+ if (!record)
295
+ continue;
296
+ const similarity = cosine(vector[0], record.values);
297
+ if (similarity >= MIN_COSINE)
298
+ scores.set(key, similarity);
299
+ }
300
+ return scores;
301
+ }
302
+ }