@danielblomma/cortex-mcp 2.1.1 → 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.json +4 -4
- package/scaffold/mcp/src/embed.ts +181 -77
- package/scaffold/mcp/src/embedScheduler.ts +509 -0
- package/scaffold/mcp/src/embeddings.ts +31 -30
- package/scaffold/mcp/src/graphCsv.ts +65 -0
- package/scaffold/mcp/src/jsonl.ts +69 -14
- package/scaffold/mcp/src/loadGraph.ts +276 -40
- package/scaffold/mcp/src/lruCache.ts +41 -0
- package/scaffold/mcp/src/searchCore.ts +1 -1
- package/scaffold/mcp/src/searchResults.ts +3 -3
- package/scaffold/mcp/src/types.ts +6 -1
- package/scaffold/mcp/tests/embed-scheduler.test.mjs +474 -0
- package/scaffold/mcp/tests/embedding-jsonl.test.mjs +39 -0
- package/scaffold/mcp/tests/graph-bulk-load.test.mjs +258 -0
- package/scaffold/mcp/tests/graph-csv.test.mjs +118 -0
- package/scaffold/mcp/tests/lru-cache.test.mjs +37 -0
- package/scaffold/mcp/tests/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
|
+
}
|
|
@@ -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",
|
|
@@ -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,36 +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
|
-
//
|
|
436
|
-
// jobs
|
|
437
|
-
//
|
|
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.
|
|
438
448
|
const threadsRaw = Number(process.env.CORTEX_EMBED_THREADS);
|
|
439
|
-
const
|
|
440
|
-
Number.isFinite(threadsRaw) && threadsRaw >= 1
|
|
441
|
-
? { session_options: { intraOpNumThreads: Math.floor(threadsRaw), interOpNumThreads: 1 } }
|
|
442
|
-
: undefined;
|
|
443
|
-
const extractor = await pipeline(
|
|
444
|
-
"feature-extraction",
|
|
445
|
-
modelId,
|
|
446
|
-
sessionOptions as Parameters<typeof pipeline>[2]
|
|
447
|
-
);
|
|
449
|
+
const threadBudget =
|
|
450
|
+
Number.isFinite(threadsRaw) && threadsRaw >= 1 ? Math.floor(threadsRaw) : os.cpus().length;
|
|
448
451
|
|
|
449
452
|
let reused = 0;
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
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[] = [];
|
|
454
456
|
let dimensions = 0;
|
|
455
457
|
|
|
456
|
-
|
|
458
|
+
entities.forEach((entity, index) => {
|
|
457
459
|
const previous = existing.get(entity.id);
|
|
458
460
|
if (previous && previous.signature === entity.signature && previous.vector.length > 0) {
|
|
459
461
|
reused += 1;
|
|
460
462
|
dimensions = dimensions || previous.vector.length;
|
|
461
|
-
|
|
463
|
+
slots[index] = {
|
|
462
464
|
...previous,
|
|
463
465
|
entity_type: entity.type,
|
|
464
466
|
kind: entity.kind,
|
|
@@ -471,46 +473,146 @@ async function main(): Promise<void> {
|
|
|
471
473
|
signature: entity.signature,
|
|
472
474
|
model: modelId,
|
|
473
475
|
dimensions: previous.vector.length
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
+
};
|
|
477
|
+
return;
|
|
476
478
|
}
|
|
479
|
+
pending.push({ index, text: normalizeText(entity.text) });
|
|
480
|
+
});
|
|
477
481
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
+
});
|
|
487
503
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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}`
|
|
509
567
|
);
|
|
510
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
|
+
});
|
|
511
608
|
}
|
|
512
609
|
|
|
513
|
-
|
|
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));
|
|
514
616
|
|
|
515
617
|
const manifest = {
|
|
516
618
|
generated_at: new Date().toISOString(),
|
|
@@ -519,7 +621,7 @@ async function main(): Promise<void> {
|
|
|
519
621
|
dimensions,
|
|
520
622
|
counts: {
|
|
521
623
|
entities: entities.length,
|
|
522
|
-
output:
|
|
624
|
+
output: outputCount,
|
|
523
625
|
embedded,
|
|
524
626
|
reused,
|
|
525
627
|
failed
|
|
@@ -529,7 +631,9 @@ async function main(): Promise<void> {
|
|
|
529
631
|
|
|
530
632
|
fs.writeFileSync(EMBEDDINGS_MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
531
633
|
|
|
532
|
-
console.log(
|
|
634
|
+
console.log(
|
|
635
|
+
`[embed] mode=${mode} model=${modelId} dim=${dimensions} pool=${poolConfig.sessions}x${poolConfig.threadsPerSession} batch<=${schedulerOptions.batchMaxItems}`
|
|
636
|
+
);
|
|
533
637
|
console.log(
|
|
534
638
|
`[embed] entities=${entities.length} embedded=${embedded} reused=${reused} failed=${failed}`
|
|
535
639
|
);
|