capman 0.6.0 → 0.6.2

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 (64) hide show
  1. package/CODEBASE.md +6 -5
  2. package/dist/cjs/cache.d.ts +9 -0
  3. package/dist/cjs/cache.d.ts.map +1 -1
  4. package/dist/cjs/cache.js +37 -7
  5. package/dist/cjs/cache.js.map +1 -1
  6. package/dist/cjs/concurrent.d.ts +53 -0
  7. package/dist/cjs/concurrent.d.ts.map +1 -0
  8. package/dist/cjs/concurrent.js +71 -0
  9. package/dist/cjs/concurrent.js.map +1 -0
  10. package/dist/cjs/engine.d.ts +92 -7
  11. package/dist/cjs/engine.d.ts.map +1 -1
  12. package/dist/cjs/engine.js +269 -57
  13. package/dist/cjs/engine.js.map +1 -1
  14. package/dist/cjs/generator.d.ts.map +1 -1
  15. package/dist/cjs/generator.js +28 -6
  16. package/dist/cjs/generator.js.map +1 -1
  17. package/dist/cjs/index.d.ts +3 -1
  18. package/dist/cjs/index.d.ts.map +1 -1
  19. package/dist/cjs/index.js +5 -1
  20. package/dist/cjs/index.js.map +1 -1
  21. package/dist/cjs/learning.d.ts +16 -1
  22. package/dist/cjs/learning.d.ts.map +1 -1
  23. package/dist/cjs/learning.js +95 -14
  24. package/dist/cjs/learning.js.map +1 -1
  25. package/dist/cjs/matcher.d.ts +51 -2
  26. package/dist/cjs/matcher.d.ts.map +1 -1
  27. package/dist/cjs/matcher.js +173 -33
  28. package/dist/cjs/matcher.js.map +1 -1
  29. package/dist/cjs/parser.js +27 -9
  30. package/dist/cjs/parser.js.map +1 -1
  31. package/dist/cjs/resolver.d.ts +2 -2
  32. package/dist/cjs/resolver.d.ts.map +1 -1
  33. package/dist/cjs/resolver.js +66 -26
  34. package/dist/cjs/resolver.js.map +1 -1
  35. package/dist/cjs/schema.d.ts +821 -68
  36. package/dist/cjs/schema.d.ts.map +1 -1
  37. package/dist/cjs/schema.js +62 -13
  38. package/dist/cjs/schema.js.map +1 -1
  39. package/dist/cjs/types.d.ts +156 -9
  40. package/dist/cjs/types.d.ts.map +1 -1
  41. package/dist/cjs/version.d.ts +1 -1
  42. package/dist/cjs/version.js +1 -1
  43. package/dist/esm/cache.d.ts +9 -0
  44. package/dist/esm/cache.js +37 -7
  45. package/dist/esm/concurrent.d.ts +52 -0
  46. package/dist/esm/concurrent.js +66 -0
  47. package/dist/esm/engine.d.ts +92 -7
  48. package/dist/esm/engine.js +270 -58
  49. package/dist/esm/generator.js +28 -6
  50. package/dist/esm/index.d.ts +3 -1
  51. package/dist/esm/index.js +2 -0
  52. package/dist/esm/learning.d.ts +16 -1
  53. package/dist/esm/learning.js +95 -14
  54. package/dist/esm/matcher.d.ts +51 -2
  55. package/dist/esm/matcher.js +170 -33
  56. package/dist/esm/parser.js +27 -9
  57. package/dist/esm/resolver.d.ts +2 -2
  58. package/dist/esm/resolver.js +66 -26
  59. package/dist/esm/schema.d.ts +821 -68
  60. package/dist/esm/schema.js +62 -13
  61. package/dist/esm/types.d.ts +156 -9
  62. package/dist/esm/version.d.ts +1 -1
  63. package/dist/esm/version.js +1 -1
  64. package/package.json +1 -1
@@ -10,6 +10,9 @@ const version_1 = require("./version");
10
10
  // ─── CapmanEngine ─────────────────────────────────────────────────────────────
