@danielblomma/cortex-mcp 2.1.0 → 2.1.4

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 (34) hide show
  1. package/README.md +19 -0
  2. package/bin/cortex.mjs +2 -0
  3. package/package.json +3 -2
  4. package/scaffold/mcp/build.mjs +92 -0
  5. package/scaffold/mcp/package-lock.json +3 -3
  6. package/scaffold/mcp/package.json +5 -5
  7. package/scaffold/mcp/src/embed.ts +182 -66
  8. package/scaffold/mcp/src/embedScheduler.ts +509 -0
  9. package/scaffold/mcp/src/embeddings.ts +31 -30
  10. package/scaffold/mcp/src/graphCsv.ts +65 -0
  11. package/scaffold/mcp/src/jsonl.ts +69 -14
  12. package/scaffold/mcp/src/loadGraph.ts +276 -40
  13. package/scaffold/mcp/src/lruCache.ts +41 -0
  14. package/scaffold/mcp/src/searchCore.ts +1 -1
  15. package/scaffold/mcp/src/searchResults.ts +3 -3
  16. package/scaffold/mcp/src/types.ts +6 -1
  17. package/scaffold/mcp/tests/embed-scheduler.test.mjs +474 -0
  18. package/scaffold/mcp/tests/embedding-jsonl.test.mjs +39 -0
  19. package/scaffold/mcp/tests/graph-bulk-load.test.mjs +258 -0
  20. package/scaffold/mcp/tests/graph-csv.test.mjs +118 -0
  21. package/scaffold/mcp/tests/lru-cache.test.mjs +37 -0
  22. package/scaffold/mcp/tests/vector-index.test.mjs +109 -0
  23. package/scaffold/mcp/tsconfig.json +3 -1
  24. package/scaffold/scripts/bootstrap.sh +34 -2
  25. package/scaffold/scripts/dashboard.mjs +1 -1
  26. package/scaffold/scripts/ingest-parsers.mjs +387 -0
  27. package/scaffold/scripts/ingest-worker.mjs +52 -0
  28. package/scaffold/scripts/ingest.mjs +550 -378
  29. package/scaffold/scripts/parsers/csharp.mjs +2 -1
  30. package/scaffold/scripts/parsers/javascript/ast.mjs +160 -8
  31. package/scaffold/scripts/parsers/javascript/chunks.mjs +129 -22
  32. package/scaffold/scripts/parsers/javascript/imports.mjs +38 -4
  33. package/scaffold/scripts/parsers/vbnet.mjs +2 -1
  34. package/scaffold/scripts/status.sh +50 -14
