@danielblomma/cortex-mcp 2.2.3 → 2.2.5

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 CHANGED
@@ -413,12 +413,20 @@ Required npm configuration:
413
413
  ## Embedding performance
414
414
 
415
415
  Embedding generation tunes itself to the machine: the number of parallel
416
- workers, memory limits for long files, and skip-work caching are all derived
417
- from the available CPU cores and RAM at run time (container memory limits
418
- included). No configuration is needed — on a laptop or a CI runner, cortex
419
- picks safe, fast settings by itself. The one exception worth knowing:
420
- when several cortex instances share one machine, set `CORTEX_EMBED_THREADS`
421
- to give each its fair share of cores.
416
+ workers, memory limits for long files, token budget, and skip-work caching are
417
+ all derived from the available CPU cores, RAM, and repository size at run time
418
+ (container memory limits included). No configuration is needed — on a laptop or
419
+ a CI runner, cortex picks safe settings by itself.
420
+
421
+ The embedding token budget defaults to `auto`, which is quality-first but
422
+ memory-aware. Cortex starts from the embedding model's own maximum context and
423
+ only lowers the cap when the local memory headroom is unlikely to fit that
424
+ model/context combination. Degraded auto runs are explicit in logs, for example
425
+ `token_budget=auto_degraded reason=memory_headroom cap=2048`. To force a
426
+ specific capped run, set `CORTEX_EMBED_MAX_TOKENS` to a number such as `2048`;
427
+ set it to `model` or `full` to force the full-model baseline even on tight
428
+ machines. When several cortex instances share one machine, set
429
+ `CORTEX_EMBED_THREADS` to give each its fair share of cores.
422
430
 
423
431
  ## Limitations
