@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.
Files changed (150) hide show
  1. package/README.md +72 -24
  2. package/bin/cortex.mjs +50 -21
  3. package/mcp-registry-submission.json +63 -0
  4. package/package.json +19 -5
  5. package/scaffold/AGENTS.md +13 -9
  6. package/scaffold/CLAUDE.md +13 -9
  7. package/scaffold/docs/architecture.md +1 -1
  8. package/scaffold/mcp/build.mjs +92 -0
  9. package/scaffold/mcp/package.json +4 -4
  10. package/scaffold/mcp/src/cli/query.ts +455 -0
  11. package/scaffold/mcp/src/embed.ts +181 -77
  12. package/scaffold/mcp/src/embedScheduler.ts +509 -0
  13. package/scaffold/mcp/src/embeddings.ts +31 -30
  14. package/scaffold/mcp/src/graphCsv.ts +65 -0
  15. package/scaffold/mcp/src/jsonl.ts +69 -14
  16. package/scaffold/mcp/src/loadGraph.ts +276 -40
  17. package/scaffold/mcp/src/lruCache.ts +41 -0
  18. package/scaffold/mcp/src/searchCore.ts +1 -1
  19. package/scaffold/mcp/src/searchResults.ts +3 -3
  20. package/scaffold/mcp/src/types.ts +6 -1
  21. package/scaffold/mcp/tests/embed-scheduler.test.mjs +474 -0
  22. package/scaffold/mcp/tests/embedding-jsonl.test.mjs +39 -0
  23. package/scaffold/mcp/tests/graph-bulk-load.test.mjs +258 -0
  24. package/scaffold/mcp/tests/graph-csv.test.mjs +118 -0
  25. package/scaffold/mcp/tests/lru-cache.test.mjs +37 -0
  26. package/scaffold/mcp/tests/query-cli.test.mjs +115 -0
  27. package/scaffold/mcp/tests/vector-index.test.mjs +109 -0
  28. package/scaffold/mcp/tsconfig.json +3 -1
  29. package/scaffold/scripts/bootstrap.sh +37 -4
  30. package/scaffold/scripts/dashboard.mjs +1 -1
  31. package/scaffold/scripts/doctor.sh +9 -8
  32. package/scaffold/scripts/embed.sh +3 -2
  33. package/scaffold/scripts/ingest-parsers.mjs +387 -0
  34. package/scaffold/scripts/ingest-worker.mjs +52 -0
  35. package/scaffold/scripts/ingest.mjs +550 -378
  36. package/scaffold/scripts/load-ryu.sh +3 -2
  37. package/scaffold/scripts/memory-compile.mjs +5 -2
  38. package/scaffold/scripts/memory-lint.mjs +5 -2
  39. package/scaffold/scripts/parsers/csharp.mjs +2 -1
  40. package/scaffold/scripts/parsers/javascript/ast.mjs +160 -8
  41. package/scaffold/scripts/parsers/javascript/chunks.mjs +129 -22
  42. package/scaffold/scripts/parsers/javascript/imports.mjs +38 -4
  43. package/scaffold/scripts/parsers/vbnet.mjs +2 -1
  44. package/scaffold/scripts/status.sh +50 -14
  45. package/scaffold/scripts/parsers/node_modules/.package-lock.json +0 -56
  46. package/scaffold/scripts/parsers/node_modules/acorn/CHANGELOG.md +0 -972
  47. package/scaffold/scripts/parsers/node_modules/acorn/LICENSE +0 -21
  48. package/scaffold/scripts/parsers/node_modules/acorn/README.md +0 -301
  49. package/scaffold/scripts/parsers/node_modules/acorn/bin/acorn +0 -4
  50. package/scaffold/scripts/parsers/node_modules/acorn/dist/acorn.d.mts +0 -883
  51. package/scaffold/scripts/parsers/node_modules/acorn/dist/acorn.d.ts +0 -883
  52. package/scaffold/scripts/parsers/node_modules/acorn/dist/acorn.js +0 -6295
  53. package/scaffold/scripts/parsers/node_modules/acorn/dist/acorn.mjs +0 -6266
  54. package/scaffold/scripts/parsers/node_modules/acorn/dist/bin.js +0 -90
  55. package/scaffold/scripts/parsers/node_modules/acorn/package.json +0 -50
  56. package/scaffold/scripts/parsers/node_modules/acorn-typescript/CHANGELOG.md +0 -421
  57. package/scaffold/scripts/parsers/node_modules/acorn-typescript/LICENSE +0 -21
  58. package/scaffold/scripts/parsers/node_modules/acorn-typescript/README.md +0 -81
  59. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/error.d.ts +0 -103
  60. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/error.js +0 -78
  61. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/error.js.map +0 -1
  62. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/decorators.d.ts +0 -167
  63. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/decorators.js +0 -75
  64. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/decorators.js.map +0 -1
  65. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/import-assertions.d.ts +0 -177
  66. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/import-assertions.js +0 -56
  67. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/import-assertions.js.map +0 -1
  68. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/index.d.ts +0 -198
  69. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/index.js +0 -327
  70. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/index.js.map +0 -1
  71. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/xhtml.d.ts +0 -256
  72. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/xhtml.js +0 -256
  73. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/xhtml.js.map +0 -1
  74. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/index.d.ts +0 -472
  75. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/index.js +0 -1
  76. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/index.js.map +0 -1
  77. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/index.mjs +0 -1
  78. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/middleware.d.ts +0 -159
  79. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/middleware.js +0 -2
  80. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/middleware.js.map +0 -1
  81. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/parseutil.d.ts +0 -10
  82. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/parseutil.js +0 -38
  83. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/parseutil.js.map +0 -1
  84. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/scopeflags.d.ts +0 -12
  85. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/scopeflags.js +0 -29
  86. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/scopeflags.js.map +0 -1
  87. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/tokenType.d.ts +0 -2
  88. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/tokenType.js +0 -118
  89. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/tokenType.js.map +0 -1
  90. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/types.d.ts +0 -60
  91. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/types.js +0 -2
  92. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/types.js.map +0 -1
  93. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/whitespace.d.ts +0 -2
  94. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/whitespace.js +0 -19
  95. package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/whitespace.js.map +0 -1
  96. package/scaffold/scripts/parsers/node_modules/acorn-typescript/package.json +0 -53
  97. package/scaffold/scripts/parsers/node_modules/acorn-typescript/tsconfig.json +0 -19
  98. package/scaffold/scripts/parsers/node_modules/acorn-walk/CHANGELOG.md +0 -209
  99. package/scaffold/scripts/parsers/node_modules/acorn-walk/LICENSE +0 -21
  100. package/scaffold/scripts/parsers/node_modules/acorn-walk/README.md +0 -124
  101. package/scaffold/scripts/parsers/node_modules/acorn-walk/dist/walk.d.mts +0 -152
  102. package/scaffold/scripts/parsers/node_modules/acorn-walk/dist/walk.d.ts +0 -152
  103. package/scaffold/scripts/parsers/node_modules/acorn-walk/dist/walk.js +0 -485
  104. package/scaffold/scripts/parsers/node_modules/acorn-walk/dist/walk.mjs +0 -467
  105. package/scaffold/scripts/parsers/node_modules/acorn-walk/package.json +0 -50
  106. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/LICENSE +0 -24
  107. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/README.md +0 -23
  108. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-bash.wasm +0 -0
  109. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-c.wasm +0 -0
  110. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-c_sharp.wasm +0 -0
  111. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-cpp.wasm +0 -0
  112. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-css.wasm +0 -0
  113. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-dart.wasm +0 -0
  114. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-elisp.wasm +0 -0
  115. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-elixir.wasm +0 -0
  116. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-elm.wasm +0 -0
  117. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-embedded_template.wasm +0 -0
  118. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-go.wasm +0 -0
  119. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-html.wasm +0 -0
  120. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-java.wasm +0 -0
  121. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-javascript.wasm +0 -0
  122. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-json.wasm +0 -0
  123. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-kotlin.wasm +0 -0
  124. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-lua.wasm +0 -0
  125. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-objc.wasm +0 -0
  126. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-ocaml.wasm +0 -0
  127. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-php.wasm +0 -0
  128. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-python.wasm +0 -0
  129. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-ql.wasm +0 -0
  130. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-rescript.wasm +0 -0
  131. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-ruby.wasm +0 -0
  132. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-rust.wasm +0 -0
  133. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-scala.wasm +0 -0
  134. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-solidity.wasm +0 -0
  135. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-swift.wasm +0 -0
  136. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-systemrdl.wasm +0 -0
  137. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-tlaplus.wasm +0 -0
  138. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-toml.wasm +0 -0
  139. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-tsx.wasm +0 -0
  140. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-typescript.wasm +0 -0
  141. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-vue.wasm +0 -0
  142. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-yaml.wasm +0 -0
  143. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-zig.wasm +0 -0
  144. package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/package.json +0 -64
  145. package/scaffold/scripts/parsers/node_modules/web-tree-sitter/LICENSE +0 -21
  146. package/scaffold/scripts/parsers/node_modules/web-tree-sitter/README.md +0 -198
  147. package/scaffold/scripts/parsers/node_modules/web-tree-sitter/package.json +0 -37
  148. package/scaffold/scripts/parsers/node_modules/web-tree-sitter/tree-sitter-web.d.ts +0 -242
  149. package/scaffold/scripts/parsers/node_modules/web-tree-sitter/tree-sitter.js +0 -1
  150. 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 readJsonl(filePath: string): JsonObject[] {
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
- return Array.from(data as ArrayLike<number>)
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[]): EmbeddingIndex {
67
- const vectors = new Map<string, number[]>();
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 = vectorRaw
78
- .map((value) => (typeof value === "number" && Number.isFinite(value) ? value : null))
79
- .filter((value): value is number => value !== null);
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
- vectors.set(id, vector);
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(readJsonl(PATHS.embeddingsEntities));
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
- export async function embedQuery(query: string, modelId: string): Promise<number[] | null> {
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
+ }