11
11
  class CapmanEngine {
12
12
  constructor(options) {
13
+ this.manifestVersion = 0;
14
+ /** Resolves when the post-loadManifest re-encode completes. Awaited by buildEmbeddingScores(). */
15
+ this.pendingEmbedding = null;
13
16
  // ── LLM rate limiting state ────────────────────────────────────────────────
14
17
  this.llmCallsThisMinute = 0;
15
18
  this.llmWindowStart = Date.now();
@@ -20,6 +23,7 @@ class CapmanEngine {
20
23
  this.mode = options.mode ?? 'balanced';
21
24
  this.llm = options.llm;
22
25
  this.baseUrl = options.baseUrl;
26
+ this.environment = options.environment;
23
27
  this.auth = options.auth;
24
28
  this.headers = options.headers;
25
29
  this.threshold = options.threshold ?? 50;
@@ -45,8 +49,20 @@ class CapmanEngine {
45
49
  // Use FileLearningStore explicitly for persistence across restarts
46
50
  this.learning = options.learning === false
47
51
  ? null
48
- : (options.learning ?? new learning_1.MemoryLearningStore());
49
- logger_1.logger.info(`CapmanEngine initialized — mode: ${this.mode}, cache: ${this.cache ? 'enabled' : 'disabled'}, learning: ${this.learning ? 'enabled' : 'disabled'}`);
52
+ : (options.learning ?? new learning_1.MemoryLearningStore(options.learningHalfLifeDays ?? 30));
53
+ this.embedding = options.embedding;
54
+ if (this.embedding) {
55
+ // Pre-encode all capability texts at construction time — one batch call.
56
+ // Concatenate name + description for richer semantic surface.
57
+ const texts = this.manifest.capabilities.map(c => `${c.name}: ${c.description}`);
58
+ this.embedding.encode(texts).then(vecs => {
59
+ this.capEmbeddings = vecs;
60
+ logger_1.logger.info('Capability embeddings pre-encoded');
61
+ }).catch(err => {
62
+ logger_1.logger.warn(`EmbeddingProvider pre-encode failed — embedding signal disabled: ${err instanceof Error ? err.message : String(err)}`);
63
+ });
64
+ }
65
+ logger_1.logger.info(`CapmanEngine initialized — mode: ${this.mode}, cache: ${this.cache ? 'enabled' : 'disabled'}, learning: ${this.learning ? 'enabled' : 'disabled'}, embedding: ${this.embedding ? 'enabled' : 'disabled'}`);
50
66
  // ── Manifest version compatibility check ─────────────────────────────────
51
67
  this.checkManifestVersion(options.manifest);
52
68
  }
@@ -70,6 +86,9 @@ class CapmanEngine {
70
86
  }
71
87
  const start = Date.now();
72
88
  const steps = [];
89
+ // Capture manifest version at entry — used to guard the cache write.
90
+ // If loadManifest() is called mid-flight, we skip writing stale results.
91
+ const manifestVersion = this.manifestVersion;
73
92
  // ── Step 1: Check cache ──────────────────────────────────────────────────
74
93
  const cacheStart = Date.now();
75
94
  if (this.cache) {
@@ -127,6 +146,7 @@ class CapmanEngine {
127
146
  // ── Step 2.5: Apply learning boost ───────────────────────────────────────
128
147
  matchResult = await this.applyBoostToMatchResult(query, matchResult, resolvedVia);
129
148
  // ── Step 3: Privacy check ────────────────────────────────────────────────
149
+ let privacyFailed = false;
130
150
  if (matchResult.capability) {
131
151
  const privacyError = (0, resolver_1.checkPrivacy)(matchResult.capability, this.auth);
132
152
  steps.push({
@@ -135,13 +155,23 @@ class CapmanEngine {
135
155
  durationMs: 0,
136
156
  detail: privacyError ?? `level: ${matchResult.capability.privacy.level}`,
137
157
  });
158
+ // Warn on deprecated or sunset capabilities — never silently fail
159
+ this.checkCapabilityLifecycle(matchResult.capability);
160
+ // Log when engine mode differs from capability's preferred mode
161
+ this.checkMatchHint(matchResult.capability);
162
+ // Short-circuit: if privacy fails, skip disambiguation to avoid burning an LLM
163
+ // call on a request that _resolve() will block anyway. privacyFailed propagates
164
+ // to Step 4a so the mode guard check is clean and explicit.
165
+ if (privacyError)
166
+ privacyFailed = true;
138
167
  }
139
168
  // ── Step 4a: Compute verdict + optional margin-aware LLM disambiguation ──
140
169
  let { verdict, margin } = this.computeVerdict(matchResult);
141
170
  if (verdict === 'marginal' &&
142
171
  this.marginAwareLLM &&
143
172
  this.llm &&
144
- this.mode === 'balanced') {
173
+ !privacyFailed &&
174
+ (this.mode === 'balanced' || this.mode === 'accurate')) {
145
175
  matchResult = await this.disambiguateLLM(query, matchResult, steps);
146
176
  // Recompute verdict after disambiguation
147
177
  const recomputed = this.computeVerdict(matchResult);
@@ -164,11 +194,19 @@ class CapmanEngine {
164
194
  // queries that resolve to the same capability share a cache entry
165
195
  if (this.cache && resolution.success && matchResult.capability
166
196
  && matchResult.capability.privacy.level === 'public') {
167
- const queryKey = (0, cache_1.normalizeQuery)(query);
168
- const capKey = (0, cache_1.buildCacheKey)(query, matchResult.capability.id, matchResult.extractedParams);
169
- await this.cache.set(queryKey, matchResult);
170
- await this.cache.set(capKey, matchResult);
171
- // capKey always starts with 'cap:' — structurally distinct from queryKey
197
+ // Optimistic concurrency guard — skip cache write if manifest was swapped
198
+ // mid-flight. The result was computed against a now-stale manifest and
199
+ // must not pollute the cache for the new one.
200
+ if (this.manifestVersion === manifestVersion) {
201
+ const queryKey = (0, cache_1.normalizeQuery)(query);
202
+ const capKey = (0, cache_1.buildCacheKey)(query, matchResult.capability.id, matchResult.extractedParams);
203
+ await this.cache.set(queryKey, matchResult);
204
+ await this.cache.set(capKey, matchResult);
205
+ // capKey always starts with 'cap:' — structurally distinct from queryKey
206
+ }
207
+ else {
208
+ logger_1.logger.warn('loadManifest() called mid-flight — skipping cache write for stale result');
209
+ }
172
210
  }
173
211
  // ── Step 5b: Compute missingParams ───────────────────────────────────────
174
212
  // Spec: LLM attempts extraction first when available. missingParams is last resort.
@@ -208,8 +246,19 @@ class CapmanEngine {
208
246
  }
209
247
  }
210
248
  }
211
- catch {
212
- // LLM param extraction failed — fall through to missingParams below
249
+ catch (err) {
250
+ const isParseError = err instanceof SyntaxError;
251
+ if (isParseError) {
252
+ // JSON parse failure: refund the rate-limit slot but don't open circuit breaker
253
+ // The llm is reachable - the response format was just bad
254
+ this.llmCallsThisMinute = Math.max(0, this.llmCallsThisMinute - 1);
255
+ }
256
+ else {
257
+ // Hard failure (timeout, network): refund slot and increment fail counter
258
+ this.recordLLMFailure();
259
+ }
260
+ logger_1.logger.warn(`LLM param extraction failed: ${err instanceof Error ? err.message : String(err)}`);
261
+ // fall through to missingParams below
213
262
  }
214
263
  }
215
264
  }
@@ -295,6 +344,20 @@ class CapmanEngine {
295
344
  await this.cache.clear();
296
345
  }
297
346
  checkManifestVersion(manifest) {
347
+ // ── Schema version check ─────────────────────────────────────────────────
348
+ // schemaVersion tracks manifest format — "1" for v0.6+.
349
+ // Manifests without schemaVersion are pre-v0.6 — warn but allow.
350
+ const CURRENT_SCHEMA_VERSION = '1';
351
+ if (!manifest.schemaVersion) {
352
+ console.warn(`[capman] Manifest is missing schemaVersion — it was generated with capman < 0.6. ` +
353
+ `Regenerate with: npx capman generate`);
354
+ }
355
+ else if (manifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
356
+ console.warn(`[capman] Manifest schemaVersion "${manifest.schemaVersion}" differs from ` +
357
+ `engine's expected "${CURRENT_SCHEMA_VERSION}". ` +
358
+ `Regenerate with: npx capman generate`);
359
+ }
360
+ // ── Package version check ────────────────────────────────────────────────
298
361
  if (!manifest.version)
299
362
  return;
300
363
  const SEMVER_RE = /^\d+\.\d+\.\d+$/;
@@ -302,8 +365,8 @@ class CapmanEngine {
302
365
  const [mMaj, mMin] = manifest.version.split('.').map(Number);
303
366
  const [eMaj, eMin] = version_1.VERSION.split('.').map(Number);
304
367
  if (mMaj !== eMaj || mMin !== eMin) {
305
- console.warn(`[capman] Manifest version "${manifest.version}" was generated with a ` +
306
- `different engine version than "${version_1.VERSION}". This is usually fine across patch versions. ` +
368
+ console.warn(`[capman] Manifest was generated with capman "${manifest.version}" ` +
369
+ `but engine is "${version_1.VERSION}". This is usually fine across patch versions. ` +
307
370
  `If you experience unexpected matching issues, regenerate with: npx capman generate`);
308
371
  }
309
372
  }
@@ -312,6 +375,80 @@ class CapmanEngine {
312
375
  `to engine version "${version_1.VERSION}" — version strings are not valid semver.`);
313
376
  }
314
377
  }
378
+ checkCapabilityLifecycle(capability) {
379
+ const lc = capability.lifecycle;
380
+ if (!lc || lc.status === 'stable' || lc.status === 'beta' || lc.status === 'experimental') {
381
+ if (lc?.status === 'beta') {
382
+ logger_1.logger.warn(`Capability "${capability.id}" is in beta — behavior may change`);
383
+ }
384
+ if (lc?.status === 'experimental') {
385
+ logger_1.logger.warn(`Capability "${capability.id}" is experimental — use with caution`);
386
+ }
387
+ return;
388
+ }
389
+ if (lc.status === 'deprecated') {
390
+ const sunsetPassed = lc.sunsetAt && new Date(lc.sunsetAt) < new Date();
391
+ if (sunsetPassed) {
392
+ // Sunset date has passed — strongest warning
393
+ console.warn(`[capman] ⚠️ Capability "${capability.id}" passed its sunset date (${lc.sunsetAt}). ` +
394
+ `It may be removed in a future version.` +
395
+ (lc.successor ? ` Use "${lc.successor}" instead.` : '') +
396
+ (lc.note ? ` Note: ${lc.note}` : ''));
397
+ }
398
+ else {
399
+ logger_1.logger.warn(`Capability "${capability.id}" is deprecated.` +
400
+ (lc.sunsetAt ? ` Sunset: ${lc.sunsetAt}.` : '') +
401
+ (lc.successor ? ` Use "${lc.successor}" instead.` : '') +
402
+ (lc.note ? ` Note: ${lc.note}` : ''));
403
+ }
404
+ }
405
+ }
406
+ /** Cosine similarity between two equal-length vectors */
407
+ cosineSim(a, b) {
408
+ if (a.length !== b.length || a.length === 0) {
409
+ logger_1.logger.warn(`cosineSim: dimension mismatch (${a.length} vs ${b.length}) — returning 0`);
410
+ return 0;
411
+ }
412
+ let dot = 0, normA = 0, normB = 0;
413
+ for (let i = 0; i < a.length; i++) {
414
+ dot += a[i] * b[i];
415
+ normA += a[i] * a[i];
416
+ normB += b[i] * b[i];
417
+ }
418
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
419
+ return denom === 0 ? 0 : dot / denom;
420
+ }
421
+ /** Encode query and return cosine similarity scores (0–100) keyed by capability ID */
422
+ async buildEmbeddingScores(query) {
423
+ if (!this.embedding || !this.capEmbeddings)
424
+ return undefined;
425
+ // Wait for any in-flight re-encode from loadManifest() to finish.
426
+ // Without this, the first ask() after loadManifest returns uses stale embeddings.
427
+ if (this.pendingEmbedding)
428
+ await this.pendingEmbedding;
429
+ try {
430
+ const [queryVec] = await this.embedding.encode([query]);
431
+ const scores = new Map();
432
+ this.manifest.capabilities.forEach((cap, i) => {
433
+ const sim = this.cosineSim(queryVec, this.capEmbeddings[i]);
434
+ // Cosine sim is -1..1; map to 0–100, negatives floored to 0
435
+ scores.set(cap.id, Math.max(0, Math.round(sim * 100)));
436
+ });
437
+ return scores;
438
+ }
439
+ catch (err) {
440
+ logger_1.logger.warn(`Embedding encode failed — skipping embedding signal: ${err instanceof Error ? err.message : String(err)}`);
441
+ return undefined;
442
+ }
443
+ }
444
+ checkMatchHint(capability) {
445
+ const hint = capability.matchHint?.preferredMode;
446
+ if (!hint || hint === this.mode)
447
+ return;
448
+ // Advisory only — log but never enforce
449
+ logger_1.logger.warn(`Capability "${capability.id}" prefers mode "${hint}" but engine is in "${this.mode}" mode. ` +
450
+ `Set mode: '${hint}' in EngineOptions to honor this hint.`);
451
+ }
315
452
  /**
316
453
  * Replaces the active manifest without creating a new engine instance.
317
454
  * Useful for hot-reloading manifests in long-running servers without
@@ -326,11 +463,31 @@ class CapmanEngine {
326
463
  */
327
464
  async loadManifest(manifest) {
328
465
  this.checkManifestVersion(manifest);
466
+ // Assign all derived state atomically before any await — an in-flight ask()
467
+ // must never see a new manifest paired with a stale bm25Index or ceiling.
329
468
  this.manifest = manifest;
330
469
  this.bm25Index = (0, matcher_1.buildBM25Index)(manifest.capabilities);
331
470
  this.bm25Ceiling = this.calibrateBM25Ceiling();
332
471
  this.adaptiveMargin = this.calibrateAdaptiveMargin();
472
+ this.manifestVersion++;
473
+ // server selection updates automatically after loadManifest()
333
474
  await this.clearCache();
475
+ // Re-encode capabilities after manifest swap — stale embeddings misalign with new capabilities
476
+ if (this.embedding) {
477
+ const texts = manifest.capabilities.map(c => `${c.name}: ${c.description}`);
478
+ this.pendingEmbedding = this.embedding.encode(texts).then(vecs => {
479
+ this.capEmbeddings = vecs;
480
+ this.pendingEmbedding = null;
481
+ logger_1.logger.info('Capability embeddings re-encoded after manifest reload');
482
+ }).catch(err => {
483
+ this.capEmbeddings = undefined;
484
+ this.pendingEmbedding = null;
485
+ logger_1.logger.warn(`EmbeddingProvider re-encode failed after loadManifest: ${err instanceof Error ? err.message : String(err)}`);
486
+ });
487
+ }
488
+ else {
489
+ this.pendingEmbedding = null;
490
+ }
334
491
  }
335
492
  /**
336
493
  * Explain what would happen for a query — without executing it.
@@ -572,13 +729,15 @@ class CapmanEngine {
572
729
  let matchResult;
573
730
  let resolvedVia = 'keyword';
574
731
  // Fuzzy options — never applied in cheap mode
732
+ const embeddingScores = await this.buildEmbeddingScores(query);
575
733
  const fuzzyOpts = {
576
734
  fuzzyMatch: this.fuzzyMatch,
577
735
  fuzzyThreshold: this.fuzzyThreshold,
578
736
  bm25Index: this.bm25Index,
579
- bm25Ceiling: this.bm25Ceiling,
580
737
  bm25K1: this.bm25K1,
581
738
  bm25B: this.bm25B,
739
+ bm25Ceiling: this.bm25Ceiling,
740
+ embeddingScores,
582
741
  };
583
742
  switch (this.mode) {
584
743
  case 'cheap': {
@@ -601,20 +760,33 @@ class CapmanEngine {
601
760
  else {
602
761
  const t = Date.now();
603
762
  try {
604
- matchResult = await (0, matcher_1.matchWithLLM)(query, this.manifest, { llm: this.llm });
605
- this.recordLLMSuccess();
606
- resolvedVia = 'llm';
607
- // Merge keyword scores into LLM candidates so boost has real signal for alternatives
608
- const kwResult = (0, matcher_1.match)(query, this.manifest, fuzzyOpts);
609
- matchResult = {
610
- ...matchResult,
611
- candidates: matchResult.candidates.map(c => ({
612
- ...c,
613
- score: c.matched
614
- ? c.score // keep LLM confidence for winner
615
- : (kwResult.candidates.find(kc => kc.capabilityId === c.capabilityId)?.score ?? 0),
616
- })),
617
- };
763
+ const kwResultAccurate = (0, matcher_1.match)(query, this.manifest, fuzzyOpts);
764
+ const top3Accurate = kwResultAccurate.candidates
765
+ .sort((a, b) => b.score - a.score)
766
+ .filter(c => c.score > 0)
767
+ .slice(0, 3)
768
+ .map(c => this.manifest.capabilities.find(cap => cap.id === c.capabilityId))
769
+ .filter(Boolean);
770
+ // Skip LLM if no candidates scored above zero — no meaningful top-3 to discriminate
771
+ if (top3Accurate.length === 0) {
772
+ matchResult = kwResultAccurate;
773
+ }
774
+ else {
775
+ const llmResult = await (0, matcher_1.matchWithLLM)(query, top3Accurate, { llm: this.llm, app: this.manifest.app });
776
+ this.recordLLMSuccess();
777
+ resolvedVia = 'llm';
778
+ // If LLM says OOS but keyword had a match, the correct capability may have
779
+ // been rank 4+. Fall back to keyword result rather than returning OOS.
780
+ matchResult = llmResult.capability === null ? kwResultAccurate : {
781
+ ...llmResult,
782
+ candidates: llmResult.candidates.map(c => ({
783
+ ...c,
784
+ score: c.matched
785
+ ? c.score
786
+ : (kwResultAccurate.candidates.find(kc => kc.capabilityId === c.capabilityId)?.score ?? 0),
787
+ })),
788
+ };
789
+ }
618
790
  steps?.push({ type: 'llm_match', status: 'pass', durationMs: Date.now() - t, detail: `confidence: ${matchResult.confidence}%` });
619
791
  }
620
792
  catch (err) {
@@ -659,19 +831,32 @@ class CapmanEngine {
659
831
  logger_1.logger.debug(`Query escalated to LLM: "${query}"`);
660
832
  const t2 = Date.now();
661
833
  try {
662
- matchResult = await (0, matcher_1.matchWithLLM)(query, this.manifest, { llm: this.llm });
663
- this.recordLLMSuccess();
664
- resolvedVia = 'llm';
665
- // keywordResult already computed above in balanced mode — merge scores
666
- matchResult = {
667
- ...matchResult,
668
- candidates: matchResult.candidates.map(c => ({
669
- ...c,
670
- score: c.matched
671
- ? c.score
672
- : (keywordResult.candidates.find(kc => kc.capabilityId === c.capabilityId)?.score ?? 0),
673
- })),
674
- };
834
+ const top3Balanced = keywordResult.candidates
835
+ .sort((a, b) => b.score - a.score)
836
+ .filter(c => c.score > 0)
837
+ .slice(0, 3)
838
+ .map(c => this.manifest.capabilities.find(cap => cap.id === c.capabilityId))
839
+ .filter(Boolean);
840
+ // Balanced mode only escalates when keyword confidence is low but > 0 —
841
+ // top3 should always be non-empty here, but guard anyway
842
+ if (top3Balanced.length === 0) {
843
+ matchResult = keywordResult;
844
+ }
845
+ else {
846
+ const llmResult = await (0, matcher_1.matchWithLLM)(query, top3Balanced, { llm: this.llm, app: this.manifest.app });
847
+ this.recordLLMSuccess();
848
+ resolvedVia = 'llm';
849
+ // If LLM returns OOS but keyword had a scored candidate, fall back to keyword
850
+ matchResult = llmResult.capability === null ? keywordResult : {
851
+ ...llmResult,
852
+ candidates: llmResult.candidates.map(c => ({
853
+ ...c,
854
+ score: c.matched
855
+ ? c.score
856
+ : (keywordResult.candidates.find(kc => kc.capabilityId === c.capabilityId)?.score ?? 0),
857
+ })),
858
+ };
859
+ }
675
860
  steps?.push({ type: 'llm_match', status: 'pass', durationMs: Date.now() - t2, detail: `confidence: ${matchResult.confidence}%` });
676
861
  }
677
862
  catch (err) {
@@ -687,7 +872,11 @@ class CapmanEngine {
687
872
  break;
688
873
  }
689
874
  }
690
- return { matchResult: matchResult, resolvedVia };
875
+ if (matchResult === undefined) {
876
+ const exhaustive = this.mode;
877
+ throw new Error(`_runMatch: unhandled MatchMode "${exhaustive}"`);
878
+ }
879
+ return { matchResult, resolvedVia };
691
880
  }
692
881
  /**
693
882
  * Applies learning boost to a MatchResult and returns the updated result.
@@ -758,7 +947,15 @@ class CapmanEngine {
758
947
  const hits = wordIndex[candidate.capabilityId] ?? 0;
759
948
  if (hits > 0) {
760
949
  // Logarithmic boost — diminishing returns after first few hits
761
- boost += Math.min(5, Math.log2(hits + 1) * 2);
950
+ const rawBoost = Math.min(5, Math.log2(hits + 1) * 2);
951
+ // IDF weighting — common words ("get", "show", "user") appear in many
952
+ // capabilities and accumulate learning hits that carry little signal.
953
+ // Reuses BM25 df/N so no separate computation is needed.
954
+ const df = this.bm25Index.df[word] ?? 0;
955
+ const idf = df > 0
956
+ ? Math.log((this.bm25Index.N - df + 0.5) / (df + 0.5) + 1)
957
+ : 0;
958
+ boost += rawBoost * Math.min(1, idf);
762
959
  }
763
960
  }
764
961
  const cappedBoost = Math.min(15, Math.round(boost));
@@ -772,10 +969,26 @@ class CapmanEngine {
772
969
  };
773
970
  });
774
971
  }
972
+ /**
973
+ * Resolves the effective baseUrl from manifest.servers[] or EngineOptions.baseUrl.
974
+ * Priority: environment-matched server > first server > explicit baseUrl > undefined
975
+ */
976
+ resolveBaseUrl() {
977
+ const servers = this.manifest.servers;
978
+ if (!servers?.length)
979
+ return this.baseUrl;
980
+ if (this.environment) {
981
+ const match = servers.find(s => s.environment === this.environment);
982
+ if (match)
983
+ return match.url.replace(/\/$/, '');
984
+ }
985
+ // Fallback to first server
986
+ return servers[0].url.replace(/\/$/, '');
987
+ }
775
988
  // ── Private helpers ────────────────────────────────────────────────────────
776
989
  resolveOptions(overrides = {}) {
777
990
  return {
778
- baseUrl: this.baseUrl,
991
+ baseUrl: this.resolveBaseUrl(),
779
992
  auth: this.auth,
780
993
  headers: this.headers,
781
994
  ...overrides,
@@ -795,16 +1008,7 @@ class CapmanEngine {
795
1008
  });
796
1009
  }
797
1010
  calibrateBM25Ceiling() {
798
- let max = 0;
799
- for (const cap of this.manifest.capabilities) {
800
- if (!cap.examples?.length)
801
- continue;
802
- const selfWords = new Set((0, matcher_1.tokenize)(cap.examples[0]));
803
- const raw = (0, matcher_1.scoreCapability)(selfWords, cap, this.bm25Index, this.bm25K1, this.bm25B);
804
- if (raw > max)
805
- max = raw;
806
- }
807
- return max > 0 ? max : 100;
1011
+ return (0, matcher_1.calibrateCeiling)(this.manifest.capabilities, this.bm25Index, this.bm25K1, this.bm25B);
808
1012
  }
809
1013
  /**
810
1014
  * Calibrates the adaptive margin threshold from the manifest's own score
@@ -817,6 +1021,10 @@ class CapmanEngine {
817
1021
  * For manifests with ≤100 capabilities this is negligible (<10ms).
818
1022
  * For very large manifests (500+ capabilities), consider passing
819
1023
  * `adaptiveMarginOverride` to skip calibration.
1024
+ *
1025
+ * Note: constructor total cost also includes BM25 index build O(capabilities × tokens)
1026
+ * and embedding pre-encoding O(capabilities) if an EmbeddingProvider is configured.
1027
+ * For 100 capabilities with embeddings, expect ~100–500ms depending on provider latency.
820
1028
  */
821
1029
  calibrateAdaptiveMargin() {
822
1030
  if (this.manifest.capabilities.length < 2)
@@ -832,10 +1040,14 @@ class CapmanEngine {
832
1040
  for (const cap of this.manifest.capabilities) {
833
1041
  if (!cap.examples?.length)
834
1042
  continue;
835
- const result = (0, matcher_1.match)(cap.examples[0], this.manifest, fuzzyOpts);
836
- const sorted = [...result.candidates].sort((a, b) => b.score - a.score);
837
- if (sorted.length >= 2) {
838
- margins.push(sorted[0].score - sorted[1].score);
1043
+ // Use all examples and take the maximum margin — same rationale as
1044
+ // calibrateBM25Ceiling(): a weak first example skews the calibration.
1045
+ for (const example of cap.examples) {
1046
+ const result = (0, matcher_1.match)(example, this.manifest, fuzzyOpts);
1047
+ const sorted = [...result.candidates].sort((a, b) => b.score - a.score);
1048
+ if (sorted.length >= 2) {
1049
+ margins.push(sorted[0].score - sorted[1].score);
1050
+ }
839
1051
  }
840
1052
  }
841
1053
  if (margins.length === 0)