424
432
 
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.2.3",
4
+ "version": "2.2.5",
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",
@@ -11,11 +11,14 @@ import {
11
11
  DEFAULT_SCHEDULER_OPTIONS,
12
12
  groupDuplicates,
13
13
  packWorkUnits,
14
+ resolveEffectiveTokenBudget,
14
15
  resolveInFlightTokens,
15
16
  resolveMemoryHeadroom,
16
17
  resolveModelMaxTokens,
17
18
  resolvePoolConfig,
19
+ resolveTokenBudgetChoice,
18
20
  runWorkUnits,
21
+ truncateTextToTokenBudget,
19
22
  type EmbedExtractor,
20
23
  type MeasuredText,
21
24
  type PendingText
@@ -405,6 +408,14 @@ function roundVector(values: number[]): number[] {
405
408
  return values.map((value) => Number(value.toFixed(6)));
406
409
  }
407
410
 
411
+ function resolveSignatureProfile(maxTokenCap: number | null): string {
412
+ return maxTokenCap ? `embed|max_tokens=${maxTokenCap}` : "";
413
+ }
414
+
415
+ function embeddingSignature(entitySignature: string, profile: string): string {
416
+ return profile ? hashText(`${profile}|${entitySignature}`) : entitySignature;
417
+ }
418
+
408
419
  function* presentEmbeddingRecords(
409
420
  slots: Array<EmbeddingRecord | null>
410
421
  ): Generator<EmbeddingRecord> {
@@ -438,8 +449,12 @@ async function main(): Promise<void> {
438
449
  const chunks = parseChunkEntities(readJsonl(path.join(CACHE_DIR, "entities.chunk.jsonl")), filePathById);
439
450
 
440
451
  const entities: SearchEntity[] = [...documents, ...rules, ...adrs, ...modules, ...projects, ...chunks].sort((a, b) => a.id.localeCompare(b.id));
441
-
442
- const existing = parseExistingEmbeddings(readJsonlRecords(EMBEDDINGS_PATH), modelId);
452
+ const uniqueSignatures = new Set<string>();
453
+ for (const entity of entities) {
454
+ uniqueSignatures.add(entity.signature);
455
+ }
456
+ const uniqueTextCount = uniqueSignatures.size;
457
+ uniqueSignatures.clear();
443
458
 
444
459
  env.cacheDir = MODEL_CACHE_DIR;
445
460
  // Total thread budget for embedding. CORTEX_EMBED_THREADS caps it so
@@ -448,6 +463,29 @@ async function main(): Promise<void> {
448
463
  const threadsRaw = Number(process.env.CORTEX_EMBED_THREADS);
449
464
  const threadBudget =
450
465
  Number.isFinite(threadsRaw) && threadsRaw >= 1 ? Math.floor(threadsRaw) : os.cpus().length;
466
+ const readHeadroom = () =>
467
+ resolveMemoryHeadroom({
468
+ freeMemory: os.freemem(),
469
+ totalMemory: os.totalmem(),
470
+ constrainedMemory: process.constrainedMemory?.() ?? null,
471
+ availableMemory: process.availableMemory?.() ?? null
472
+ });
473
+ const memoryHeadroom = readHeadroom();
474
+ const previewPoolConfig = resolvePoolConfig({
475
+ threadBudget,
476
+ uniqueCount: uniqueTextCount,
477
+ memoryBytes: memoryHeadroom
478
+ });
479
+ const requestedTokenBudget = resolveTokenBudgetChoice(process.env.CORTEX_EMBED_MAX_TOKENS, uniqueTextCount);
480
+ const tokenBudget = resolveEffectiveTokenBudget({
481
+ choice: requestedTokenBudget,
482
+ modelMaxTokens: resolveModelMaxTokens(undefined, requestedTokenBudget.cap ?? undefined),
483
+ memoryBytes: memoryHeadroom,
484
+ sessions: previewPoolConfig.sessions
485
+ });
486
+ const signatureProfile = resolveSignatureProfile(tokenBudget.cap);
487
+
488
+ const existing = parseExistingEmbeddings(readJsonlRecords(EMBEDDINGS_PATH), modelId);
451
489
 
452
490
  let reused = 0;
453
491
  // Slot per entity keeps output in entity order; failed slots stay null.
@@ -456,8 +494,9 @@ async function main(): Promise<void> {
456
494
  let dimensions = 0;
457
495
 
458
496
  entities.forEach((entity, index) => {
497
+ const signature = embeddingSignature(entity.signature, signatureProfile);
459
498
  const previous = existing.get(entity.id);
460
- if (previous && previous.signature === entity.signature && previous.vector.length > 0) {
499
+ if (previous && previous.signature === signature && previous.vector.length > 0) {
461
500
  reused += 1;
462
501
  dimensions = dimensions || previous.vector.length;
463
502
  slots[index] = {
@@ -470,7 +509,7 @@ async function main(): Promise<void> {
470
509
  source_of_truth: entity.source_of_truth,
471
510
  trust_level: entity.trust_level,
472
511
  updated_at: entity.updated_at,
473
- signature: entity.signature,
512
+ signature,
474
513
  model: modelId,
475
514
  dimensions: previous.vector.length
476
515
  };
@@ -486,14 +525,6 @@ async function main(): Promise<void> {
486
525
  // adapts to the machine so no tuning is expected from users. Container
487
526
  // limits (cgroups) and platforms that under-report free memory are both
488
527
  // 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
528
  const poolConfig = resolvePoolConfig({
498
529
  threadBudget,
499
530
  poolOverride: Number(process.env.CORTEX_EMBED_POOL) || null,
@@ -522,6 +553,7 @@ async function main(): Promise<void> {
522
553
  // Fully warm cache: nothing to embed, so skip model loading entirely —
523
554
  // this is the common repeat-bootstrap / small-update path.
524
555
  let embedded = 0;
556
+ let modelMaxTokensUsed = 0;
525
557
  let result: { vectors: Map<number, number[]>; failures: Array<{ index: number; message: string }> } = {
526
558
  vectors: new Map(),
527
559
  failures: []
@@ -569,11 +601,22 @@ async function main(): Promise<void> {
569
601
 
570
602
  // Inference truncates at the model max; token counts must too, or one
571
603
  // giant file inflates scheduling cost and gate mass far beyond reality.
572
- const modelMaxTokens = resolveModelMaxTokens(first.tokenizer?.model_max_length);
604
+ const modelMaxTokens = resolveModelMaxTokens(
605
+ first.tokenizer?.model_max_length,
606
+ tokenBudget.cap ?? undefined
607
+ );
608
+ modelMaxTokensUsed = modelMaxTokens;
573
609
  const countTokens = createTokenCounter(first.tokenizer, modelMaxTokens);
610
+ const countRawTokens = createTokenCounter(first.tokenizer, 131072);
574
611
 
575
- const measured: MeasuredText[] = unique.map((item) => ({ ...item, tokens: countTokens(item.text) }));
612
+ const measured: MeasuredText[] = unique.map((item) => {
613
+ const text = truncateTextToTokenBudget(item.text, countRawTokens, modelMaxTokens);
614
+ return { ...item, text, tokens: countTokens(text) };
615
+ });
576
616
  const units = packWorkUnits(measured, schedulerOptions);
617
+ console.log(
618
+ `[embed] scheduler unique=${unique.length} units=${units.length} max_tokens<=${modelMaxTokens} token_budget=${tokenBudget.mode} reason=${tokenBudget.reason}`
619
+ );
577
620
  // Recompute headroom after the model copies are resident so the gate
578
621
  // reflects what is actually left for inference activations.
579
622
  const inFlightRaw = Number(process.env.CORTEX_EMBED_INFLIGHT_TOKENS);
@@ -598,7 +641,7 @@ async function main(): Promise<void> {
598
641
  source_of_truth: entity.source_of_truth,
599
642
  trust_level: entity.trust_level,
600
643
  updated_at: entity.updated_at,
601
- signature: entity.signature,
644
+ signature: embeddingSignature(entity.signature, signatureProfile),
602
645
  model: modelId,
603
646
  dimensions: vector.length,
604
647
  vector
@@ -632,7 +675,7 @@ async function main(): Promise<void> {
632
675
  fs.writeFileSync(EMBEDDINGS_MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
633
676
 
634
677
  console.log(
635
- `[embed] mode=${mode} model=${modelId} dim=${dimensions} pool=${poolConfig.sessions}x${poolConfig.threadsPerSession} batch<=${schedulerOptions.batchMaxItems}`
678
+ `[embed] mode=${mode} model=${modelId} dim=${dimensions} pool=${poolConfig.sessions}x${poolConfig.threadsPerSession} batch<=${schedulerOptions.batchMaxItems} max_tokens<=${modelMaxTokensUsed || tokenBudget.cap || "model"} token_budget=${tokenBudget.mode} reason=${tokenBudget.reason}`
636
679
  );
637
680
  console.log(
638
681
  `[embed] entities=${entities.length} embedded=${embedded} reused=${reused} failed=${failed}`
@@ -183,9 +183,114 @@ export function resolveMemoryHeadroom({
183
183
  /** Sanitizes a tokenizer-reported maximum sequence length. Sentinel and
184
184
  * absurd values (some configs report 1e30) fall back to 8192; anything
185
185
  * beyond 128k tokens is treated as absurd. */
186
- export function resolveModelMaxTokens(raw: unknown): number {
186
+ export function resolveModelMaxTokens(raw: unknown, overrideRaw?: unknown): number {
187
187
  const value = Number(raw);
188
- return Number.isFinite(value) && value >= 1 && value <= 131072 ? Math.floor(value) : 8192;
188
+ const modelMax = Number.isFinite(value) && value >= 1 && value <= 131072 ? Math.floor(value) : 8192;
189
+ const override = Number(overrideRaw);
190
+ if (Number.isFinite(override) && override >= 16 && override <= 131072) {
191
+ return Math.min(modelMax, Math.floor(override));
192
+ }
193
+ return modelMax;
194
+ }
195
+
196
+ export type TokenBudgetChoice = {
197
+ cap: number | null;
198
+ mode: "auto" | "auto_degraded" | "explicit" | "model";
199
+ reason: string;
200
+ };
201
+
202
+ /**
203
+ * Chooses the requested embedding token budget before model load, so cache
204
+ * signatures can include the same cap that inference will later enforce.
205
+ * Auto mode is quality-preserving: it uses the model's own maximum by default
206
+ * and only truncates when the user explicitly requests a numeric cap.
207
+ */
208
+ export function resolveTokenBudgetChoice(overrideRaw: unknown, uniqueCount: number): TokenBudgetChoice {
209
+ const raw = String(overrideRaw ?? "").trim().toLowerCase();
210
+ const override = Number(raw);
211
+ if (Number.isFinite(override) && override >= 16 && override <= 131072) {
212
+ const cap = Math.floor(override);
213
+ return { cap, mode: "explicit", reason: "env_override" };
214
+ }
215
+
216
+ if (raw === "model" || raw === "none" || raw === "off" || raw === "full") {
217
+ return { cap: null, mode: "model", reason: "env_full_model" };
218
+ }
219
+
220
+ const count = Number.isFinite(uniqueCount) && uniqueCount > 0 ? Math.floor(uniqueCount) : 0;
221
+ return {
222
+ cap: null,
223
+ mode: "auto",
224
+ reason: `quality_preserving_model_max:unique=${count}`
225
+ };
226
+ }
227
+
228
+ const AUTO_TOKEN_BUDGET_CANDIDATES = [8192, 4096, 2048];
229
+ const AUTO_TOKEN_BUDGET_MIN = 2048;
230
+ const AUTO_TOKEN_BUDGET_SESSION_BYTES = 2.4e9;
231
+ const AUTO_TOKEN_BUDGET_ACTIVATION_BYTES_AT_4096 = 3e9;
232
+ const AUTO_TOKEN_BUDGET_MEMORY_MARGIN = 0.95;
233
+
234
+ function estimateTokenBudgetMemoryBytes(maxTokens: number, sessions: number): number {
235
+ const sessionCount = Number.isFinite(sessions) && sessions >= 1 ? Math.floor(sessions) : 1;
236
+ const tokenCount = Number.isFinite(maxTokens) && maxTokens >= 1 ? Math.floor(maxTokens) : 8192;
237
+ const activationBytes =
238
+ AUTO_TOKEN_BUDGET_ACTIVATION_BYTES_AT_4096 * Math.pow(tokenCount / 4096, 2);
239
+ return sessionCount * AUTO_TOKEN_BUDGET_SESSION_BYTES + activationBytes;
240
+ }
241
+
242
+ /**
243
+ * Keeps auto quality-first but avoids choosing a token budget that is likely
244
+ * to OOM this process. Explicit numeric caps and explicit full-model modes are
245
+ * operator choices and are never degraded here.
246
+ */
247
+ export function resolveEffectiveTokenBudget({
248
+ choice,
249
+ modelMaxTokens,
250
+ memoryBytes,
251
+ sessions
252
+ }: {
253
+ choice: TokenBudgetChoice;
254
+ modelMaxTokens: number;
255
+ memoryBytes?: number;
256
+ sessions?: number;
257
+ }): TokenBudgetChoice {
258
+ if (choice.mode !== "auto" || choice.cap !== null) {
259
+ return choice;
260
+ }
261
+ const modelMax =
262
+ Number.isFinite(modelMaxTokens) && modelMaxTokens >= 1 ? Math.floor(modelMaxTokens) : 8192;
263
+ if (modelMax <= AUTO_TOKEN_BUDGET_MIN) {
264
+ return choice;
265
+ }
266
+ if (!Number.isFinite(memoryBytes) || (memoryBytes as number) <= 0) {
267
+ return choice;
268
+ }
269
+
270
+ const available = (memoryBytes as number) * AUTO_TOKEN_BUDGET_MEMORY_MARGIN;
271
+ const candidates = AUTO_TOKEN_BUDGET_CANDIDATES
272
+ .filter((candidate) => candidate <= modelMax)
273
+ .sort((a, b) => b - a);
274
+ if (!candidates.includes(AUTO_TOKEN_BUDGET_MIN)) {
275
+ candidates.push(AUTO_TOKEN_BUDGET_MIN);
276
+ }
277
+
278
+ const selected =
279
+ candidates.find((candidate) => estimateTokenBudgetMemoryBytes(candidate, sessions ?? 1) <= available) ??
280
+ AUTO_TOKEN_BUDGET_MIN;
281
+
282
+ if (selected >= modelMax) {
283
+ return choice;
284
+ }
285
+
286
+ return {
287
+ cap: selected,
288
+ mode: "auto_degraded",
289
+ reason: `memory_headroom cap=${selected} model_max=${modelMax} sessions=${Math.max(
290
+ 1,
291
+ Math.floor(sessions ?? 1)
292
+ )} headroom_mb=${Math.round((memoryBytes as number) / 1024 / 1024)}`
293
+ };
189
294
  }
190
295
 
191
296
  /**
@@ -216,6 +321,29 @@ export function createTokenCounter(
216
321
  };
217
322
  }
218
323
 
324
+ export function truncateTextToTokenBudget(
325
+ text: string,
326
+ countTokens: (text: string) => number,
327
+ maxTokens: number
328
+ ): string {
329
+ if (!Number.isFinite(maxTokens) || maxTokens < 1 || countTokens(text) <= maxTokens) {
330
+ return text;
331
+ }
332
+
333
+ let low = 0;
334
+ let high = text.length;
335
+ while (low < high) {
336
+ const mid = Math.ceil((low + high) / 2);
337
+ if (countTokens(text.slice(0, mid)) <= maxTokens) {
338
+ low = mid;
339
+ } else {
340
+ high = mid - 1;
341
+ }
342
+ }
343
+
344
+ return text.slice(0, low).trimEnd();
345
+ }
346
+
219
347
  /** Estimated working memory one full pool session needs (model copy plus
220
348
  * inference activations for a base-size model). Used only as a derating
221
349
  * heuristic; deliberately conservative. */
@@ -448,6 +448,100 @@ test("resolveModelMaxTokens: sane values pass, sentinels and absurdities fall ba
448
448
  assert.equal(resolveModelMaxTokens(0), 8192);
449
449
  });
450
450
 
451
+ test("resolveModelMaxTokens: explicit override caps but never raises model max", async () => {
452
+ const { resolveModelMaxTokens } = await import("../dist/embedScheduler.js");
453
+ assert.equal(resolveModelMaxTokens(8192, 2048), 2048);
454
+ assert.equal(resolveModelMaxTokens(512, 2048), 512);
455
+ assert.equal(resolveModelMaxTokens(8192, "1024"), 1024);
456
+ assert.equal(resolveModelMaxTokens(8192, 4), 8192);
457
+ assert.equal(resolveModelMaxTokens(8192, "nope"), 8192);
458
+ });
459
+
460
+ test("resolveTokenBudgetChoice: auto preserves model max regardless of repo size", async () => {
461
+ const { resolveTokenBudgetChoice } = await import("../dist/embedScheduler.js");
462
+ assert.deepEqual(resolveTokenBudgetChoice(undefined, 180), {
463
+ cap: null,
464
+ mode: "auto",
465
+ reason: "quality_preserving_model_max:unique=180"
466
+ });
467
+ assert.deepEqual(resolveTokenBudgetChoice("auto", 20000), {
468
+ cap: null,
469
+ mode: "auto",
470
+ reason: "quality_preserving_model_max:unique=20000"
471
+ });
472
+ assert.deepEqual(resolveTokenBudgetChoice(undefined, 48533), {
473
+ cap: null,
474
+ mode: "auto",
475
+ reason: "quality_preserving_model_max:unique=48533"
476
+ });
477
+ });
478
+
479
+ test("resolveTokenBudgetChoice: explicit caps and full-model opt-out win over auto", async () => {
480
+ const { resolveTokenBudgetChoice } = await import("../dist/embedScheduler.js");
481
+ assert.deepEqual(resolveTokenBudgetChoice("4096", 48533), {
482
+ cap: 4096,
483
+ mode: "explicit",
484
+ reason: "env_override"
485
+ });
486
+ assert.deepEqual(resolveTokenBudgetChoice("model", 48533), {
487
+ cap: null,
488
+ mode: "model",
489
+ reason: "env_full_model"
490
+ });
491
+ });
492
+
493
+ test("resolveEffectiveTokenBudget: auto degrades when model max is too expensive for headroom", async () => {
494
+ const { resolveEffectiveTokenBudget, resolveTokenBudgetChoice } = await import("../dist/embedScheduler.js");
495
+ const choice = resolveTokenBudgetChoice(undefined, 2237);
496
+ const degraded = resolveEffectiveTokenBudget({
497
+ choice,
498
+ modelMaxTokens: 8192,
499
+ memoryBytes: 7e9,
500
+ sessions: 2
501
+ });
502
+ assert.equal(degraded.mode, "auto_degraded");
503
+ assert.equal(degraded.cap, 2048);
504
+ assert.match(degraded.reason, /memory_headroom/);
505
+ });
506
+
507
+ test("resolveEffectiveTokenBudget: auto keeps model max when headroom is sufficient", async () => {
508
+ const { resolveEffectiveTokenBudget, resolveTokenBudgetChoice } = await import("../dist/embedScheduler.js");
509
+ const choice = resolveTokenBudgetChoice("auto", 2237);
510
+ assert.deepEqual(
511
+ resolveEffectiveTokenBudget({
512
+ choice,
513
+ modelMaxTokens: 8192,
514
+ memoryBytes: 64e9,
515
+ sessions: 2
516
+ }),
517
+ choice
518
+ );
519
+ });
520
+
521
+ test("resolveEffectiveTokenBudget: explicit caps and full-model opt-out are never degraded", async () => {
522
+ const { resolveEffectiveTokenBudget, resolveTokenBudgetChoice } = await import("../dist/embedScheduler.js");
523
+ const explicit = resolveTokenBudgetChoice("4096", 2237);
524
+ assert.deepEqual(
525
+ resolveEffectiveTokenBudget({
526
+ choice: explicit,
527
+ modelMaxTokens: 8192,
528
+ memoryBytes: 1e9,
529
+ sessions: 4
530
+ }),
531
+ explicit
532
+ );
533
+ const full = resolveTokenBudgetChoice("full", 2237);
534
+ assert.deepEqual(
535
+ resolveEffectiveTokenBudget({
536
+ choice: full,
537
+ modelMaxTokens: 8192,
538
+ memoryBytes: 1e9,
539
+ sessions: 4
540
+ }),
541
+ full
542
+ );
543
+ });
544
+
451
545
  test("createTokenCounter: uses the tokenizer, clamps at model max, survives failures", async () => {
452
546
  const { createTokenCounter } = await import("../dist/embedScheduler.js");
453
547
  const tokenizer = (text) => ({ input_ids: { dims: [1, text.length * 2] } });
@@ -462,6 +556,13 @@ test("createTokenCounter: uses the tokenizer, clamps at model max, survives fail
462
556
  assert.equal(missing("x".repeat(40)), 10);
463
557
  });
464
558
 
559
+ test("truncateTextToTokenBudget: trims a prefix to the requested token cap", async () => {
560
+ const { truncateTextToTokenBudget } = await import("../dist/embedScheduler.js");
561
+ const countTokens = (text) => text.length;
562
+ assert.equal(truncateTextToTokenBudget("abcdef", countTokens, 3), "abc");
563
+ assert.equal(truncateTextToTokenBudget("abc", countTokens, 3), "abc");
564
+ });
565
+
465
566
  test("resolveMemoryHeadroom: constrainedMemory 0 means unconstrained (Node sentinel)", async () => {
466
567
  const { resolveMemoryHeadroom } = await import("../dist/embedScheduler.js");
467
568
  const unconstrained = resolveMemoryHeadroom({ freeMemory: 50e9, totalMemory: 64e9 });