@danielblomma/cortex-mcp 2.2.3 → 2.2.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 CHANGED
@@ -413,12 +413,19 @@ 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 preserves the embedding
422
+ model's own maximum context. Cortex does not lower the default token budget
423
+ just because a repository is large; lower caps can discard semantically useful
424
+ text and are reserved for explicit benchmark or quality experiments. To force a
425
+ specific capped run, set `CORTEX_EMBED_MAX_TOKENS` to a number such as `2048`;
426
+ set it to `model` or `full` to make the full-model baseline explicit. When
427
+ several cortex instances share one machine, set `CORTEX_EMBED_THREADS` to give
428
+ each its fair share of cores.
422
429
 
423
430
  ## Limitations
424
431
 
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.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",
@@ -15,7 +15,9 @@ import {
15
15
  resolveMemoryHeadroom,
16
16
  resolveModelMaxTokens,
17
17
  resolvePoolConfig,
18
+ resolveTokenBudgetChoice,
18
19
  runWorkUnits,
20
+ truncateTextToTokenBudget,
19
21
  type EmbedExtractor,
20
22
  type MeasuredText,
21
23
  type PendingText
@@ -405,6 +407,14 @@ function roundVector(values: number[]): number[] {
405
407
  return values.map((value) => Number(value.toFixed(6)));
406
408
  }
407
409
 
410
+ function resolveSignatureProfile(maxTokenCap: number | null): string {
411
+ return maxTokenCap ? `embed|max_tokens=${maxTokenCap}` : "";
412
+ }
413
+
414
+ function embeddingSignature(entitySignature: string, profile: string): string {
415
+ return profile ? hashText(`${profile}|${entitySignature}`) : entitySignature;
416
+ }
417
+
408
418
  function* presentEmbeddingRecords(
409
419
  slots: Array<EmbeddingRecord | null>
410
420
  ): Generator<EmbeddingRecord> {
@@ -438,6 +448,14 @@ async function main(): Promise<void> {
438
448
  const chunks = parseChunkEntities(readJsonl(path.join(CACHE_DIR, "entities.chunk.jsonl")), filePathById);
439
449
 
440
450
  const entities: SearchEntity[] = [...documents, ...rules, ...adrs, ...modules, ...projects, ...chunks].sort((a, b) => a.id.localeCompare(b.id));
451
+ const uniqueSignatures = new Set<string>();
452
+ for (const entity of entities) {
453
+ uniqueSignatures.add(entity.signature);
454
+ }
455
+ const uniqueTextCount = uniqueSignatures.size;
456
+ const tokenBudget = resolveTokenBudgetChoice(process.env.CORTEX_EMBED_MAX_TOKENS, uniqueTextCount);
457
+ uniqueSignatures.clear();
458
+ const signatureProfile = resolveSignatureProfile(tokenBudget.cap);
441
459
 
442
460
  const existing = parseExistingEmbeddings(readJsonlRecords(EMBEDDINGS_PATH), modelId);
443
461
 
@@ -456,8 +474,9 @@ async function main(): Promise<void> {
456
474
  let dimensions = 0;
457
475
 
458
476
  entities.forEach((entity, index) => {
477
+ const signature = embeddingSignature(entity.signature, signatureProfile);
459
478
  const previous = existing.get(entity.id);
460
- if (previous && previous.signature === entity.signature && previous.vector.length > 0) {
479
+ if (previous && previous.signature === signature && previous.vector.length > 0) {
461
480
  reused += 1;
462
481
  dimensions = dimensions || previous.vector.length;
463
482
  slots[index] = {
@@ -470,7 +489,7 @@ async function main(): Promise<void> {
470
489
  source_of_truth: entity.source_of_truth,
471
490
  trust_level: entity.trust_level,
472
491
  updated_at: entity.updated_at,
473
- signature: entity.signature,
492
+ signature,
474
493
  model: modelId,
475
494
  dimensions: previous.vector.length
476
495
  };
@@ -522,6 +541,7 @@ async function main(): Promise<void> {
522
541
  // Fully warm cache: nothing to embed, so skip model loading entirely —
523
542
  // this is the common repeat-bootstrap / small-update path.
524
543
  let embedded = 0;
544
+ let modelMaxTokensUsed = 0;
525
545
  let result: { vectors: Map<number, number[]>; failures: Array<{ index: number; message: string }> } = {
526
546
  vectors: new Map(),
527
547
  failures: []
@@ -569,11 +589,22 @@ async function main(): Promise<void> {
569
589
 
570
590
  // Inference truncates at the model max; token counts must too, or one
571
591
  // giant file inflates scheduling cost and gate mass far beyond reality.
572
- const modelMaxTokens = resolveModelMaxTokens(first.tokenizer?.model_max_length);
592
+ const modelMaxTokens = resolveModelMaxTokens(
593
+ first.tokenizer?.model_max_length,
594
+ tokenBudget.cap ?? undefined
595
+ );
596
+ modelMaxTokensUsed = modelMaxTokens;
573
597
  const countTokens = createTokenCounter(first.tokenizer, modelMaxTokens);
598
+ const countRawTokens = createTokenCounter(first.tokenizer, 131072);
574
599
 
575
- const measured: MeasuredText[] = unique.map((item) => ({ ...item, tokens: countTokens(item.text) }));
600
+ const measured: MeasuredText[] = unique.map((item) => {
601
+ const text = truncateTextToTokenBudget(item.text, countRawTokens, modelMaxTokens);
602
+ return { ...item, text, tokens: countTokens(text) };
603
+ });
576
604
  const units = packWorkUnits(measured, schedulerOptions);
605
+ console.log(
606
+ `[embed] scheduler unique=${unique.length} units=${units.length} max_tokens<=${modelMaxTokens} token_budget=${tokenBudget.mode} reason=${tokenBudget.reason}`
607
+ );
577
608
  // Recompute headroom after the model copies are resident so the gate
578
609
  // reflects what is actually left for inference activations.
579
610
  const inFlightRaw = Number(process.env.CORTEX_EMBED_INFLIGHT_TOKENS);
@@ -598,7 +629,7 @@ async function main(): Promise<void> {
598
629
  source_of_truth: entity.source_of_truth,
599
630
  trust_level: entity.trust_level,
600
631
  updated_at: entity.updated_at,
601
- signature: entity.signature,
632
+ signature: embeddingSignature(entity.signature, signatureProfile),
602
633
  model: modelId,
603
634
  dimensions: vector.length,
604
635
  vector
@@ -632,7 +663,7 @@ async function main(): Promise<void> {
632
663
  fs.writeFileSync(EMBEDDINGS_MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
633
664
 
634
665
  console.log(
635
- `[embed] mode=${mode} model=${modelId} dim=${dimensions} pool=${poolConfig.sessions}x${poolConfig.threadsPerSession} batch<=${schedulerOptions.batchMaxItems}`
666
+ `[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}`
636
667
  );
637
668
  console.log(
638
669
  `[embed] entities=${entities.length} embedded=${embedded} reused=${reused} failed=${failed}`
@@ -183,9 +183,46 @@ 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" | "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
+ };
189
226
  }
190
227
 
191
228
  /**
@@ -216,6 +253,29 @@ export function createTokenCounter(
216
253
  };
217
254
  }
218
255
 
256
+ export function truncateTextToTokenBudget(
257
+ text: string,
258
+ countTokens: (text: string) => number,
259
+ maxTokens: number
260
+ ): string {
261
+ if (!Number.isFinite(maxTokens) || maxTokens < 1 || countTokens(text) <= maxTokens) {
262
+ return text;
263
+ }
264
+
265
+ let low = 0;
266
+ let high = text.length;
267
+ while (low < high) {
268
+ const mid = Math.ceil((low + high) / 2);
269
+ if (countTokens(text.slice(0, mid)) <= maxTokens) {
270
+ low = mid;
271
+ } else {
272
+ high = mid - 1;
273
+ }
274
+ }
275
+
276
+ return text.slice(0, low).trimEnd();
277
+ }
278
+
219
279
  /** Estimated working memory one full pool session needs (model copy plus
220
280
  * inference activations for a base-size model). Used only as a derating
221
281
  * heuristic; deliberately conservative. */
@@ -448,6 +448,48 @@ 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
+
451
493
  test("createTokenCounter: uses the tokenizer, clamps at model max, survives failures", async () => {
452
494
  const { createTokenCounter } = await import("../dist/embedScheduler.js");
453
495
  const tokenizer = (text) => ({ input_ids: { dims: [1, text.length * 2] } });
@@ -462,6 +504,13 @@ test("createTokenCounter: uses the tokenizer, clamps at model max, survives fail
462
504
  assert.equal(missing("x".repeat(40)), 10);
463
505
  });
464
506
 
507
+ test("truncateTextToTokenBudget: trims a prefix to the requested token cap", async () => {
508
+ const { truncateTextToTokenBudget } = await import("../dist/embedScheduler.js");
509
+ const countTokens = (text) => text.length;
510
+ assert.equal(truncateTextToTokenBudget("abcdef", countTokens, 3), "abc");
511
+ assert.equal(truncateTextToTokenBudget("abc", countTokens, 3), "abc");
512
+ });
513
+
465
514
  test("resolveMemoryHeadroom: constrainedMemory 0 means unconstrained (Node sentinel)", async () => {
466
515
  const { resolveMemoryHeadroom } = await import("../dist/embedScheduler.js");
467
516
  const unconstrained = resolveMemoryHeadroom({ freeMemory: 50e9, totalMemory: 64e9 });