package/README.md CHANGED
@@ -9,6 +9,7 @@
9
9
  [![npm version](https://img.shields.io/npm/v/%40danielblomma%2Fcortex-mcp)](https://www.npmjs.com/package/@danielblomma/cortex-mcp)
10
10
  [![npm downloads](https://img.shields.io/npm/dw/%40danielblomma%2Fcortex-mcp)](https://www.npmjs.com/package/@danielblomma/cortex-mcp)
11
11
  [![license](https://img.shields.io/npm/l/%40danielblomma%2Fcortex-mcp)](./LICENSE)
12
+ [![website](https://img.shields.io/badge/website-cortex-2563eb)](https://danielblomma.github.io/cortex/)
12
13
 
13
14
  ---
14
15
 
@@ -380,6 +381,16 @@ Required npm configuration:
380
381
  - Use GitHub Actions publisher `DanielBlomma/cortex`
381
382
  - Workflow filename must match `release-publish.yml`
382
383
 
384
+ ## Embedding performance
385
+
386
+ Embedding generation tunes itself to the machine: the number of parallel
387
+ workers, memory limits for long files, and skip-work caching are all derived
388
+ from the available CPU cores and RAM at run time (container memory limits
389
+ included). No configuration is needed — on a laptop or a CI runner, cortex
390
+ picks safe, fast settings by itself. The one exception worth knowing:
391
+ when several cortex instances share one machine, set `CORTEX_EMBED_THREADS`
392
+ to give each its fair share of cores.
393
+
383
394
  ## Limitations
384
395
 
385
396
  - Requires repo initialization (`cortex init --bootstrap`).
@@ -400,6 +411,14 @@ Required npm configuration:
400
411
  - MCP tools return stale context:
401
412
  Run `cortex update`, then reconnect MCP or call `context.reload` from your MCP client.
402
413
 
414
+ ## Website and Benchmarks
415
+
416
+ - `frontend/` hosts the cortex website (GitHub Pages, deployed on push to `main`):
417
+ product overview plus bootstrap evaluation metrics.
418
+ - `benchmark/bootstrapbench/` runs `cortex bootstrap` against 69 pinned
419
+ real-world repositories in isolated containers and extracts chunk, embedding
420
+ and graph statistics. See [benchmark/bootstrapbench/README.md](benchmark/bootstrapbench/README.md).
421
+
403
422
  ## Support
404
423
 
405
424
  - Issues: https://github.com/DanielBlomma/cortex/issues
package/bin/cortex.mjs CHANGED
@@ -229,6 +229,8 @@ const INIT_SOURCE_EXTENSIONS = new Set([
229
229
  ".csv",
230
230
  ".ts",
231
231
  ".tsx",
232
+ ".mts",
233
+ ".cts",
232
234
  ".js",
233
235
  ".jsx",
234
236
  ".mjs",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@danielblomma/cortex-mcp",
3
3
  "mcpName": "io.github.DanielBlomma/cortex",
4
- "version": "2.1.0",
4
+ "version": "2.1.4",
5
5
  "description": "Local, repo-scoped context platform for coding assistants. Semantic search, graph relationships, and architectural rule context.",
6
6
  "type": "module",
7
7
  "author": "Daniel Blomma",
@@ -42,6 +42,7 @@
42
42
  "scaffold/scripts",
43
43
  "scaffold/mcp/src",
44
44
  "scaffold/mcp/tests",
45
+ "scaffold/mcp/build.mjs",
45
46
  "scaffold/mcp/package.json",
46
47
  "scaffold/mcp/package-lock.json",
47
48
  "scaffold/mcp/tsconfig.json",
@@ -50,7 +51,7 @@
50
51
  ],
51
52
  "scripts": {
52
53
  "pretest": "test -d scaffold/scripts/parsers/node_modules || npm --prefix scaffold/scripts/parsers install --no-fund --no-update-notifier --silent",
53
- "test": "node tests/context-regressions.test.mjs && node --test tests/ingest-units.test.mjs tests/javascript-parser.test.mjs tests/markdown-parser.test.mjs tests/sql-parser.test.mjs tests/config-parser.test.mjs tests/resources-parser.test.mjs tests/vbnet-parser.test.mjs tests/cpp-parser.test.mjs tests/dashboard.test.mjs tests/init-config.test.mjs tests/init-agents.test.mjs tests/multi-level.test.mjs tests/no-legacy-paths.test.mjs tests/tree-sitter-error-reporting.test.mjs tests/tree-sitter-body-cap.test.mjs tests/tree-sitter-exported.test.mjs tests/tree-sitter-robustness.test.mjs",
54
+ "test": "node tests/context-regressions.test.mjs && node --test tests/ingest-units.test.mjs tests/ingest-parallel.test.mjs tests/ingest-worker-crash.test.mjs tests/javascript-parser.test.mjs tests/markdown-parser.test.mjs tests/sql-parser.test.mjs tests/config-parser.test.mjs tests/resources-parser.test.mjs tests/vbnet-parser.test.mjs tests/cpp-parser.test.mjs tests/dashboard.test.mjs tests/init-config.test.mjs tests/init-agents.test.mjs tests/multi-level.test.mjs tests/no-legacy-paths.test.mjs tests/tree-sitter-error-reporting.test.mjs tests/tree-sitter-body-cap.test.mjs tests/tree-sitter-exported.test.mjs tests/tree-sitter-robustness.test.mjs tests/bootstrapbench-stats.test.mjs tests/bootstrapbench-aggregate.test.mjs",
54
55
  "release:sync-version": "node scripts/sync-release-version.mjs",
55
56
  "release:check-version-sync": "node scripts/sync-release-version.mjs --check",
56
57
  "prepublishOnly": "echo 'Ready to publish to npm'"
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Builds the MCP server only when its TypeScript inputs changed.
4
+ *
5
+ * Every embed/graph-load/test invocation used to run a full `tsc` compile
6
+ * even with untouched sources — twice per bootstrap, on every `cortex
7
+ * update`. This guard hashes the inputs (src/**, tsconfig.json,
8
+ * package-lock.json) and skips the compiler when the previous build is
9
+ * current. `--force` rebuilds unconditionally.
10
+ */
11
+ import crypto from "node:crypto";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { spawnSync } from "node:child_process";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const ROOT = path.dirname(fileURLToPath(import.meta.url));
18
+ const DIST = path.join(ROOT, "dist");
19
+ const MARKER = path.join(DIST, ".cortex-build-hash");
20
+ // Every runtime entrypoint must exist for a build to count as current;
21
+ // checking only one would let a partially deleted dist pass as fresh.
22
+ const ENTRIES = ["server.js", "embed.js", "loadGraph.js"].map((name) => path.join(DIST, name));
23
+
24
+ function collectSources(dir, files = []) {
25
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
26
+ const absolute = path.join(dir, entry.name);
27
+ if (entry.isDirectory()) {
28
+ collectSources(absolute, files);
29
+ } else if (entry.isFile() && entry.name.endsWith(".ts")) {
30
+ files.push(absolute);
31
+ }
32
+ }
33
+ return files;
34
+ }
35
+
36
+ function inputHash() {
37
+ const hash = crypto.createHash("sha256");
38
+ const inputs = [
39
+ path.join(ROOT, "tsconfig.json"),
40
+ path.join(ROOT, "package.json"),
41
+ path.join(ROOT, "package-lock.json")
42
+ ];
43
+ const srcDir = path.join(ROOT, "src");
44
+ if (fs.existsSync(srcDir)) {
45
+ inputs.push(...collectSources(srcDir));
46
+ }
47
+ for (const file of inputs) {
48
+ if (!fs.existsSync(file)) {
49
+ continue;
50
+ }
51
+ hash.update(path.relative(ROOT, file));
52
+ hash.update("\0");
53
+ hash.update(fs.readFileSync(file));
54
+ hash.update("\0");
55
+ }
56
+ return hash.digest("hex");
57
+ }
58
+
59
+ const force = process.argv.includes("--force");
60
+ const current = inputHash();
61
+
62
+ if (!force && ENTRIES.every((entry) => fs.existsSync(entry)) && fs.existsSync(MARKER)) {
63
+ try {
64
+ if (fs.readFileSync(MARKER, "utf8").trim() === current) {
65
+ console.log("[build] dist is up to date (sources unchanged)");
66
+ process.exit(0);
67
+ }
68
+ } catch {
69
+ // unreadable marker -> rebuild
70
+ }
71
+ }
72
+
73
+ const tscLocal = path.join(ROOT, "node_modules", ".bin", "tsc");
74
+ if (!fs.existsSync(tscLocal)) {
75
+ console.error("[build] TypeScript not installed; run npm install in this directory first");
76
+ process.exit(1);
77
+ }
78
+ const result = spawnSync(tscLocal, ["-p", "tsconfig.json"], { cwd: ROOT, stdio: "inherit" });
79
+ if (result.error) {
80
+ console.error(`[build] failed to launch tsc: ${result.error.message}`);
81
+ process.exit(1);
82
+ }
83
+ if (result.status !== 0) {
84
+ process.exit(result.status ?? 1);
85
+ }
86
+
87
+ try {
88
+ fs.mkdirSync(DIST, { recursive: true });
89
+ fs.writeFileSync(MARKER, `${current}\n`);
90
+ } catch {
91
+ // A missing marker only means the next run rebuilds; never fail the build.
92
+ }
@@ -1760,9 +1760,9 @@
1760
1760
  }
1761
1761
  },
1762
1762
  "node_modules/hono": {
1763
- "version": "4.12.18",
1764
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
1765
- "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
1763
+ "version": "4.12.25",
1764
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
1765
+ "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
1766
1766
  "license": "MIT",
1767
1767
  "engines": {
1768
1768
  "node": ">=16.9.0"
@@ -5,12 +5,12 @@
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "build": "tsc -p tsconfig.json",
8
- "embed": "npm run build --silent && node dist/embed.js",
9
- "graph:load": "npm run build --silent && node dist/loadGraph.js",
8
+ "embed": "node build.mjs && node dist/embed.js",
9
+ "graph:load": "node build.mjs && node dist/loadGraph.js",
10
10
  "dev": "node --loader ts-node/esm src/server.ts",
11
11
  "start": "node dist/server.js",
12
- "test": "npm run build --silent && node --test tests/*.test.mjs",
13
- "test:ci": "npm run build --silent && node --test --test-reporter=spec tests/*.test.mjs"
12
+ "test": "node build.mjs && node --test tests/*.test.mjs",
13
+ "test:ci": "node build.mjs && node --test --test-reporter=spec tests/*.test.mjs"
14
14
  },
15
15
  "dependencies": {
16
16
  "@huggingface/transformers": "^4.1.0",
@@ -24,7 +24,7 @@
24
24
  "overrides": {
25
25
  "cmake-js": "^8.0.0",
26
26
  "express-rate-limit": "^8.5.1",
27
- "hono": "^4.12.18",
27
+ "hono": "^4.12.23",
28
28
  "@hono/node-server": "^1.19.13",
29
29
  "tar": "^7.5.11"
30
30
  },
@@ -1,10 +1,25 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { pathToFileURL } from "node:url";
5
6
  import { env, pipeline } from "@huggingface/transformers";
6
- import { readJsonl, asString, asNumber, asBoolean } from "./jsonl.js";
7
+ import { readJsonl, readJsonlRecords, writeJsonlRecords, asString, asNumber, asBoolean } from "./jsonl.js";
7
8
  import { CACHE_DIR, PATHS } from "./paths.js";
9
+ import {
10
+ createTokenCounter,
11
+ DEFAULT_SCHEDULER_OPTIONS,
12
+ groupDuplicates,
13
+ packWorkUnits,
14
+ resolveInFlightTokens,
15
+ resolveMemoryHeadroom,
16
+ resolveModelMaxTokens,
17
+ resolvePoolConfig,
18
+ runWorkUnits,
19
+ type EmbedExtractor,
20
+ type MeasuredText,
21
+ type PendingText
22
+ } from "./embedScheduler.js";
8
23
  import type { JsonObject, JsonValue } from "./types.js";
9
24
 
10
25
  const EMBEDDINGS_PATH = PATHS.embeddingsEntities;
@@ -137,11 +152,6 @@ function normalizeText(value: string): string {
137
152
  return value.replace(/\s+/g, " ").trim();
138
153
  }
139
154
 
140
- function writeJsonl(filePath: string, records: EmbeddingRecord[]): void {
141
- const body = records.map((record) => JSON.stringify(record)).join("\n");
142
- fs.writeFileSync(filePath, body ? `${body}\n` : "", "utf8");
143
- }
144
-
145
155
  function ensureRequiredFiles(): void {
146
156
  const required = [
147
157
  path.join(CACHE_DIR, "documents.jsonl"),
@@ -350,7 +360,7 @@ function parseProjectEntities(raw: JsonObject[]): ProjectEntity[] {
350
360
  .filter((value): value is ProjectEntity => value !== null);
351
361
  }
352
362
 
353
- function parseExistingEmbeddings(raw: JsonObject[], modelId: string): Map<string, EmbeddingRecord> {
363
+ function parseExistingEmbeddings(raw: Iterable<JsonObject>, modelId: string): Map<string, EmbeddingRecord> {
354
364
  const index = new Map<string, EmbeddingRecord>();
355
365
 
356
366
  for (const item of raw) {
@@ -360,9 +370,12 @@ function parseExistingEmbeddings(raw: JsonObject[], modelId: string): Map<string
360
370
  const vectorRaw = item.vector;
361
371
  if (!Array.isArray(vectorRaw)) continue;
362
372
 
363
- const vector = vectorRaw
364
- .map((value) => (typeof value === "number" && Number.isFinite(value) ? value : null))
365
- .filter((value): value is number => value !== null);
373
+ const vector: number[] = [];
374
+ for (const value of vectorRaw) {
375
+ if (typeof value === "number" && Number.isFinite(value)) {
376
+ vector.push(value);
377
+ }
378
+ }
366
379
 
367
380
  if (vector.length === 0) continue;
368
381
  const model = asString(item.model);
@@ -388,23 +401,20 @@ function parseExistingEmbeddings(raw: JsonObject[], modelId: string): Map<string
388
401
  return index;
389
402
  }
390
403
 
391
- function toEmbeddingVector(output: unknown): number[] {
392
- if (!output || typeof output !== "object") {
393
- throw new Error("Invalid embedding output type");
394
- }
395
-
396
- const data = (output as { data?: unknown }).data;
397
- if (!data || typeof (data as ArrayLike<number>).length !== "number") {
398
- throw new Error("Missing embedding data");
399
- }
400
-
401
- return Array.from(data as ArrayLike<number>).map((value) => Number(value));
402
- }
403
-
404
404
  function roundVector(values: number[]): number[] {
405
405
  return values.map((value) => Number(value.toFixed(6)));
406
406
  }
407
407
 
408
+ function* presentEmbeddingRecords(
409
+ slots: Array<EmbeddingRecord | null>
410
+ ): Generator<EmbeddingRecord> {
411
+ for (const record of slots) {
412
+ if (record) {
413
+ yield record;
414
+ }
415
+ }
416
+ }
417
+
408
418
  async function main(): Promise<void> {
409
419
  const { mode } = parseArgs(process.argv);
410
420
  ensureRequiredFiles();
@@ -429,24 +439,28 @@ async function main(): Promise<void> {
429
439
 
430
440
  const entities: SearchEntity[] = [...documents, ...rules, ...adrs, ...modules, ...projects, ...chunks].sort((a, b) => a.id.localeCompare(b.id));
431
441
 
432
- const existing = parseExistingEmbeddings(readJsonl(EMBEDDINGS_PATH), modelId);
442
+ const existing = parseExistingEmbeddings(readJsonlRecords(EMBEDDINGS_PATH), modelId);
433
443
 
434
444
  env.cacheDir = MODEL_CACHE_DIR;
435
- const extractor = await pipeline("feature-extraction", modelId);
445
+ // Total thread budget for embedding. CORTEX_EMBED_THREADS caps it so
446
+ // co-located embedders (parallel CI jobs, eval containers) do not
447
+ // oversubscribe shared cores; unset = all cores.
448
+ const threadsRaw = Number(process.env.CORTEX_EMBED_THREADS);
449
+ const threadBudget =
450
+ Number.isFinite(threadsRaw) && threadsRaw >= 1 ? Math.floor(threadsRaw) : os.cpus().length;
436
451
 
437
452
  let reused = 0;
438
- let embedded = 0;
439
- let failed = 0;
440
- const failures: string[] = [];
441
- const output: EmbeddingRecord[] = [];
453
+ // Slot per entity keeps output in entity order; failed slots stay null.
454
+ const slots: Array<EmbeddingRecord | null> = entities.map(() => null);
455
+ const pending: PendingText[] = [];
442
456
  let dimensions = 0;
443
457
 
444
- for (const entity of entities) {
458
+ entities.forEach((entity, index) => {
445
459
  const previous = existing.get(entity.id);
446
460
  if (previous && previous.signature === entity.signature && previous.vector.length > 0) {
447
461
  reused += 1;
448
462
  dimensions = dimensions || previous.vector.length;
449
- output.push({
463
+ slots[index] = {
450
464
  ...previous,
451
465
  entity_type: entity.type,
452
466
  kind: entity.kind,
@@ -459,46 +473,146 @@ async function main(): Promise<void> {
459
473
  signature: entity.signature,
460
474
  model: modelId,
461
475
  dimensions: previous.vector.length
462
- });
463
- continue;
476
+ };
477
+ return;
464
478
  }
479
+ pending.push({ index, text: normalizeText(entity.text) });
480
+ });
465
481
 
466
- try {
467
- const embeddingOutput = await extractor(normalizeText(entity.text), {
468
- pooling: "mean",
469
- normalize: true
470
- });
471
- const vector = roundVector(toEmbeddingVector(embeddingOutput));
472
- if (vector.length === 0) {
473
- throw new Error("Empty embedding vector");
474
- }
482
+ // Deduplicate identical texts (lossless: identical input -> identical
483
+ // vector), then measure token lengths for routing and batch packing.
484
+ const unique = groupDuplicates(pending);
485
+ // Memory headroom drives pool size and the concurrency gate; everything
486
+ // adapts to the machine so no tuning is expected from users. Container
487
+ // limits (cgroups) and platforms that under-report free memory are both
488
+ // handled inside resolveMemoryHeadroom.
489
+ const readHeadroom = () =>
490
+ resolveMemoryHeadroom({
491
+ freeMemory: os.freemem(),
492
+ totalMemory: os.totalmem(),
493
+ constrainedMemory: process.constrainedMemory?.() ?? null,
494
+ availableMemory: process.availableMemory?.() ?? null
495
+ });
496
+ const memoryHeadroom = readHeadroom();
497
+ const poolConfig = resolvePoolConfig({
498
+ threadBudget,
499
+ poolOverride: Number(process.env.CORTEX_EMBED_POOL) || null,
500
+ uniqueCount: unique.length,
501
+ memoryBytes: memoryHeadroom
502
+ });
475
503
 
476
- embedded += 1;
477
- dimensions = dimensions || vector.length;
478
- output.push({
479
- id: entity.id,
480
- entity_type: entity.type,
481
- kind: entity.kind,
482
- label: entity.label,
483
- path: entity.path,
484
- status: entity.status,
485
- source_of_truth: entity.source_of_truth,
486
- trust_level: entity.trust_level,
487
- updated_at: entity.updated_at,
488
- signature: entity.signature,
489
- model: modelId,
490
- dimensions: vector.length,
491
- vector
492
- });
493
- } catch (error) {
494
- failed += 1;
495
- failures.push(
496
- `${entity.id}: ${error instanceof Error ? error.message : "embedding generation failed"}`
504
+ const batchSizeRaw = Number(process.env.CORTEX_EMBED_BATCH_SIZE);
505
+ const batchTokensRaw = Number(process.env.CORTEX_EMBED_BATCH_TOKENS);
506
+ const shortTokensRaw = Number(process.env.CORTEX_EMBED_SHORT_TOKENS);
507
+ const schedulerOptions = {
508
+ ...DEFAULT_SCHEDULER_OPTIONS,
509
+ // 0 (or any value below 1) disables micro-batching rather than silently
510
+ // meaning "use the default".
511
+ ...(Number.isFinite(batchSizeRaw) && batchSizeRaw >= 0
512
+ ? { batchMaxItems: Math.max(1, Math.floor(batchSizeRaw)) }
513
+ : {}),
514
+ ...(Number.isFinite(batchTokensRaw) && batchTokensRaw >= 16
515
+ ? { batchTokenBudget: Math.floor(batchTokensRaw) }
516
+ : {}),
517
+ ...(Number.isFinite(shortTokensRaw) && shortTokensRaw >= 1
518
+ ? { shortMaxTokens: Math.floor(shortTokensRaw) }
519
+ : {})
520
+ };
521
+
522
+ // Fully warm cache: nothing to embed, so skip model loading entirely —
523
+ // this is the common repeat-bootstrap / small-update path.
524
+ let embedded = 0;
525
+ let result: { vectors: Map<number, number[]>; failures: Array<{ index: number; message: string }> } = {
526
+ vectors: new Map(),
527
+ failures: []
528
+ };
529
+
530
+ if (unique.length > 0) {
531
+ const makeExtractor = async (threads: number) =>
532
+ (await pipeline("feature-extraction", modelId, {
533
+ session_options: { intraOpNumThreads: threads, interOpNumThreads: 1 }
534
+ } as Parameters<typeof pipeline>[2])) as unknown as EmbedExtractor & {
535
+ tokenizer?: ((text: string) => { input_ids?: { dims?: number[] } }) & {
536
+ model_max_length?: number;
537
+ };
538
+ };
539
+
540
+ // First session loads (and caches) the model; the rest load in parallel.
541
+ // Extra sessions failing (e.g. memory pressure) degrades the pool instead
542
+ // of aborting the run — one session can always finish the work.
543
+ const first = await makeExtractor(poolConfig.threadsPerSession);
544
+ const extraSessions = await Promise.allSettled(
545
+ Array.from({ length: poolConfig.sessions - 1 }, () =>
546
+ makeExtractor(poolConfig.threadsPerSession)
547
+ )
548
+ );
549
+ const extractors: EmbedExtractor[] = [
550
+ first,
551
+ ...extraSessions
552
+ .filter(
553
+ (settled): settled is PromiseFulfilledResult<Awaited<ReturnType<typeof makeExtractor>>> =>
554
+ settled.status === "fulfilled"
555
+ )
556
+ .map((settled) => settled.value)
557
+ ];
558
+ const failedSessions = extraSessions.length + 1 - extractors.length;
559
+ if (failedSessions > 0) {
560
+ const firstFailure = extraSessions.find(
561
+ (settled): settled is PromiseRejectedResult => settled.status === "rejected"
562
+ );
563
+ const reason =
564
+ firstFailure?.reason instanceof Error ? firstFailure.reason.message : String(firstFailure?.reason ?? "unknown");
565
+ console.warn(
566
+ `[embed] ${failedSessions} pool session(s) failed to load (${reason}); continuing with ${extractors.length}`
497
567
  );
498
568
  }
569
+
570
+ // Inference truncates at the model max; token counts must too, or one
571
+ // giant file inflates scheduling cost and gate mass far beyond reality.
572
+ const modelMaxTokens = resolveModelMaxTokens(first.tokenizer?.model_max_length);
573
+ const countTokens = createTokenCounter(first.tokenizer, modelMaxTokens);
574
+
575
+ const measured: MeasuredText[] = unique.map((item) => ({ ...item, tokens: countTokens(item.text) }));
576
+ const units = packWorkUnits(measured, schedulerOptions);
577
+ // Recompute headroom after the model copies are resident so the gate
578
+ // reflects what is actually left for inference activations.
579
+ const inFlightRaw = Number(process.env.CORTEX_EMBED_INFLIGHT_TOKENS);
580
+ const maxInFlightTokens =
581
+ Number.isFinite(inFlightRaw) && inFlightRaw >= 1024
582
+ ? Math.floor(inFlightRaw)
583
+ : resolveInFlightTokens({ memoryBytes: readHeadroom(), modelMaxTokens });
584
+ result = await runWorkUnits(units, extractors, {
585
+ maxInFlightTokens,
586
+ onVector(index, rawVector) {
587
+ const entity = entities[index];
588
+ const vector = roundVector(rawVector);
589
+ embedded += 1;
590
+ dimensions = dimensions || vector.length;
591
+ slots[index] = {
592
+ id: entity.id,
593
+ entity_type: entity.type,
594
+ kind: entity.kind,
595
+ label: entity.label,
596
+ path: entity.path,
597
+ status: entity.status,
598
+ source_of_truth: entity.source_of_truth,
599
+ trust_level: entity.trust_level,
600
+ updated_at: entity.updated_at,
601
+ signature: entity.signature,
602
+ model: modelId,
603
+ dimensions: vector.length,
604
+ vector
605
+ };
606
+ }
607
+ });
499
608
  }
500
609
 
501
- writeJsonl(EMBEDDINGS_PATH, output);
610
+ const failures = result.failures.map(
611
+ (failure) => `${entities[failure.index].id}: ${failure.message}`
612
+ );
613
+ const failed = result.failures.length;
614
+
615
+ const outputCount = writeJsonlRecords(EMBEDDINGS_PATH, presentEmbeddingRecords(slots));
502
616
 
503
617
  const manifest = {
504
618
  generated_at: new Date().toISOString(),
@@ -507,7 +621,7 @@ async function main(): Promise<void> {
507
621
  dimensions,
508
622
  counts: {
509
623
  entities: entities.length,
510
- output: output.length,
624
+ output: outputCount,
511
625
  embedded,
512
626
  reused,
513
627
  failed
@@ -517,7 +631,9 @@ async function main(): Promise<void> {
517
631
 
518
632
  fs.writeFileSync(EMBEDDINGS_MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
519
633
 
520
- console.log(`[embed] mode=${mode} model=${modelId} dim=${dimensions}`);
634
+ console.log(
635
+ `[embed] mode=${mode} model=${modelId} dim=${dimensions} pool=${poolConfig.sessions}x${poolConfig.threadsPerSession} batch<=${schedulerOptions.batchMaxItems}`
636
+ );
521
637
  console.log(
522
638
  `[embed] entities=${entities.length} embedded=${embedded} reused=${reused} failed=${failed}`
523
639
  );