@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.
- package/README.md +19 -0
- package/bin/cortex.mjs +2 -0
- package/package.json +3 -2
- package/scaffold/mcp/build.mjs +92 -0
- package/scaffold/mcp/package-lock.json +3 -3
- package/scaffold/mcp/package.json +5 -5
- package/scaffold/mcp/src/embed.ts +182 -66
- 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/vector-index.test.mjs +109 -0
- package/scaffold/mcp/tsconfig.json +3 -1
- package/scaffold/scripts/bootstrap.sh +34 -2
- package/scaffold/scripts/dashboard.mjs +1 -1
- 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/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/README.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
[](https://www.npmjs.com/package/@danielblomma/cortex-mcp)
|
|
10
10
|
[](https://www.npmjs.com/package/@danielblomma/cortex-mcp)
|
|
11
11
|
[](./LICENSE)
|
|
12
|
+
[](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
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.
|
|
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.
|
|
1764
|
-
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.
|
|
1765
|
-
"integrity": "sha512-
|
|
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": "
|
|
9
|
-
"graph:load": "
|
|
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": "
|
|
13
|
-
"test:ci": "
|
|
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.
|
|
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
|
|
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 =
|
|
364
|
-
|
|
365
|
-
|
|
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(
|
|
442
|
+
const existing = parseExistingEmbeddings(readJsonlRecords(EMBEDDINGS_PATH), modelId);
|
|
433
443
|
|
|
434
444
|
env.cacheDir = MODEL_CACHE_DIR;
|
|
435
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
476
|
+
};
|
|
477
|
+
return;
|
|
464
478
|
}
|
|
479
|
+
pending.push({ index, text: normalizeText(entity.text) });
|
|
480
|
+
});
|
|
465
481
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
);
|