capman 0.5.4 → 0.6.0
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/CHANGELOG.md +46 -0
- package/CODEBASE.md +111 -66
- package/README.md +45 -4
- package/bin/lib/cmd-generate.js +200 -40
- package/bin/lib/cmd-help.js +3 -0
- package/dist/cjs/cache.d.ts.map +1 -1
- package/dist/cjs/cache.js +22 -5
- package/dist/cjs/cache.js.map +1 -1
- package/dist/cjs/engine.d.ts +53 -1
- package/dist/cjs/engine.d.ts.map +1 -1
- package/dist/cjs/engine.js +252 -17
- package/dist/cjs/engine.js.map +1 -1
- package/dist/cjs/generator.d.ts.map +1 -1
- package/dist/cjs/generator.js +7 -1
- package/dist/cjs/generator.js.map +1 -1
- package/dist/cjs/index.d.ts +1 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/learning.d.ts.map +1 -1
- package/dist/cjs/learning.js +51 -30
- package/dist/cjs/learning.js.map +1 -1
- package/dist/cjs/matcher.d.ts +69 -9
- package/dist/cjs/matcher.d.ts.map +1 -1
- package/dist/cjs/matcher.js +328 -43
- package/dist/cjs/matcher.js.map +1 -1
- package/dist/cjs/parser.d.ts.map +1 -1
- package/dist/cjs/parser.js +15 -8
- package/dist/cjs/parser.js.map +1 -1
- package/dist/cjs/resolver.d.ts +1 -0
- package/dist/cjs/resolver.d.ts.map +1 -1
- package/dist/cjs/resolver.js +16 -5
- package/dist/cjs/resolver.js.map +1 -1
- package/dist/cjs/schema.d.ts +64 -46
- package/dist/cjs/schema.d.ts.map +1 -1
- package/dist/cjs/schema.js +2 -1
- package/dist/cjs/schema.js.map +1 -1
- package/dist/cjs/types.d.ts +8 -2
- 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.js +22 -5
- package/dist/esm/engine.d.ts +53 -1
- package/dist/esm/engine.js +255 -20
- package/dist/esm/generator.js +7 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/learning.js +52 -31
- package/dist/esm/matcher.d.ts +69 -9
- package/dist/esm/matcher.js +321 -42
- package/dist/esm/parser.js +15 -8
- package/dist/esm/resolver.d.ts +1 -0
- package/dist/esm/resolver.js +16 -6
- package/dist/esm/schema.d.ts +64 -46
- package/dist/esm/schema.js +2 -1
- package/dist/esm/types.d.ts +8 -2
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/dist/esm/engine.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { match as _match, matchWithLLM as _matchWithLLM, resolverToIntent, extractParams,
|
|
2
|
-
import { resolve as _resolve } from './resolver';
|
|
1
|
+
import { match as _match, matchWithLLM as _matchWithLLM, resolverToIntent, extractParams, LLMParseError, tokenize, buildBM25Index, scoreCapability as _scoreCapability, sanitizeForPrompt } from './matcher';
|
|
2
|
+
import { resolve as _resolve, checkPrivacy } from './resolver';
|
|
3
3
|
import { MemoryLearningStore } from './learning';
|
|
4
4
|
import { logger } from './logger';
|
|
5
|
-
import { MemoryCache, normalizeQuery } from './cache';
|
|
5
|
+
import { MemoryCache, normalizeQuery, buildCacheKey } from './cache';
|
|
6
6
|
import { VERSION } from './version';
|
|
7
7
|
// ─── CapmanEngine ─────────────────────────────────────────────────────────────
|
|
8
8
|
export class CapmanEngine {
|
|
@@ -27,6 +27,12 @@ export class CapmanEngine {
|
|
|
27
27
|
this.llmCircuitBreakerResetMs = options.llmCircuitBreakerResetMs ?? 60_000;
|
|
28
28
|
this.fuzzyMatch = options.fuzzyMatch ?? false;
|
|
29
29
|
this.fuzzyThreshold = options.fuzzyThreshold ?? 0.4;
|
|
30
|
+
this.bm25K1 = options.bm25K1 ?? 1.5;
|
|
31
|
+
this.bm25B = options.bm25B ?? 0.75;
|
|
32
|
+
this.bm25Index = buildBM25Index(options.manifest.capabilities);
|
|
33
|
+
this.bm25Ceiling = this.calibrateBM25Ceiling();
|
|
34
|
+
this.marginAwareLLM = options.marginAwareLLM ?? false;
|
|
35
|
+
this.adaptiveMargin = options.adaptiveMarginOverride ?? this.calibrateAdaptiveMargin();
|
|
30
36
|
// Cache — default MemoryCache (no filesystem writes), or disabled with false
|
|
31
37
|
// Use FileCache or ComboCache explicitly for persistence across restarts
|
|
32
38
|
this.cache = options.cache === false
|
|
@@ -90,12 +96,16 @@ export class CapmanEngine {
|
|
|
90
96
|
resolvedVia: 'cache',
|
|
91
97
|
totalMs: Date.now() - start,
|
|
92
98
|
};
|
|
99
|
+
const { verdict: cacheVerdict, margin: cacheMargin } = this.computeVerdict(matchWithFreshParams);
|
|
93
100
|
const result = {
|
|
94
101
|
match: matchWithFreshParams,
|
|
95
102
|
resolution,
|
|
96
103
|
resolvedVia: 'cache',
|
|
97
104
|
durationMs: Date.now() - start,
|
|
98
105
|
trace,
|
|
106
|
+
verdict: cacheVerdict,
|
|
107
|
+
margin: cacheMargin,
|
|
108
|
+
missingParams: undefined
|
|
99
109
|
};
|
|
100
110
|
await this.recordLearning(query, matchWithFreshParams, 'cache');
|
|
101
111
|
return result;
|
|
@@ -107,20 +117,35 @@ export class CapmanEngine {
|
|
|
107
117
|
}
|
|
108
118
|
// ── Step 2: Match ────────────────────────────────────────────────────────
|
|
109
119
|
let { matchResult, resolvedVia } = await this._runMatch(query, steps);
|
|
110
|
-
|
|
120
|
+
// Shallow copy with candidates slice — not a reference alias.
|
|
121
|
+
// applyBoostToMatchResult() returns a new object today, but an explicit
|
|
122
|
+
// copy makes the invariant clear and safe against future in-place mutation.
|
|
123
|
+
const preBoostMatchResult = { ...matchResult, candidates: matchResult.candidates.slice() };
|
|
111
124
|
// ── Step 2.5: Apply learning boost ───────────────────────────────────────
|
|
112
|
-
matchResult = await this.applyBoostToMatchResult(query, matchResult);
|
|
125
|
+
matchResult = await this.applyBoostToMatchResult(query, matchResult, resolvedVia);
|
|
113
126
|
// ── Step 3: Privacy check ────────────────────────────────────────────────
|
|
114
127
|
if (matchResult.capability) {
|
|
115
|
-
const
|
|
128
|
+
const privacyError = checkPrivacy(matchResult.capability, this.auth);
|
|
116
129
|
steps.push({
|
|
117
130
|
type: 'privacy_check',
|
|
118
|
-
status: 'pass',
|
|
131
|
+
status: privacyError ? 'fail' : 'pass',
|
|
119
132
|
durationMs: 0,
|
|
120
|
-
detail: `level: ${
|
|
133
|
+
detail: privacyError ?? `level: ${matchResult.capability.privacy.level}`,
|
|
121
134
|
});
|
|
122
135
|
}
|
|
123
|
-
// ── Step
|
|
136
|
+
// ── Step 4a: Compute verdict + optional margin-aware LLM disambiguation ──
|
|
137
|
+
let { verdict, margin } = this.computeVerdict(matchResult);
|
|
138
|
+
if (verdict === 'marginal' &&
|
|
139
|
+
this.marginAwareLLM &&
|
|
140
|
+
this.llm &&
|
|
141
|
+
this.mode === 'balanced') {
|
|
142
|
+
matchResult = await this.disambiguateLLM(query, matchResult, steps);
|
|
143
|
+
// Recompute verdict after disambiguation
|
|
144
|
+
const recomputed = this.computeVerdict(matchResult);
|
|
145
|
+
verdict = recomputed.verdict;
|
|
146
|
+
margin = recomputed.margin;
|
|
147
|
+
}
|
|
148
|
+
// ── Step 4b: Resolve ──────────────────────────────────────────────────────
|
|
124
149
|
const resolveStart = Date.now();
|
|
125
150
|
const resolution = await _resolve(matchResult, matchResult.extractedParams, this.resolveOptions(overrides));
|
|
126
151
|
steps.push({
|
|
@@ -130,14 +155,68 @@ export class CapmanEngine {
|
|
|
130
155
|
detail: resolution.error ?? `via ${resolution.resolverType}`,
|
|
131
156
|
});
|
|
132
157
|
// ── Step 5: Cache after successful resolution ────────────────────────────
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
158
|
+
// Write under two keys:
|
|
159
|
+
// 1. normalizeQuery — exact phrasing lookup for this query
|
|
160
|
+
// 2. buildCacheKey — semantic key (capability + params) so differently-phrased
|
|
161
|
+
// queries that resolve to the same capability share a cache entry
|
|
137
162
|
if (this.cache && resolution.success && matchResult.capability
|
|
138
163
|
&& matchResult.capability.privacy.level === 'public') {
|
|
139
164
|
const queryKey = normalizeQuery(query);
|
|
165
|
+
const capKey = buildCacheKey(query, matchResult.capability.id, matchResult.extractedParams);
|
|
140
166
|
await this.cache.set(queryKey, matchResult);
|
|
167
|
+
await this.cache.set(capKey, matchResult);
|
|
168
|
+
// capKey always starts with 'cap:' — structurally distinct from queryKey
|
|
169
|
+
}
|
|
170
|
+
// ── Step 5b: Compute missingParams ───────────────────────────────────────
|
|
171
|
+
// Spec: LLM attempts extraction first when available. missingParams is last resort.
|
|
172
|
+
let missingParams;
|
|
173
|
+
if (matchResult.capability && resolvedVia !== 'llm') {
|
|
174
|
+
const cap = matchResult.capability;
|
|
175
|
+
const unresolved = cap.params.filter(p => p.source === 'user_query' && p.required
|
|
176
|
+
&& matchResult.extractedParams[p.name] === null);
|
|
177
|
+
if (unresolved.length > 0 && this.llm && this.mode !== 'cheap') {
|
|
178
|
+
// LLM available — attempt targeted param extraction before declaring incomplete
|
|
179
|
+
const skipReason = this.checkLLMAllowed();
|
|
180
|
+
if (!skipReason) {
|
|
181
|
+
try {
|
|
182
|
+
const paramExtractionStart = Date.now();
|
|
183
|
+
const paramDescriptions = unresolved
|
|
184
|
+
.map(p => `- ${p.name}: ${p.description}`)
|
|
185
|
+
.join('\n');
|
|
186
|
+
const paramPrompt = `Extract the following parameters from this user query.\n` +
|
|
187
|
+
`Query: ${JSON.stringify({ user_query: query })}\n\n` +
|
|
188
|
+
`Parameters to extract:\n${paramDescriptions}\n\n` +
|
|
189
|
+
`Respond ONLY with valid JSON: { "params": { "<name>": "<value or null>" } }`;
|
|
190
|
+
const raw = await this.llm(paramPrompt);
|
|
191
|
+
const clean = raw.replace(/```json|```/g, '').trim();
|
|
192
|
+
const parsed = JSON.parse(clean);
|
|
193
|
+
this.recordLLMSuccess();
|
|
194
|
+
steps.push({
|
|
195
|
+
type: 'llm_match',
|
|
196
|
+
status: 'pass',
|
|
197
|
+
durationMs: Date.now() - paramExtractionStart,
|
|
198
|
+
detail: `param extraction: ${unresolved.map(p => p.name).join(', ')}`,
|
|
199
|
+
});
|
|
200
|
+
// Merge LLM-extracted values — validate type before accepting
|
|
201
|
+
for (const p of unresolved) {
|
|
202
|
+
const val = parsed?.params?.[p.name];
|
|
203
|
+
if (val && typeof val === 'string' && val.trim().length > 0) {
|
|
204
|
+
matchResult.extractedParams[p.name] = val.trim();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// LLM param extraction failed — fall through to missingParams below
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// After LLM attempt (or if skipped/unavailable), report what's still missing
|
|
214
|
+
const stillMissing = cap.params
|
|
215
|
+
.filter(p => p.source === 'user_query' && p.required
|
|
216
|
+
&& matchResult.extractedParams[p.name] === null)
|
|
217
|
+
.map(p => p.name);
|
|
218
|
+
if (stillMissing.length > 0)
|
|
219
|
+
missingParams = stillMissing;
|
|
141
220
|
}
|
|
142
221
|
// ── Step 6: Build reasoning array ────────────────────────────────────────
|
|
143
222
|
const reasoning = [];
|
|
@@ -183,6 +262,9 @@ export class CapmanEngine {
|
|
|
183
262
|
resolvedVia,
|
|
184
263
|
durationMs: Date.now() - start,
|
|
185
264
|
trace,
|
|
265
|
+
verdict,
|
|
266
|
+
margin,
|
|
267
|
+
missingParams,
|
|
186
268
|
};
|
|
187
269
|
}
|
|
188
270
|
/**
|
|
@@ -242,6 +324,9 @@ export class CapmanEngine {
|
|
|
242
324
|
async loadManifest(manifest) {
|
|
243
325
|
this.checkManifestVersion(manifest);
|
|
244
326
|
this.manifest = manifest;
|
|
327
|
+
this.bm25Index = buildBM25Index(manifest.capabilities);
|
|
328
|
+
this.bm25Ceiling = this.calibrateBM25Ceiling();
|
|
329
|
+
this.adaptiveMargin = this.calibrateAdaptiveMargin();
|
|
245
330
|
await this.clearCache();
|
|
246
331
|
}
|
|
247
332
|
/**
|
|
@@ -279,8 +364,10 @@ export class CapmanEngine {
|
|
|
279
364
|
}
|
|
280
365
|
let resolvedVia = _resolvedVia;
|
|
281
366
|
// ── Apply learning boost (same as ask()) ─────────────────────────────────
|
|
282
|
-
matchResult = await this.applyBoostToMatchResult(query, matchResult);
|
|
367
|
+
matchResult = await this.applyBoostToMatchResult(query, matchResult, resolvedVia);
|
|
283
368
|
// ── Build candidate explanations ─────────────────────────────────────────
|
|
369
|
+
const qTokens = tokenize(query);
|
|
370
|
+
const qWordSet = new Set(qTokens);
|
|
284
371
|
const candidates = matchResult.candidates
|
|
285
372
|
.sort((a, b) => b.score - a.score)
|
|
286
373
|
.map(c => {
|
|
@@ -293,10 +380,9 @@ export class CapmanEngine {
|
|
|
293
380
|
explanation = `Strong match (${c.score}%) — query closely matches examples`;
|
|
294
381
|
}
|
|
295
382
|
else if (c.score >= 50) {
|
|
296
|
-
const qWords = query.toLowerCase().split(/\W+/).filter(Boolean);
|
|
297
383
|
const matchedWords = (cap?.examples ?? [])
|
|
298
|
-
.flatMap(e => e
|
|
299
|
-
.filter(w =>
|
|
384
|
+
.flatMap(e => tokenize(e))
|
|
385
|
+
.filter(w => qWordSet.has(w));
|
|
300
386
|
const unique = [...new Set(matchedWords)].slice(0, 3);
|
|
301
387
|
explanation = unique.length
|
|
302
388
|
? `Matched keywords: ${unique.join(', ')} (${c.score}%)`
|
|
@@ -442,8 +528,10 @@ export class CapmanEngine {
|
|
|
442
528
|
this.llmCallsThisMinute = 0;
|
|
443
529
|
this.llmWindowStart = now;
|
|
444
530
|
}
|
|
531
|
+
if (this.maxLLMCallsPerMinute === 0) {
|
|
532
|
+
return 'LLM disabled — maxLLMCallsPerMinute is 0';
|
|
533
|
+
}
|
|
445
534
|
if (this.llmCallsThisMinute >= this.maxLLMCallsPerMinute) {
|
|
446
|
-
// Recalculate elapsed after possible window reset above
|
|
447
535
|
const resetIn = Math.ceil((60_000 - (now - this.llmWindowStart)) / 1000);
|
|
448
536
|
return `rate limit reached (${this.maxLLMCallsPerMinute}/min) — resets in ${Math.max(0, resetIn)}s`;
|
|
449
537
|
}
|
|
@@ -462,6 +550,10 @@ export class CapmanEngine {
|
|
|
462
550
|
* Records a failed LLM call — may open the circuit breaker.
|
|
463
551
|
*/
|
|
464
552
|
recordLLMFailure() {
|
|
553
|
+
// Refund the rate-limit slot — the call failed so it shouldn't count
|
|
554
|
+
// against the per-minute quota. Without this, sustained failures
|
|
555
|
+
// exhaust the limit prematurely and silently degrade to keyword-only.
|
|
556
|
+
this.llmCallsThisMinute = Math.max(0, this.llmCallsThisMinute - 1);
|
|
465
557
|
this.llmConsecutiveFails++;
|
|
466
558
|
if (this.llmConsecutiveFails >= this.llmCircuitBreakerThreshold) {
|
|
467
559
|
this.llmCircuitOpenAt = Date.now();
|
|
@@ -480,6 +572,10 @@ export class CapmanEngine {
|
|
|
480
572
|
const fuzzyOpts = {
|
|
481
573
|
fuzzyMatch: this.fuzzyMatch,
|
|
482
574
|
fuzzyThreshold: this.fuzzyThreshold,
|
|
575
|
+
bm25Index: this.bm25Index,
|
|
576
|
+
bm25Ceiling: this.bm25Ceiling,
|
|
577
|
+
bm25K1: this.bm25K1,
|
|
578
|
+
bm25B: this.bm25B,
|
|
483
579
|
};
|
|
484
580
|
switch (this.mode) {
|
|
485
581
|
case 'cheap': {
|
|
@@ -490,6 +586,8 @@ export class CapmanEngine {
|
|
|
490
586
|
}
|
|
491
587
|
case 'accurate': {
|
|
492
588
|
if (this.llm) {
|
|
589
|
+
// Rate limiter shared between ask() and explain() — explain() counts
|
|
590
|
+
// against the same quota since it makes real LLM calls.
|
|
493
591
|
const skipReason = this.checkLLMAllowed();
|
|
494
592
|
if (skipReason) {
|
|
495
593
|
logger.warn(`LLM skipped — ${skipReason} — falling back to keyword`);
|
|
@@ -545,6 +643,8 @@ export class CapmanEngine {
|
|
|
545
643
|
matchResult = keywordResult;
|
|
546
644
|
}
|
|
547
645
|
else {
|
|
646
|
+
// Rate limiter shared between ask() and explain() — explain() counts
|
|
647
|
+
// against the same quota since it makes real LLM calls.
|
|
548
648
|
const skipReason = this.checkLLMAllowed();
|
|
549
649
|
if (skipReason) {
|
|
550
650
|
logger.warn(`LLM skipped — ${skipReason}`);
|
|
@@ -590,7 +690,12 @@ export class CapmanEngine {
|
|
|
590
690
|
* Applies learning boost to a MatchResult and returns the updated result.
|
|
591
691
|
* Shared by ask() and explain() to avoid logic divergence.
|
|
592
692
|
*/
|
|
593
|
-
async applyBoostToMatchResult(query, matchResult) {
|
|
693
|
+
async applyBoostToMatchResult(query, matchResult, resolvedVia = 'keyword') {
|
|
694
|
+
// Skip boost when LLM matched with high confidence — learning signal is
|
|
695
|
+
// less reliable than a strong LLM result and could incorrectly override it.
|
|
696
|
+
// Threshold 80% leaves room for boost to help on borderline LLM matches.
|
|
697
|
+
if (resolvedVia === 'llm' && matchResult.confidence > 80)
|
|
698
|
+
return matchResult;
|
|
594
699
|
const hasKeywordSignal = matchResult.candidates.some(c => c.score > 0);
|
|
595
700
|
if (!hasKeywordSignal || matchResult.candidates.length === 0 || !this.learning || this.mode === 'cheap') {
|
|
596
701
|
return matchResult;
|
|
@@ -638,7 +743,7 @@ export class CapmanEngine {
|
|
|
638
743
|
const stats = await this.learning.getStats();
|
|
639
744
|
if (!stats || Object.keys(stats.index).length === 0)
|
|
640
745
|
return candidates;
|
|
641
|
-
const qWords = query
|
|
746
|
+
const qWords = tokenize(query);
|
|
642
747
|
if (qWords.length === 0)
|
|
643
748
|
return candidates;
|
|
644
749
|
return candidates.map(candidate => {
|
|
@@ -686,6 +791,136 @@ export class CapmanEngine {
|
|
|
686
791
|
timestamp: new Date().toISOString(),
|
|
687
792
|
});
|
|
688
793
|
}
|
|
794
|
+
calibrateBM25Ceiling() {
|
|
795
|
+
let max = 0;
|
|
796
|
+
for (const cap of this.manifest.capabilities) {
|
|
797
|
+
if (!cap.examples?.length)
|
|
798
|
+
continue;
|
|
799
|
+
const selfWords = new Set(tokenize(cap.examples[0]));
|
|
800
|
+
const raw = _scoreCapability(selfWords, cap, this.bm25Index, this.bm25K1, this.bm25B);
|
|
801
|
+
if (raw > max)
|
|
802
|
+
max = raw;
|
|
803
|
+
}
|
|
804
|
+
return max > 0 ? max : 100;
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Calibrates the adaptive margin threshold from the manifest's own score
|
|
808
|
+
* distribution. Runs each capability's first example against all other
|
|
809
|
+
* capabilities to find the typical inter-capability score spread.
|
|
810
|
+
* Dense overlapping vocabulary → lower margin (harder to separate).
|
|
811
|
+
* Sparse vocabulary → higher margin (easier to separate).
|
|
812
|
+
*
|
|
813
|
+
* Complexity: O(capabilities²) — runs at constructor time and on loadManifest().
|
|
814
|
+
* For manifests with ≤100 capabilities this is negligible (<10ms).
|
|
815
|
+
* For very large manifests (500+ capabilities), consider passing
|
|
816
|
+
* `adaptiveMarginOverride` to skip calibration.
|
|
817
|
+
*/
|
|
818
|
+
calibrateAdaptiveMargin() {
|
|
819
|
+
if (this.manifest.capabilities.length < 2)
|
|
820
|
+
return 20;
|
|
821
|
+
const margins = [];
|
|
822
|
+
const fuzzyOpts = {
|
|
823
|
+
fuzzyMatch: false, // calibration uses keyword only — deterministic
|
|
824
|
+
bm25Index: this.bm25Index,
|
|
825
|
+
bm25Ceiling: this.bm25Ceiling,
|
|
826
|
+
bm25K1: this.bm25K1,
|
|
827
|
+
bm25B: this.bm25B,
|
|
828
|
+
};
|
|
829
|
+
for (const cap of this.manifest.capabilities) {
|
|
830
|
+
if (!cap.examples?.length)
|
|
831
|
+
continue;
|
|
832
|
+
const result = _match(cap.examples[0], this.manifest, fuzzyOpts);
|
|
833
|
+
const sorted = [...result.candidates].sort((a, b) => b.score - a.score);
|
|
834
|
+
if (sorted.length >= 2) {
|
|
835
|
+
margins.push(sorted[0].score - sorted[1].score);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
if (margins.length === 0)
|
|
839
|
+
return 20;
|
|
840
|
+
// Use 25th percentile of margins as the threshold — manifests where
|
|
841
|
+
// capabilities are naturally close together get a tighter threshold
|
|
842
|
+
margins.sort((a, b) => a - b);
|
|
843
|
+
const p25 = margins[Math.floor(margins.length * 0.25)];
|
|
844
|
+
return Math.max(10, Math.min(30, Math.round(p25 * 0.6)));
|
|
845
|
+
}
|
|
846
|
+
computeVerdict(matchResult) {
|
|
847
|
+
if (!matchResult.capability)
|
|
848
|
+
return { verdict: 'uncertain', margin: 0 };
|
|
849
|
+
const sorted = [...matchResult.candidates].sort((a, b) => b.score - a.score);
|
|
850
|
+
const best = sorted[0]?.score ?? 0;
|
|
851
|
+
const second = sorted[1]?.score ?? 0;
|
|
852
|
+
const margin = best - second;
|
|
853
|
+
if (best < 60)
|
|
854
|
+
return { verdict: 'uncertain', margin };
|
|
855
|
+
if (margin < this.adaptiveMargin)
|
|
856
|
+
return { verdict: 'marginal', margin };
|
|
857
|
+
return { verdict: 'clear', margin };
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Targeted disambiguation between top-2 candidates.
|
|
861
|
+
* Sends ~200 tokens instead of full manifest (~4000 tokens) — 93% cost reduction.
|
|
862
|
+
* Returns updated matchResult with LLM-preferred winner, or original on failure.
|
|
863
|
+
*/
|
|
864
|
+
async disambiguateLLM(query, matchResult, steps) {
|
|
865
|
+
if (!this.llm)
|
|
866
|
+
return matchResult;
|
|
867
|
+
const sorted = [...matchResult.candidates]
|
|
868
|
+
.sort((a, b) => b.score - a.score)
|
|
869
|
+
.slice(0, 2);
|
|
870
|
+
if (sorted.length < 2)
|
|
871
|
+
return matchResult;
|
|
872
|
+
const capA = this.manifest.capabilities.find(c => c.id === sorted[0].capabilityId);
|
|
873
|
+
const capB = this.manifest.capabilities.find(c => c.id === sorted[1].capabilityId);
|
|
874
|
+
if (!capA || !capB)
|
|
875
|
+
return matchResult;
|
|
876
|
+
const skipReason = this.checkLLMAllowed();
|
|
877
|
+
if (skipReason) {
|
|
878
|
+
logger.warn(`Disambiguation LLM skipped — ${skipReason}`);
|
|
879
|
+
steps.push({ type: 'llm_match', status: 'skip', durationMs: 0, detail: `disambiguation skipped: ${skipReason}` });
|
|
880
|
+
return matchResult;
|
|
881
|
+
}
|
|
882
|
+
const prompt = `Two capabilities are close matches for this query. Pick the best one.
|
|
883
|
+
|
|
884
|
+
Query: ${JSON.stringify({ user_query: query })}
|
|
885
|
+
|
|
886
|
+
Option A: ${capA.id} — ${sanitizeForPrompt(capA.description, 150)}
|
|
887
|
+
Option B: ${capB.id} — ${sanitizeForPrompt(capB.description, 150)}
|
|
888
|
+
|
|
889
|
+
Respond ONLY with valid JSON:
|
|
890
|
+
{ "winner": "<capability_id>", "confidence": <0-100>, "reasoning": "<one sentence>" }`;
|
|
891
|
+
const t = Date.now();
|
|
892
|
+
try {
|
|
893
|
+
const raw = await this.llm(prompt);
|
|
894
|
+
const clean = raw.replace(/```json|```/g, '').trim();
|
|
895
|
+
const parsed = JSON.parse(clean);
|
|
896
|
+
this.recordLLMSuccess();
|
|
897
|
+
const winner = this.manifest.capabilities.find(c => c.id === parsed.winner);
|
|
898
|
+
if (!winner) {
|
|
899
|
+
steps.push({ type: 'llm_match', status: 'fail', durationMs: Date.now() - t, detail: 'disambiguation returned unknown id' });
|
|
900
|
+
return matchResult;
|
|
901
|
+
}
|
|
902
|
+
steps.push({ type: 'llm_match', status: 'pass', durationMs: Date.now() - t, detail: `disambiguation: ${winner.id} (${parsed.confidence}%)` });
|
|
903
|
+
const confidence = typeof parsed.confidence === 'number' && !isNaN(parsed.confidence)
|
|
904
|
+
? Math.min(100, Math.max(0, Math.round(parsed.confidence)))
|
|
905
|
+
: matchResult.confidence; // fallback to original if LLM returned bad value
|
|
906
|
+
return {
|
|
907
|
+
...matchResult,
|
|
908
|
+
capability: winner,
|
|
909
|
+
confidence,
|
|
910
|
+
intent: resolverToIntent(winner),
|
|
911
|
+
extractedParams: extractParams(query, winner),
|
|
912
|
+
candidates: matchResult.candidates.map(c => ({ ...c, matched: c.capabilityId === winner.id })),
|
|
913
|
+
reasoning: parsed.reasoning ?? `Disambiguated to "${winner.id}"`,
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
catch (err) {
|
|
917
|
+
const isParseError = err instanceof LLMParseError;
|
|
918
|
+
if (!isParseError)
|
|
919
|
+
this.recordLLMFailure();
|
|
920
|
+
steps.push({ type: 'llm_match', status: 'fail', durationMs: Date.now() - t, detail: String(err) });
|
|
921
|
+
return matchResult;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
689
924
|
}
|
|
690
925
|
/** Maximum allowed query length in characters. Queries exceeding this throw RangeError. */
|
|
691
926
|
CapmanEngine.MAX_QUERY_LENGTH = 1000;
|
package/dist/esm/generator.js
CHANGED
|
@@ -73,7 +73,13 @@ export function loadConfig(configPath) {
|
|
|
73
73
|
`Run: node bin/capman.js init to create one.`);
|
|
74
74
|
}
|
|
75
75
|
export function writeManifest(manifest, outputPath = 'manifest.json') {
|
|
76
|
-
const
|
|
76
|
+
const cwd = process.cwd();
|
|
77
|
+
const resolved = path.resolve(cwd, outputPath);
|
|
78
|
+
const allowedPrefix = cwd === '/' ? '/' : cwd + path.sep;
|
|
79
|
+
if (!resolved.startsWith(allowedPrefix)) {
|
|
80
|
+
throw new Error(`writeManifest: output path "${outputPath}" resolves outside the working directory.\n` +
|
|
81
|
+
`Resolved: ${resolved}\nAllowed: ${cwd}`);
|
|
82
|
+
}
|
|
77
83
|
fs.writeFileSync(resolved, JSON.stringify(manifest, null, 2));
|
|
78
84
|
return resolved;
|
|
79
85
|
}
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export { generate, loadConfig, writeManifest, readManifest, validate, generateSt
|
|
|
5
5
|
export { match, matchWithLLM, extractParams, } from './matcher';
|
|
6
6
|
export { LLMParseError } from './matcher';
|
|
7
7
|
export type { LLMMatcherOptions } from './matcher';
|
|
8
|
+
export { TYPE_PATTERNS } from './matcher';
|
|
8
9
|
export { resolve } from './resolver';
|
|
9
10
|
export type { ResolveOptions, AuthContext } from './resolver';
|
|
10
11
|
export { CapmanEngine } from './engine';
|
package/dist/esm/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export { setLogLevel } from './logger';
|
|
|
2
2
|
export { generate, loadConfig, writeManifest, readManifest, validate, generateStarterConfig, } from './generator';
|
|
3
3
|
export { match, matchWithLLM, extractParams, } from './matcher';
|
|
4
4
|
export { LLMParseError } from './matcher';
|
|
5
|
+
export { TYPE_PATTERNS } from './matcher';
|
|
5
6
|
export { resolve } from './resolver';
|
|
6
7
|
// ─── Engine (recommended API) ─────────────────────────────────────────────────
|
|
7
8
|
export { CapmanEngine } from './engine';
|
package/dist/esm/learning.js
CHANGED
|
@@ -2,16 +2,42 @@ import * as fs from 'fs';
|
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { logger } from './logger';
|
|
4
4
|
const MAX_LEARNING_ENTRIES = 10_000;
|
|
5
|
-
import {
|
|
5
|
+
import { tokenize } from './matcher';
|
|
6
6
|
// Module-level registry — tracks all active FileLearningStore instances
|
|
7
7
|
// for process exit flushing. Handlers registered once to avoid accumulation.
|
|
8
8
|
const activeStores = new Set();
|
|
9
|
-
|
|
9
|
+
// Module-level handler references — stored so they can be removed
|
|
10
|
+
// when all stores are destroyed. Never call process.exit() in a library.
|
|
11
|
+
let exitHandler = null;
|
|
12
|
+
let sigTermHandler = null;
|
|
13
|
+
let sigIntHandler = null;
|
|
10
14
|
function flushAllStores() {
|
|
11
15
|
for (const store of activeStores) {
|
|
12
16
|
store.flushSync();
|
|
13
17
|
}
|
|
14
18
|
}
|
|
19
|
+
function registerExitHandlers() {
|
|
20
|
+
if (exitHandler)
|
|
21
|
+
return; // already registered
|
|
22
|
+
exitHandler = flushAllStores;
|
|
23
|
+
sigTermHandler = flushAllStores;
|
|
24
|
+
sigIntHandler = flushAllStores;
|
|
25
|
+
process.on('exit', exitHandler);
|
|
26
|
+
process.on('SIGTERM', sigTermHandler);
|
|
27
|
+
process.on('SIGINT', sigIntHandler);
|
|
28
|
+
}
|
|
29
|
+
function unregisterExitHandlers() {
|
|
30
|
+
if (!exitHandler)
|
|
31
|
+
return; // nothing registered
|
|
32
|
+
if (activeStores.size > 0)
|
|
33
|
+
return; // other stores still active
|
|
34
|
+
process.off('exit', exitHandler);
|
|
35
|
+
process.off('SIGTERM', sigTermHandler);
|
|
36
|
+
process.off('SIGINT', sigIntHandler);
|
|
37
|
+
exitHandler = null;
|
|
38
|
+
sigTermHandler = null;
|
|
39
|
+
sigIntHandler = null;
|
|
40
|
+
}
|
|
15
41
|
// ─── Shared computation helpers ───────────────────────────────────────────────
|
|
16
42
|
function computeTopCapabilities(entries, limit) {
|
|
17
43
|
const counts = {};
|
|
@@ -45,13 +71,15 @@ class LearningIndex {
|
|
|
45
71
|
if (!entry.capabilityId)
|
|
46
72
|
this.statsCounter.outOfScope++;
|
|
47
73
|
if (entry.capabilityId) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
74
|
+
// Confidence-weighted contribution — a 95% match contributes 9.5×
|
|
75
|
+
// more signal than a 51% borderline match. Floor of 0.1 ensures
|
|
76
|
+
// borderline matches still contribute, just proportionally less.
|
|
77
|
+
const weight = Math.max(0.1, entry.confidence / 100);
|
|
78
|
+
const words = tokenize(entry.query);
|
|
51
79
|
for (const word of words) {
|
|
52
80
|
this.index[word] ??= {};
|
|
53
81
|
this.index[word][entry.capabilityId] =
|
|
54
|
-
(this.index[word][entry.capabilityId] ?? 0) +
|
|
82
|
+
(this.index[word][entry.capabilityId] ?? 0) + weight;
|
|
55
83
|
}
|
|
56
84
|
}
|
|
57
85
|
}
|
|
@@ -67,14 +95,14 @@ class LearningIndex {
|
|
|
67
95
|
return;
|
|
68
96
|
}
|
|
69
97
|
// Keyword index cleanup
|
|
70
|
-
const words = entry.query
|
|
71
|
-
.split(/\W+/)
|
|
72
|
-
.filter(w => w.length > 2 && !STOPWORDS.has(w));
|
|
98
|
+
const words = tokenize(entry.query);
|
|
73
99
|
for (const word of words) {
|
|
74
100
|
if (!this.index[word])
|
|
75
101
|
continue;
|
|
102
|
+
// Subtract estimated weight (0.5 average) — exact weight not stored.
|
|
103
|
+
// Minor drift on prune is acceptable; index is rebuilt when drift matters.
|
|
76
104
|
this.index[word][entry.capabilityId] =
|
|
77
|
-
(this.index[word][entry.capabilityId] ??
|
|
105
|
+
(this.index[word][entry.capabilityId] ?? 0.5) - 0.5;
|
|
78
106
|
if (this.index[word][entry.capabilityId] <= 0) {
|
|
79
107
|
delete this.index[word][entry.capabilityId];
|
|
80
108
|
}
|
|
@@ -120,12 +148,7 @@ export class FileLearningStore {
|
|
|
120
148
|
this.filePath = resolved;
|
|
121
149
|
logger.info(`FileLearningStore initialized — writing to: ${this.filePath}`);
|
|
122
150
|
activeStores.add(this);
|
|
123
|
-
|
|
124
|
-
exitHandlersRegistered = true;
|
|
125
|
-
process.on('exit', flushAllStores);
|
|
126
|
-
process.on('SIGTERM', () => { flushAllStores(); process.exit(0); });
|
|
127
|
-
process.on('SIGINT', () => { flushAllStores(); process.exit(0); });
|
|
128
|
-
}
|
|
151
|
+
registerExitHandlers();
|
|
129
152
|
}
|
|
130
153
|
flushSync() {
|
|
131
154
|
// Cancel pending timer — prevents scheduleSave firing after sync write
|
|
@@ -160,15 +183,17 @@ export class FileLearningStore {
|
|
|
160
183
|
}
|
|
161
184
|
if (this.dirty) {
|
|
162
185
|
this.dirty = false;
|
|
163
|
-
// Await final flush before removing from registry —
|
|
164
|
-
// ensures data is written before the store becomes unreachable
|
|
165
186
|
await this.save();
|
|
166
187
|
}
|
|
167
188
|
activeStores.delete(this);
|
|
189
|
+
unregisterExitHandlers(); // remove handlers if no stores remain
|
|
168
190
|
}
|
|
169
191
|
load() {
|
|
170
192
|
if (!this.loadPromise) {
|
|
171
|
-
this.loadPromise = this._doLoad()
|
|
193
|
+
this.loadPromise = this._doLoad().catch(err => {
|
|
194
|
+
this.loadPromise = null; // allow retry on next call
|
|
195
|
+
throw err;
|
|
196
|
+
});
|
|
172
197
|
}
|
|
173
198
|
return this.loadPromise;
|
|
174
199
|
}
|
|
@@ -185,8 +210,12 @@ export class FileLearningStore {
|
|
|
185
210
|
logger.warn(`Learning store at ${this.filePath} contained unexpected format — starting fresh`);
|
|
186
211
|
}
|
|
187
212
|
}
|
|
188
|
-
catch {
|
|
189
|
-
|
|
213
|
+
catch (err) {
|
|
214
|
+
const code = err.code;
|
|
215
|
+
if (code !== 'ENOENT') {
|
|
216
|
+
logger.warn(`Failed to load learning store from ${this.filePath} (${code ?? 'unknown error'}) — starting fresh`);
|
|
217
|
+
}
|
|
218
|
+
// ENOENT = file doesn't exist yet — expected on first run, no warning needed
|
|
190
219
|
}
|
|
191
220
|
}
|
|
192
221
|
scheduleSave(urgencyMs = 5_000) {
|
|
@@ -228,11 +257,7 @@ export class FileLearningStore {
|
|
|
228
257
|
// not be persisted to disk under GDPR/CCPA data retention requirements.
|
|
229
258
|
const sanitized = {
|
|
230
259
|
...entry,
|
|
231
|
-
query: entry.query
|
|
232
|
-
.toLowerCase()
|
|
233
|
-
.split(/\W+/)
|
|
234
|
-
.filter(w => w.length > 2 && !STOPWORDS.has(w))
|
|
235
|
-
.join(' '),
|
|
260
|
+
query: tokenize(entry.query).join(' '),
|
|
236
261
|
};
|
|
237
262
|
this.entries.push(sanitized);
|
|
238
263
|
this.learningIndex.update(sanitized);
|
|
@@ -281,11 +306,7 @@ export class MemoryLearningStore {
|
|
|
281
306
|
async record(entry) {
|
|
282
307
|
const sanitized = {
|
|
283
308
|
...entry,
|
|
284
|
-
query: entry.query
|
|
285
|
-
.toLowerCase()
|
|
286
|
-
.split(/\W+/)
|
|
287
|
-
.filter(w => w.length > 2 && !STOPWORDS.has(w))
|
|
288
|
-
.join(' '),
|
|
309
|
+
query: tokenize(entry.query).join(' '),
|
|
289
310
|
};
|
|
290
311
|
this.entries.push(sanitized);
|
|
291
312
|
this.learningIndex.update(sanitized);
|