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.
Files changed (59) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/CODEBASE.md +111 -66
  3. package/README.md +45 -4
  4. package/bin/lib/cmd-generate.js +200 -40
  5. package/bin/lib/cmd-help.js +3 -0
  6. package/dist/cjs/cache.d.ts.map +1 -1
  7. package/dist/cjs/cache.js +22 -5
  8. package/dist/cjs/cache.js.map +1 -1
  9. package/dist/cjs/engine.d.ts +53 -1
  10. package/dist/cjs/engine.d.ts.map +1 -1
  11. package/dist/cjs/engine.js +252 -17
  12. package/dist/cjs/engine.js.map +1 -1
  13. package/dist/cjs/generator.d.ts.map +1 -1
  14. package/dist/cjs/generator.js +7 -1
  15. package/dist/cjs/generator.js.map +1 -1
  16. package/dist/cjs/index.d.ts +1 -0
  17. package/dist/cjs/index.d.ts.map +1 -1
  18. package/dist/cjs/index.js +3 -1
  19. package/dist/cjs/index.js.map +1 -1
  20. package/dist/cjs/learning.d.ts.map +1 -1
  21. package/dist/cjs/learning.js +51 -30
  22. package/dist/cjs/learning.js.map +1 -1
  23. package/dist/cjs/matcher.d.ts +69 -9
  24. package/dist/cjs/matcher.d.ts.map +1 -1
  25. package/dist/cjs/matcher.js +328 -43
  26. package/dist/cjs/matcher.js.map +1 -1
  27. package/dist/cjs/parser.d.ts.map +1 -1
  28. package/dist/cjs/parser.js +15 -8
  29. package/dist/cjs/parser.js.map +1 -1
  30. package/dist/cjs/resolver.d.ts +1 -0
  31. package/dist/cjs/resolver.d.ts.map +1 -1
  32. package/dist/cjs/resolver.js +16 -5
  33. package/dist/cjs/resolver.js.map +1 -1
  34. package/dist/cjs/schema.d.ts +64 -46
  35. package/dist/cjs/schema.d.ts.map +1 -1
  36. package/dist/cjs/schema.js +2 -1
  37. package/dist/cjs/schema.js.map +1 -1
  38. package/dist/cjs/types.d.ts +8 -2
  39. package/dist/cjs/types.d.ts.map +1 -1
  40. package/dist/cjs/version.d.ts +1 -1
  41. package/dist/cjs/version.js +1 -1
  42. package/dist/esm/cache.js +22 -5
  43. package/dist/esm/engine.d.ts +53 -1
  44. package/dist/esm/engine.js +255 -20
  45. package/dist/esm/generator.js +7 -1
  46. package/dist/esm/index.d.ts +1 -0
  47. package/dist/esm/index.js +1 -0
  48. package/dist/esm/learning.js +52 -31
  49. package/dist/esm/matcher.d.ts +69 -9
  50. package/dist/esm/matcher.js +321 -42
  51. package/dist/esm/parser.js +15 -8
  52. package/dist/esm/resolver.d.ts +1 -0
  53. package/dist/esm/resolver.js +16 -6
  54. package/dist/esm/schema.d.ts +64 -46
  55. package/dist/esm/schema.js +2 -1
  56. package/dist/esm/types.d.ts +8 -2
  57. package/dist/esm/version.d.ts +1 -1
  58. package/dist/esm/version.js +1 -1
  59. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
- import { match as _match, matchWithLLM as _matchWithLLM, resolverToIntent, extractParams, STOPWORDS, LLMParseError } from './matcher';
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
- const preBoostMatchResult = matchResult; // kept for learning recording onlyprevents feedback loop
120
+ // Shallow copy with candidates slicenot 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 privacyLevel = matchResult.capability.privacy.level;
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: ${privacyLevel}`,
133
+ detail: privacyError ?? `level: ${matchResult.capability.privacy.level}`,
121
134
  });
122
135
  }
123
- // ── Step 4: Resolve ──────────────────────────────────────────────────────
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
- // Only cache when resolution succeeded — a failed resolution (network error,
134
- // auth failure, bad params) must not poison the cache. A cached failed match
135
- // would cause every subsequent cache hit to attempt the same failing resolution
136
- // until TTL expires.
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.toLowerCase().split(/\s+/))
299
- .filter(w => qWords.includes(w) && w.length > 2);
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.toLowerCase().split(/\W+/).filter(w => w.length > 2 && !STOPWORDS.has(w));
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;
@@ -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 resolved = path.resolve(process.cwd(), outputPath);
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
  }
@@ -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';
@@ -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 { STOPWORDS } from './matcher';
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
- let exitHandlersRegistered = false;
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
- const words = entry.query.toLowerCase()
49
- .split(/\W+/)
50
- .filter(w => w.length > 2 && !STOPWORDS.has(w));
74
+ // Confidence-weighted contribution — a 95% match contributes 9.
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) + 1;
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.toLowerCase()
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] ?? 1) - 1;
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
- if (!exitHandlersRegistered) {
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
- // File doesn't exist yet — start fresh
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);