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.
- package/CODEBASE.md +6 -5
- package/dist/cjs/cache.d.ts +9 -0
- package/dist/cjs/cache.d.ts.map +1 -1
- package/dist/cjs/cache.js +37 -7
- package/dist/cjs/cache.js.map +1 -1
- package/dist/cjs/concurrent.d.ts +53 -0
- package/dist/cjs/concurrent.d.ts.map +1 -0
- package/dist/cjs/concurrent.js +71 -0
- package/dist/cjs/concurrent.js.map +1 -0
- package/dist/cjs/engine.d.ts +92 -7
- package/dist/cjs/engine.d.ts.map +1 -1
- package/dist/cjs/engine.js +269 -57
- package/dist/cjs/engine.js.map +1 -1
- package/dist/cjs/generator.d.ts.map +1 -1
- package/dist/cjs/generator.js +28 -6
- package/dist/cjs/generator.js.map +1 -1
- package/dist/cjs/index.d.ts +3 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/learning.d.ts +16 -1
- package/dist/cjs/learning.d.ts.map +1 -1
- package/dist/cjs/learning.js +95 -14
- package/dist/cjs/learning.js.map +1 -1
- package/dist/cjs/matcher.d.ts +51 -2
- package/dist/cjs/matcher.d.ts.map +1 -1
- package/dist/cjs/matcher.js +173 -33
- package/dist/cjs/matcher.js.map +1 -1
- package/dist/cjs/parser.js +27 -9
- package/dist/cjs/parser.js.map +1 -1
- package/dist/cjs/resolver.d.ts +2 -2
- package/dist/cjs/resolver.d.ts.map +1 -1
- package/dist/cjs/resolver.js +66 -26
- package/dist/cjs/resolver.js.map +1 -1
- package/dist/cjs/schema.d.ts +821 -68
- package/dist/cjs/schema.d.ts.map +1 -1
- package/dist/cjs/schema.js +62 -13
- package/dist/cjs/schema.js.map +1 -1
- package/dist/cjs/types.d.ts +156 -9
- package/dist/cjs/types.d.ts.map +1 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/cache.d.ts +9 -0
- package/dist/esm/cache.js +37 -7
- package/dist/esm/concurrent.d.ts +52 -0
- package/dist/esm/concurrent.js +66 -0
- package/dist/esm/engine.d.ts +92 -7
- package/dist/esm/engine.js +270 -58
- package/dist/esm/generator.js +28 -6
- package/dist/esm/index.d.ts +3 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/learning.d.ts +16 -1
- package/dist/esm/learning.js +95 -14
- package/dist/esm/matcher.d.ts +51 -2
- package/dist/esm/matcher.js +170 -33
- package/dist/esm/parser.js +27 -9
- package/dist/esm/resolver.d.ts +2 -2
- package/dist/esm/resolver.js +66 -26
- package/dist/esm/schema.d.ts +821 -68
- package/dist/esm/schema.js +62 -13
- package/dist/esm/types.d.ts +156 -9
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/dist/cjs/engine.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
|
306
|
-
`
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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)
|