@danielblomma/cortex-mcp 2.1.1 → 2.2.0
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/README.md +72 -24
- package/bin/cortex.mjs +50 -21
- package/mcp-registry-submission.json +63 -0
- package/package.json +19 -5
- package/scaffold/AGENTS.md +13 -9
- package/scaffold/CLAUDE.md +13 -9
- package/scaffold/docs/architecture.md +1 -1
- package/scaffold/mcp/build.mjs +92 -0
- package/scaffold/mcp/package.json +4 -4
- package/scaffold/mcp/src/cli/query.ts +455 -0
- package/scaffold/mcp/src/embed.ts +181 -77
- package/scaffold/mcp/src/embedScheduler.ts +509 -0
- package/scaffold/mcp/src/embeddings.ts +31 -30
- package/scaffold/mcp/src/graphCsv.ts +65 -0
- package/scaffold/mcp/src/jsonl.ts +69 -14
- package/scaffold/mcp/src/loadGraph.ts +276 -40
- package/scaffold/mcp/src/lruCache.ts +41 -0
- package/scaffold/mcp/src/searchCore.ts +1 -1
- package/scaffold/mcp/src/searchResults.ts +3 -3
- package/scaffold/mcp/src/types.ts +6 -1
- package/scaffold/mcp/tests/embed-scheduler.test.mjs +474 -0
- package/scaffold/mcp/tests/embedding-jsonl.test.mjs +39 -0
- package/scaffold/mcp/tests/graph-bulk-load.test.mjs +258 -0
- package/scaffold/mcp/tests/graph-csv.test.mjs +118 -0
- package/scaffold/mcp/tests/lru-cache.test.mjs +37 -0
- package/scaffold/mcp/tests/query-cli.test.mjs +115 -0
- package/scaffold/mcp/tests/vector-index.test.mjs +109 -0
- package/scaffold/mcp/tsconfig.json +3 -1
- package/scaffold/scripts/bootstrap.sh +37 -4
- package/scaffold/scripts/dashboard.mjs +1 -1
- package/scaffold/scripts/doctor.sh +9 -8
- package/scaffold/scripts/embed.sh +3 -2
- package/scaffold/scripts/ingest-parsers.mjs +387 -0
- package/scaffold/scripts/ingest-worker.mjs +52 -0
- package/scaffold/scripts/ingest.mjs +550 -378
- package/scaffold/scripts/load-ryu.sh +3 -2
- package/scaffold/scripts/memory-compile.mjs +5 -2
- package/scaffold/scripts/memory-lint.mjs +5 -2
- package/scaffold/scripts/parsers/csharp.mjs +2 -1
- package/scaffold/scripts/parsers/javascript/ast.mjs +160 -8
- package/scaffold/scripts/parsers/javascript/chunks.mjs +129 -22
- package/scaffold/scripts/parsers/javascript/imports.mjs +38 -4
- package/scaffold/scripts/parsers/vbnet.mjs +2 -1
- package/scaffold/scripts/status.sh +50 -14
- package/scaffold/scripts/parsers/node_modules/.package-lock.json +0 -56
- package/scaffold/scripts/parsers/node_modules/acorn/CHANGELOG.md +0 -972
- package/scaffold/scripts/parsers/node_modules/acorn/LICENSE +0 -21
- package/scaffold/scripts/parsers/node_modules/acorn/README.md +0 -301
- package/scaffold/scripts/parsers/node_modules/acorn/bin/acorn +0 -4
- package/scaffold/scripts/parsers/node_modules/acorn/dist/acorn.d.mts +0 -883
- package/scaffold/scripts/parsers/node_modules/acorn/dist/acorn.d.ts +0 -883
- package/scaffold/scripts/parsers/node_modules/acorn/dist/acorn.js +0 -6295
- package/scaffold/scripts/parsers/node_modules/acorn/dist/acorn.mjs +0 -6266
- package/scaffold/scripts/parsers/node_modules/acorn/dist/bin.js +0 -90
- package/scaffold/scripts/parsers/node_modules/acorn/package.json +0 -50
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/CHANGELOG.md +0 -421
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/LICENSE +0 -21
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/README.md +0 -81
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/error.d.ts +0 -103
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/error.js +0 -78
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/error.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/decorators.d.ts +0 -167
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/decorators.js +0 -75
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/decorators.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/import-assertions.d.ts +0 -177
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/import-assertions.js +0 -56
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/import-assertions.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/index.d.ts +0 -198
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/index.js +0 -327
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/index.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/xhtml.d.ts +0 -256
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/xhtml.js +0 -256
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/xhtml.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/index.d.ts +0 -472
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/index.js +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/index.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/index.mjs +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/middleware.d.ts +0 -159
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/middleware.js +0 -2
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/middleware.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/parseutil.d.ts +0 -10
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/parseutil.js +0 -38
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/parseutil.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/scopeflags.d.ts +0 -12
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/scopeflags.js +0 -29
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/scopeflags.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/tokenType.d.ts +0 -2
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/tokenType.js +0 -118
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/tokenType.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/types.d.ts +0 -60
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/types.js +0 -2
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/types.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/whitespace.d.ts +0 -2
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/whitespace.js +0 -19
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/whitespace.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/package.json +0 -53
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/tsconfig.json +0 -19
- package/scaffold/scripts/parsers/node_modules/acorn-walk/CHANGELOG.md +0 -209
- package/scaffold/scripts/parsers/node_modules/acorn-walk/LICENSE +0 -21
- package/scaffold/scripts/parsers/node_modules/acorn-walk/README.md +0 -124
- package/scaffold/scripts/parsers/node_modules/acorn-walk/dist/walk.d.mts +0 -152
- package/scaffold/scripts/parsers/node_modules/acorn-walk/dist/walk.d.ts +0 -152
- package/scaffold/scripts/parsers/node_modules/acorn-walk/dist/walk.js +0 -485
- package/scaffold/scripts/parsers/node_modules/acorn-walk/dist/walk.mjs +0 -467
- package/scaffold/scripts/parsers/node_modules/acorn-walk/package.json +0 -50
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/LICENSE +0 -24
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/README.md +0 -23
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-bash.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-c.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-c_sharp.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-cpp.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-css.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-dart.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-elisp.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-elixir.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-elm.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-embedded_template.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-go.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-html.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-java.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-javascript.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-json.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-kotlin.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-lua.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-objc.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-ocaml.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-php.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-python.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-ql.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-rescript.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-ruby.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-rust.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-scala.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-solidity.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-swift.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-systemrdl.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-tlaplus.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-toml.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-tsx.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-typescript.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-vue.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-yaml.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-zig.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/package.json +0 -64
- package/scaffold/scripts/parsers/node_modules/web-tree-sitter/LICENSE +0 -21
- package/scaffold/scripts/parsers/node_modules/web-tree-sitter/README.md +0 -198
- package/scaffold/scripts/parsers/node_modules/web-tree-sitter/package.json +0 -37
- package/scaffold/scripts/parsers/node_modules/web-tree-sitter/tree-sitter-web.d.ts +0 -242
- package/scaffold/scripts/parsers/node_modules/web-tree-sitter/tree-sitter.js +0 -1
- package/scaffold/scripts/parsers/node_modules/web-tree-sitter/tree-sitter.wasm +0 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding scheduler: maximizes embedding throughput with zero change to the
|
|
3
|
+
* produced vectors (same model, same per-text inference semantics).
|
|
4
|
+
*
|
|
5
|
+
* Measured motivation (onnxruntime-node CPU):
|
|
6
|
+
* - One session cannot saturate many cores: 3 sessions x 4 threads beat
|
|
7
|
+
* 1 session x 12 threads by ~2x for BOTH short and long texts.
|
|
8
|
+
* - Batching pays only for short, similar-length texts; mixed-length batches
|
|
9
|
+
* lose badly to padding (attention cost is quadratic in padded length).
|
|
10
|
+
*
|
|
11
|
+
* Strategy: deduplicate identical texts, measure token lengths, micro-batch
|
|
12
|
+
* only short texts under a strict pad-waste bound, run everything through a
|
|
13
|
+
* pool of small-thread sessions, longest work first.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type PendingText = { index: number; text: string };
|
|
17
|
+
|
|
18
|
+
/** One unique text with every entity slot index it must fill. */
|
|
19
|
+
export type UniqueText = { text: string; indexes: number[] };
|
|
20
|
+
|
|
21
|
+
export type MeasuredText = UniqueText & { tokens: number };
|
|
22
|
+
|
|
23
|
+
export type WorkUnit = {
|
|
24
|
+
kind: "single" | "batch";
|
|
25
|
+
texts: string[];
|
|
26
|
+
/** Per text: the slot indexes that receive its vector. */
|
|
27
|
+
members: number[][];
|
|
28
|
+
maxTokens: number;
|
|
29
|
+
/** Padded-compute proxy used for longest-first ordering. */
|
|
30
|
+
cost: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type SchedulerOptions = {
|
|
34
|
+
/** Texts at or below this token count are eligible for micro-batching. */
|
|
35
|
+
shortMaxTokens: number;
|
|
36
|
+
/** Upper bound for batchSize x maxTokens of a micro-batch. */
|
|
37
|
+
batchTokenBudget: number;
|
|
38
|
+
/** Maximum texts per micro-batch (1 disables batching). */
|
|
39
|
+
batchMaxItems: number;
|
|
40
|
+
/** Maximum fraction of padded compute wasted on padding within a batch. */
|
|
41
|
+
maxPadWaste: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_SCHEDULER_OPTIONS: SchedulerOptions = {
|
|
45
|
+
shortMaxTokens: 128,
|
|
46
|
+
batchTokenBudget: 2048,
|
|
47
|
+
// Micro-batching is OFF by default: batched GEMM uses different tiling than
|
|
48
|
+
// batch-of-1, which flips ~1e-6 rounding boundaries in the stored vectors
|
|
49
|
+
// (measured: 76/120 rounded vectors differ; cosine impact ~1e-9). The pool
|
|
50
|
+
// and dedup deliver the throughput win with byte-identical output; set
|
|
51
|
+
// CORTEX_EMBED_BATCH_SIZE>1 to opt in where byte identity does not matter.
|
|
52
|
+
batchMaxItems: 1,
|
|
53
|
+
maxPadWaste: 0.1
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Groups identical texts so each unique string is embedded exactly once. */
|
|
57
|
+
export function groupDuplicates(pending: PendingText[]): UniqueText[] {
|
|
58
|
+
const byText = new Map<string, number[]>();
|
|
59
|
+
for (const item of pending) {
|
|
60
|
+
const indexes = byText.get(item.text);
|
|
61
|
+
if (indexes) {
|
|
62
|
+
indexes.push(item.index);
|
|
63
|
+
} else {
|
|
64
|
+
byText.set(item.text, [item.index]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return [...byText.entries()].map(([text, indexes]) => ({ text, indexes }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function singleUnit(item: MeasuredText): WorkUnit {
|
|
71
|
+
return {
|
|
72
|
+
kind: "single",
|
|
73
|
+
texts: [item.text],
|
|
74
|
+
members: [item.indexes],
|
|
75
|
+
maxTokens: item.tokens,
|
|
76
|
+
cost: item.tokens * item.tokens
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function batchUnit(items: MeasuredText[]): WorkUnit {
|
|
81
|
+
const maxTokens = items[items.length - 1].tokens; // items arrive sorted asc
|
|
82
|
+
return {
|
|
83
|
+
kind: items.length === 1 ? "single" : "batch",
|
|
84
|
+
texts: items.map((item) => item.text),
|
|
85
|
+
members: items.map((item) => item.indexes),
|
|
86
|
+
maxTokens,
|
|
87
|
+
cost: items.length * maxTokens * maxTokens
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Packs measured texts into work units. Long texts run alone (padding-free);
|
|
93
|
+
* short texts are greedily packed, ascending by length, into batches bounded
|
|
94
|
+
* by token budget, item count, and a pad-waste ceiling. Units are returned
|
|
95
|
+
* longest-cost-first so the pool does not end on a single long straggler.
|
|
96
|
+
*/
|
|
97
|
+
export function packWorkUnits(unique: MeasuredText[], options: SchedulerOptions): WorkUnit[] {
|
|
98
|
+
if (options.batchMaxItems < 1) {
|
|
99
|
+
throw new Error(`Invalid batchMaxItems: ${options.batchMaxItems}`);
|
|
100
|
+
}
|
|
101
|
+
const units: WorkUnit[] = [];
|
|
102
|
+
const shorts: MeasuredText[] = [];
|
|
103
|
+
|
|
104
|
+
for (const item of unique) {
|
|
105
|
+
if (options.batchMaxItems > 1 && item.tokens <= options.shortMaxTokens) {
|
|
106
|
+
shorts.push(item);
|
|
107
|
+
} else {
|
|
108
|
+
units.push(singleUnit(item));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
shorts.sort((a, b) => a.tokens - b.tokens);
|
|
113
|
+
let current: MeasuredText[] = [];
|
|
114
|
+
let currentSum = 0;
|
|
115
|
+
|
|
116
|
+
const flush = () => {
|
|
117
|
+
if (current.length > 0) {
|
|
118
|
+
units.push(batchUnit(current));
|
|
119
|
+
current = [];
|
|
120
|
+
currentSum = 0;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
for (const item of shorts) {
|
|
125
|
+
if (current.length > 0) {
|
|
126
|
+
const nextCount = current.length + 1;
|
|
127
|
+
const nextMax = item.tokens; // ascending order: the newcomer is the max
|
|
128
|
+
const paddedCompute = nextCount * nextMax;
|
|
129
|
+
const padWaste = (paddedCompute - (currentSum + item.tokens)) / paddedCompute;
|
|
130
|
+
if (
|
|
131
|
+
nextCount > options.batchMaxItems ||
|
|
132
|
+
paddedCompute > options.batchTokenBudget ||
|
|
133
|
+
padWaste > options.maxPadWaste
|
|
134
|
+
) {
|
|
135
|
+
flush();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
current.push(item);
|
|
139
|
+
currentSum += item.tokens;
|
|
140
|
+
}
|
|
141
|
+
flush();
|
|
142
|
+
|
|
143
|
+
return units.sort((a, b) => b.cost - a.cost);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type PoolConfig = { sessions: number; threadsPerSession: number };
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Usable memory for embedding work, from raw OS/process signals:
|
|
150
|
+
* - container limits (cgroups) cap everything: os.totalmem()/freemem() see
|
|
151
|
+
* the HOST, which would size pools/gates dangerously large inside a
|
|
152
|
+
* limited container;
|
|
153
|
+
* - "free" memory understates reality on platforms that count reclaimable
|
|
154
|
+
* cache as used (macOS reports ~0.5GB free on an idle 24GB laptop), so it
|
|
155
|
+
* is floored at a fraction of the effective total;
|
|
156
|
+
* - never plan beyond half of the effective total.
|
|
157
|
+
*/
|
|
158
|
+
export function resolveMemoryHeadroom({
|
|
159
|
+
freeMemory,
|
|
160
|
+
totalMemory,
|
|
161
|
+
constrainedMemory,
|
|
162
|
+
availableMemory
|
|
163
|
+
}: {
|
|
164
|
+
freeMemory: number;
|
|
165
|
+
totalMemory: number;
|
|
166
|
+
constrainedMemory?: number | null;
|
|
167
|
+
availableMemory?: number | null;
|
|
168
|
+
}): number {
|
|
169
|
+
const total = Number.isFinite(totalMemory) && totalMemory > 0 ? totalMemory : 8e9;
|
|
170
|
+
const limit =
|
|
171
|
+
Number.isFinite(constrainedMemory ?? NaN) && (constrainedMemory as number) > 0
|
|
172
|
+
? (constrainedMemory as number)
|
|
173
|
+
: Infinity;
|
|
174
|
+
const effectiveTotal = Math.min(total, limit);
|
|
175
|
+
const free = Number.isFinite(freeMemory) && freeMemory >= 0 ? freeMemory : 0;
|
|
176
|
+
const available =
|
|
177
|
+
Number.isFinite(availableMemory ?? NaN) && (availableMemory as number) > 0
|
|
178
|
+
? (availableMemory as number)
|
|
179
|
+
: free;
|
|
180
|
+
return Math.min(effectiveTotal * 0.5, Math.max(available, effectiveTotal * 0.375));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Sanitizes a tokenizer-reported maximum sequence length. Sentinel and
|
|
184
|
+
* absurd values (some configs report 1e30) fall back to 8192; anything
|
|
185
|
+
* beyond 128k tokens is treated as absurd. */
|
|
186
|
+
export function resolveModelMaxTokens(raw: unknown): number {
|
|
187
|
+
const value = Number(raw);
|
|
188
|
+
return Number.isFinite(value) && value >= 1 && value <= 131072 ? Math.floor(value) : 8192;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Builds a token counter from a tokenizer callable. Falls back to a
|
|
193
|
+
* chars/4 estimate when the tokenizer is unavailable, misbehaves, or
|
|
194
|
+
* returns an unexpected shape; always clamps at the model maximum, since
|
|
195
|
+
* inference truncates there anyway.
|
|
196
|
+
*/
|
|
197
|
+
export function createTokenCounter(
|
|
198
|
+
tokenizer: unknown,
|
|
199
|
+
modelMaxTokens: number
|
|
200
|
+
): (text: string) => number {
|
|
201
|
+
return (text: string): number => {
|
|
202
|
+
let tokens = Math.max(1, Math.ceil(text.length / 4));
|
|
203
|
+
try {
|
|
204
|
+
if (typeof tokenizer === "function") {
|
|
205
|
+
const encoded = (tokenizer as (t: string) => { input_ids?: { dims?: number[] } })(text);
|
|
206
|
+
const dims = encoded?.input_ids?.dims;
|
|
207
|
+
const last = Array.isArray(dims) ? Number(dims[dims.length - 1]) : NaN;
|
|
208
|
+
if (Number.isFinite(last) && last > 0) {
|
|
209
|
+
tokens = last;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// keep the character estimate
|
|
214
|
+
}
|
|
215
|
+
return Math.min(tokens, modelMaxTokens);
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Estimated working memory one full pool session needs (model copy plus
|
|
220
|
+
* inference activations for a base-size model). Used only as a derating
|
|
221
|
+
* heuristic; deliberately conservative. */
|
|
222
|
+
const BYTES_PER_SESSION_ESTIMATE = 3e9;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Chooses the session-pool shape from a total thread budget and available
|
|
226
|
+
* memory. Four threads per session is the measured sweet spot; the session
|
|
227
|
+
* count derates on low-memory machines (each session holds its own model
|
|
228
|
+
* copy); tiny workloads skip the pool to avoid paying several model loads
|
|
229
|
+
* for seconds of inference. Everything is derived from the machine — no
|
|
230
|
+
* user configuration expected.
|
|
231
|
+
*/
|
|
232
|
+
export function resolvePoolConfig({
|
|
233
|
+
threadBudget,
|
|
234
|
+
poolOverride,
|
|
235
|
+
uniqueCount,
|
|
236
|
+
memoryBytes
|
|
237
|
+
}: {
|
|
238
|
+
threadBudget: number;
|
|
239
|
+
poolOverride?: number | null;
|
|
240
|
+
uniqueCount: number;
|
|
241
|
+
memoryBytes?: number;
|
|
242
|
+
}): PoolConfig {
|
|
243
|
+
const budget = Number.isFinite(threadBudget) && threadBudget >= 1 ? Math.floor(threadBudget) : 1;
|
|
244
|
+
const memorySessionCap =
|
|
245
|
+
Number.isFinite(memoryBytes) && (memoryBytes as number) > 0
|
|
246
|
+
? Math.max(1, Math.floor((memoryBytes as number) / BYTES_PER_SESSION_ESTIMATE))
|
|
247
|
+
: 4;
|
|
248
|
+
if (poolOverride && Number.isFinite(poolOverride) && poolOverride >= 1) {
|
|
249
|
+
// The override never exceeds the thread budget: CORTEX_EMBED_THREADS is a
|
|
250
|
+
// contract for co-located instances. Hard upper clamp keeps model-copy
|
|
251
|
+
// memory bounded even on huge machines. Deliberately NOT memory-derated:
|
|
252
|
+
// the explicit override is the operator's escape hatch when the
|
|
253
|
+
// heuristics misjudge a machine.
|
|
254
|
+
const sessions = Math.max(1, Math.min(Math.floor(poolOverride), budget, 8));
|
|
255
|
+
return { sessions, threadsPerSession: Math.max(1, Math.floor(budget / sessions)) };
|
|
256
|
+
}
|
|
257
|
+
if (uniqueCount < 64) {
|
|
258
|
+
return { sessions: 1, threadsPerSession: budget };
|
|
259
|
+
}
|
|
260
|
+
const threadsPerSession = Math.min(4, budget);
|
|
261
|
+
const sessions = Math.min(
|
|
262
|
+
4,
|
|
263
|
+
memorySessionCap,
|
|
264
|
+
Math.max(1, Math.floor(budget / threadsPerSession))
|
|
265
|
+
);
|
|
266
|
+
return { sessions, threadsPerSession };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Estimated activation memory per padded token for a base-size model at
|
|
270
|
+
* full context (linearized from a measured ~3GB per 8k-token inference;
|
|
271
|
+
* overcharges short texts, which is the safe direction). */
|
|
272
|
+
const BYTES_PER_IN_FLIGHT_TOKEN = 366_000;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Adapts the concurrent-token gate to the machine: enough budget to overlap
|
|
276
|
+
* several long texts when memory allows, never less than one model-max unit
|
|
277
|
+
* (so the largest possible text can always run), never more than eight.
|
|
278
|
+
*/
|
|
279
|
+
export function resolveInFlightTokens({
|
|
280
|
+
memoryBytes,
|
|
281
|
+
modelMaxTokens
|
|
282
|
+
}: {
|
|
283
|
+
memoryBytes?: number;
|
|
284
|
+
modelMaxTokens: number;
|
|
285
|
+
}): number {
|
|
286
|
+
const maxUnit = Number.isFinite(modelMaxTokens) && modelMaxTokens >= 1 ? Math.floor(modelMaxTokens) : 8192;
|
|
287
|
+
if (!Number.isFinite(memoryBytes) || (memoryBytes as number) <= 0) {
|
|
288
|
+
return Math.max(DEFAULT_MAX_IN_FLIGHT_TOKENS, maxUnit);
|
|
289
|
+
}
|
|
290
|
+
const affordable = Math.floor((memoryBytes as number) / BYTES_PER_IN_FLIGHT_TOKEN);
|
|
291
|
+
return Math.min(Math.max(affordable, maxUnit), 8 * maxUnit);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function toEmbeddingVector(output: unknown): number[] {
|
|
295
|
+
if (!output || typeof output !== "object") {
|
|
296
|
+
throw new Error("Invalid embedding output type");
|
|
297
|
+
}
|
|
298
|
+
const data = (output as { data?: unknown }).data;
|
|
299
|
+
if (!data || typeof (data as ArrayLike<number>).length !== "number") {
|
|
300
|
+
throw new Error("Missing embedding data");
|
|
301
|
+
}
|
|
302
|
+
return Array.from(data as ArrayLike<number>).map((value) => Number(value));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Splits a batched [batch, dim] feature-extraction output into row vectors. */
|
|
306
|
+
export function sliceBatchVectors(output: unknown, batchLength: number): number[][] {
|
|
307
|
+
const flat = toEmbeddingVector(output);
|
|
308
|
+
if (batchLength < 1) {
|
|
309
|
+
throw new Error(`Invalid batch length: ${batchLength}`);
|
|
310
|
+
}
|
|
311
|
+
const dims = (output as { dims?: unknown }).dims;
|
|
312
|
+
const lastDim =
|
|
313
|
+
Array.isArray(dims) && dims.length > 0 && typeof dims[dims.length - 1] === "number"
|
|
314
|
+
? Number(dims[dims.length - 1])
|
|
315
|
+
: null;
|
|
316
|
+
const dimensions = lastDim && lastDim > 0 ? lastDim : flat.length / batchLength;
|
|
317
|
+
if (!Number.isInteger(dimensions) || dimensions < 1 || flat.length !== dimensions * batchLength) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`Batched embedding output shape mismatch: ${flat.length} values for ${batchLength} inputs`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
const vectors: number[][] = [];
|
|
323
|
+
for (let row = 0; row < batchLength; row += 1) {
|
|
324
|
+
vectors.push(flat.slice(row * dimensions, (row + 1) * dimensions));
|
|
325
|
+
}
|
|
326
|
+
return vectors;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export type EmbedExtractor = (
|
|
330
|
+
texts: string | string[],
|
|
331
|
+
options: { pooling: "mean"; normalize: boolean }
|
|
332
|
+
) => Promise<unknown>;
|
|
333
|
+
|
|
334
|
+
export type ScheduleResult = {
|
|
335
|
+
vectors: Map<number, number[]>;
|
|
336
|
+
failures: Array<{ index: number; message: string }>;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
export type RunOptions = {
|
|
340
|
+
/**
|
|
341
|
+
* Maximum padded tokens in flight across all lanes. Long-sequence
|
|
342
|
+
* activations are large (attention memory grows quadratically), and several
|
|
343
|
+
* concurrent 8k-token inferences can OOM the process — measured, not
|
|
344
|
+
* theoretical. The gate serializes giant units while short work keeps every
|
|
345
|
+
* lane busy. Default keeps roughly one 8k giant plus a stream of shorts.
|
|
346
|
+
*/
|
|
347
|
+
maxInFlightTokens?: number;
|
|
348
|
+
onVector?: (index: number, vector: number[]) => void;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
export const DEFAULT_MAX_IN_FLIGHT_TOKENS = 12288;
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Executes work units across a pool of extractors via promise lanes (ORT
|
|
355
|
+
* inference releases the JS thread, so sessions genuinely run concurrently).
|
|
356
|
+
* A failed batch is retried per item; a failed text records a failure for
|
|
357
|
+
* every slot index it owns. Vectors for deduplicated texts are shared
|
|
358
|
+
* (callers must not mutate them in place).
|
|
359
|
+
*/
|
|
360
|
+
export async function runWorkUnits(
|
|
361
|
+
units: WorkUnit[],
|
|
362
|
+
extractors: EmbedExtractor[],
|
|
363
|
+
options: RunOptions = {}
|
|
364
|
+
): Promise<ScheduleResult> {
|
|
365
|
+
if (extractors.length === 0) {
|
|
366
|
+
throw new Error("runWorkUnits requires at least one extractor");
|
|
367
|
+
}
|
|
368
|
+
for (const unit of units) {
|
|
369
|
+
if (!Number.isFinite(unit.maxTokens) || unit.maxTokens < 1) {
|
|
370
|
+
throw new Error(`Work unit has invalid maxTokens: ${unit.maxTokens}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const maxInFlight = options.maxInFlightTokens ?? DEFAULT_MAX_IN_FLIGHT_TOKENS;
|
|
374
|
+
const vectors = new Map<number, number[]>();
|
|
375
|
+
const failures: Array<{ index: number; message: string }> = [];
|
|
376
|
+
|
|
377
|
+
const assign = (members: number[], vector: number[]) => {
|
|
378
|
+
for (const index of members) {
|
|
379
|
+
if (options.onVector) {
|
|
380
|
+
options.onVector(index, vector);
|
|
381
|
+
} else {
|
|
382
|
+
vectors.set(index, vector);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
const recordFailure = (members: number[], error: unknown) => {
|
|
387
|
+
const message = error instanceof Error ? error.message : "embedding generation failed";
|
|
388
|
+
for (const index of members) {
|
|
389
|
+
failures.push({ index, message });
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const embedSingle = async (extractor: EmbedExtractor, text: string, members: number[]) => {
|
|
394
|
+
try {
|
|
395
|
+
const output = await extractor(text, { pooling: "mean", normalize: true });
|
|
396
|
+
const vector = toEmbeddingVector(output);
|
|
397
|
+
if (vector.length === 0) {
|
|
398
|
+
throw new Error("Empty embedding vector");
|
|
399
|
+
}
|
|
400
|
+
assign(members, vector);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
recordFailure(members, error);
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const runUnit = async (extractor: EmbedExtractor, unit: WorkUnit) => {
|
|
407
|
+
if (unit.texts.length === 1) {
|
|
408
|
+
await embedSingle(extractor, unit.texts[0], unit.members[0]);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
const output = await extractor(unit.texts, { pooling: "mean", normalize: true });
|
|
413
|
+
const rows = sliceBatchVectors(output, unit.texts.length);
|
|
414
|
+
unit.texts.forEach((_, row) => {
|
|
415
|
+
const vector = rows[row];
|
|
416
|
+
if (!vector || vector.length === 0) {
|
|
417
|
+
recordFailure(unit.members[row], new Error("Empty embedding vector"));
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
assign(unit.members[row], vector);
|
|
421
|
+
});
|
|
422
|
+
} catch {
|
|
423
|
+
// Batch-level failure: isolate the poison input by retrying singly.
|
|
424
|
+
for (let row = 0; row < unit.texts.length; row += 1) {
|
|
425
|
+
await embedSingle(extractor, unit.texts[row], unit.members[row]);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// Work distribution with a memory gate: a lane takes the next unit only
|
|
431
|
+
// when its padded token mass fits the in-flight budget; otherwise it scans
|
|
432
|
+
// for the first smaller unit that does (longest-first order means smaller
|
|
433
|
+
// work is always further down the list). Single-threaded JS makes the
|
|
434
|
+
// take-then-increment bookkeeping race-free.
|
|
435
|
+
const taken = new Array<boolean>(units.length).fill(false);
|
|
436
|
+
let untakenCount = units.length;
|
|
437
|
+
let scanHead = 0; // first possibly-untaken index; avoids O(U^2) rescans
|
|
438
|
+
let inFlight = 0;
|
|
439
|
+
let waiters: Array<() => void> = [];
|
|
440
|
+
|
|
441
|
+
const unitTokens = (unit: WorkUnit) => unit.texts.length * unit.maxTokens;
|
|
442
|
+
|
|
443
|
+
const takeNext = (): number | null => {
|
|
444
|
+
while (scanHead < units.length && taken[scanHead]) {
|
|
445
|
+
scanHead += 1;
|
|
446
|
+
}
|
|
447
|
+
for (let i = scanHead; i < units.length; i += 1) {
|
|
448
|
+
if (taken[i]) {
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
// Normal admission: the unit fits the remaining budget. Oversized
|
|
452
|
+
// units (larger than the whole budget) run exclusively: they may only
|
|
453
|
+
// start when nothing is in flight, and their token mass then blocks
|
|
454
|
+
// every other unit until they release. This keeps one giant from
|
|
455
|
+
// silently disabling the gate for the rest of the run.
|
|
456
|
+
const tokens = unitTokens(units[i]);
|
|
457
|
+
if (inFlight + tokens <= maxInFlight || inFlight === 0) {
|
|
458
|
+
taken[i] = true;
|
|
459
|
+
untakenCount -= 1;
|
|
460
|
+
inFlight += tokens;
|
|
461
|
+
return i;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return null;
|
|
465
|
+
};
|
|
466
|
+
const remaining = () => untakenCount > 0;
|
|
467
|
+
const release = (unit: WorkUnit) => {
|
|
468
|
+
inFlight -= unitTokens(unit);
|
|
469
|
+
const wake = waiters;
|
|
470
|
+
waiters = [];
|
|
471
|
+
for (const resolve of wake) {
|
|
472
|
+
resolve();
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const lanes = extractors.map(async (extractor) => {
|
|
477
|
+
while (remaining()) {
|
|
478
|
+
const index = takeNext();
|
|
479
|
+
if (index === null) {
|
|
480
|
+
// Budget exhausted by other lanes' in-flight work: wait for a release.
|
|
481
|
+
if (inFlight === 0) {
|
|
482
|
+
return; // nothing in flight and nothing fits: defensive exit
|
|
483
|
+
}
|
|
484
|
+
await new Promise<void>((resolve) => waiters.push(resolve));
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const unit = units[index];
|
|
488
|
+
try {
|
|
489
|
+
await runUnit(extractor, unit);
|
|
490
|
+
} finally {
|
|
491
|
+
release(unit);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
await Promise.all(lanes);
|
|
496
|
+
|
|
497
|
+
// Defensive: a unit that no lane ever took (cannot happen with the
|
|
498
|
+
// exclusive-admission rule, but silent loss would be worse than noise).
|
|
499
|
+
units.forEach((unit, index) => {
|
|
500
|
+
if (!taken[index]) {
|
|
501
|
+
for (const members of unit.members) {
|
|
502
|
+
recordFailure(members, new Error("work unit was never scheduled"));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
failures.sort((a, b) => a.index - b.index);
|
|
508
|
+
return { vectors, failures };
|
|
509
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { env, pipeline } from "@huggingface/transformers";
|
|
3
|
+
import { readJsonlRecords } from "./jsonl.js";
|
|
4
|
+
import { LruCache } from "./lruCache.js";
|
|
3
5
|
import { PATHS } from "./paths.js";
|
|
4
6
|
import type { EmbeddingIndex, JsonObject } from "./types.js";
|
|
5
7
|
|
|
@@ -16,27 +18,7 @@ function asString(value: unknown, fallback = ""): string {
|
|
|
16
18
|
return typeof value === "string" ? value : fallback;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
function
|
|
20
|
-
if (!fs.existsSync(filePath)) {
|
|
21
|
-
return [];
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const raw = fs.readFileSync(filePath, "utf8");
|
|
25
|
-
return raw
|
|
26
|
-
.split(/\r?\n/)
|
|
27
|
-
.map((line) => line.trim())
|
|
28
|
-
.filter(Boolean)
|
|
29
|
-
.map((line) => {
|
|
30
|
-
try {
|
|
31
|
-
return JSON.parse(line) as JsonObject;
|
|
32
|
-
} catch {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
})
|
|
36
|
-
.filter((value): value is JsonObject => value !== null);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function toVector(output: unknown): number[] | null {
|
|
21
|
+
function toVector(output: unknown): Float32Array | null {
|
|
40
22
|
if (!output || typeof output !== "object") {
|
|
41
23
|
return null;
|
|
42
24
|
}
|
|
@@ -46,9 +28,10 @@ function toVector(output: unknown): number[] | null {
|
|
|
46
28
|
return null;
|
|
47
29
|
}
|
|
48
30
|
|
|
49
|
-
|
|
31
|
+
const finite = Array.from(data as ArrayLike<number>)
|
|
50
32
|
.map((value) => Number(value))
|
|
51
33
|
.filter((value) => Number.isFinite(value));
|
|
34
|
+
return Float32Array.from(finite);
|
|
52
35
|
}
|
|
53
36
|
|
|
54
37
|
function readFileVersion(filePath: string): string {
|
|
@@ -63,8 +46,8 @@ function readFileVersion(filePath: string): string {
|
|
|
63
46
|
}
|
|
64
47
|
}
|
|
65
48
|
|
|
66
|
-
function parseEmbeddingIndex(raw: JsonObject
|
|
67
|
-
const vectors = new Map<string,
|
|
49
|
+
function parseEmbeddingIndex(raw: Iterable<JsonObject>): EmbeddingIndex {
|
|
50
|
+
const vectors = new Map<string, Float32Array>();
|
|
68
51
|
let model: string | null = null;
|
|
69
52
|
|
|
70
53
|
for (const item of raw) {
|
|
@@ -74,12 +57,17 @@ function parseEmbeddingIndex(raw: JsonObject[]): EmbeddingIndex {
|
|
|
74
57
|
const vectorRaw = item.vector;
|
|
75
58
|
if (!Array.isArray(vectorRaw)) continue;
|
|
76
59
|
|
|
77
|
-
const vector =
|
|
78
|
-
|
|
79
|
-
|
|
60
|
+
const vector: number[] = [];
|
|
61
|
+
for (const value of vectorRaw) {
|
|
62
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
63
|
+
vector.push(value);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
80
66
|
|
|
81
67
|
if (vector.length === 0) continue;
|
|
82
|
-
|
|
68
|
+
// The boxed number[] is transient — only the Float32Array is retained,
|
|
69
|
+
// so peak memory is one line's vector, not the whole index in float64.
|
|
70
|
+
vectors.set(id, Float32Array.from(vector));
|
|
83
71
|
|
|
84
72
|
const nextModel = asString(item.model);
|
|
85
73
|
if (nextModel && !model) {
|
|
@@ -106,7 +94,7 @@ export function loadEmbeddingIndex(): EmbeddingIndex {
|
|
|
106
94
|
return embeddingsCache;
|
|
107
95
|
}
|
|
108
96
|
|
|
109
|
-
const parsed = parseEmbeddingIndex(
|
|
97
|
+
const parsed = parseEmbeddingIndex(readJsonlRecords(PATHS.embeddingsEntities));
|
|
110
98
|
embeddingsCacheKey = key;
|
|
111
99
|
embeddingsCache =
|
|
112
100
|
parsed.vectors.size === 0
|
|
@@ -162,7 +150,19 @@ async function getEmbeddingExtractor(modelId: string): Promise<unknown | null> {
|
|
|
162
150
|
return embeddingExtractorPromise;
|
|
163
151
|
}
|
|
164
152
|
|
|
165
|
-
|
|
153
|
+
// Agents repeat queries verbatim within a session; the model pipeline is
|
|
154
|
+
// cached but each call still pays full inference. A small LRU keyed on
|
|
155
|
+
// (model, query) makes repeats free. Cached vectors are treated as
|
|
156
|
+
// immutable by all callers.
|
|
157
|
+
const queryEmbeddingCache = new LruCache<string, Float32Array>(256);
|
|
158
|
+
|
|
159
|
+
export async function embedQuery(query: string, modelId: string): Promise<Float32Array | null> {
|
|
160
|
+
const cacheKey = `${modelId}\u0000${query}`;
|
|
161
|
+
const cached = queryEmbeddingCache.get(cacheKey);
|
|
162
|
+
if (cached) {
|
|
163
|
+
return cached;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
166
|
const extractor = await getEmbeddingExtractor(modelId);
|
|
167
167
|
if (!extractor) {
|
|
168
168
|
return null;
|
|
@@ -180,6 +180,7 @@ export async function embedQuery(query: string, modelId: string): Promise<number
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
embeddingRuntimeWarning = null;
|
|
183
|
+
queryEmbeddingCache.set(cacheKey, vector);
|
|
183
184
|
return vector;
|
|
184
185
|
} catch (error) {
|
|
185
186
|
embeddingRuntimeWarning = error instanceof Error ? error.message : "Failed to embed query text";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
// ryugraph's CSV reader treats an empty field as NULL by default
|
|
4
|
+
// (DEFAULT_CSV_NULL_STRINGS = {""}), so a quoted empty string round-trips to
|
|
5
|
+
// null rather than "". Every column the graph loader emits is a real
|
|
6
|
+
// string/number/bool produced by asString/asNumber/asBoolean — never null —
|
|
7
|
+
// so we override NULL_STRINGS with a sentinel that cannot occur in any cell.
|
|
8
|
+
// A NUL byte qualifies: ingest's binary-file filter rejects any source file
|
|
9
|
+
// containing one, and filesystem paths and computed ids cannot contain NUL
|
|
10
|
+
// either. This keeps empty strings as "" and matches the prepared-statement
|
|
11
|
+
// loader byte-for-byte.
|
|
12
|
+
export const CSV_NULL_SENTINEL = String.fromCharCode(0);
|
|
13
|
+
|
|
14
|
+
// PARALLEL=false is required: ryugraph's parallel CSV reader rejects quoted
|
|
15
|
+
// newlines, and chunk/rule/ADR bodies routinely contain them. ESCAPE='"'
|
|
16
|
+
// plus QUOTE='"' is RFC 4180 style (a literal quote is written as "").
|
|
17
|
+
export const CSV_COPY_OPTIONS = `(HEADER=true, DELIM=",", QUOTE='"', ESCAPE='"', PARALLEL=false, NULL_STRINGS=['${CSV_NULL_SENTINEL}'])`;
|
|
18
|
+
|
|
19
|
+
export type CsvValue = string | number | boolean | null | undefined;
|
|
20
|
+
|
|
21
|
+
// Every cell is quoted unconditionally and internal quotes are doubled. The
|
|
22
|
+
// reader coerces quoted "true"/"123" back to BOOL/INT64 columns, so a single
|
|
23
|
+
// uniform escaping path covers strings, numbers, and booleans.
|
|
24
|
+
export function toCsvCell(value: CsvValue): string {
|
|
25
|
+
const text =
|
|
26
|
+
value === null || value === undefined
|
|
27
|
+
? ""
|
|
28
|
+
: typeof value === "boolean"
|
|
29
|
+
? value
|
|
30
|
+
? "true"
|
|
31
|
+
: "false"
|
|
32
|
+
: String(value);
|
|
33
|
+
return `"${text.replace(/"/g, '""')}"`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function toCsvRow(values: CsvValue[]): string {
|
|
37
|
+
return values.map(toCsvCell).join(",");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function writeCsv(filePath: string, header: string[], rows: Iterable<CsvValue[]>): number {
|
|
41
|
+
const fd = fs.openSync(filePath, "w");
|
|
42
|
+
let rowCount = 0;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
fs.writeSync(fd, `${toCsvRow(header)}\n`, undefined, "utf8");
|
|
46
|
+
for (const row of rows) {
|
|
47
|
+
fs.writeSync(fd, `${toCsvRow(row)}\n`, undefined, "utf8");
|
|
48
|
+
rowCount += 1;
|
|
49
|
+
}
|
|
50
|
+
} finally {
|
|
51
|
+
fs.closeSync(fd);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return rowCount;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Escape a filesystem path for use inside a double-quoted ryugraph COPY path
|
|
58
|
+
// literal. Separators are normalized to "/" (accepted on every platform,
|
|
59
|
+
// Windows included) and any embedded double quote is backslash-escaped, so a
|
|
60
|
+
// repo or cache path containing a quote does not break the COPY statement and
|
|
61
|
+
// silently lose the bulk-load path. Order matters: normalize separators first,
|
|
62
|
+
// then escape quotes, so the escaping backslash is not itself rewritten.
|
|
63
|
+
export function toCopyPathLiteral(filePath: string): string {
|
|
64
|
+
return filePath.replace(/\\/g, "/").replace(/"/g, '\\"');
|
|
65
|
+
}
|