capman 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/CODEBASE.md +15 -9
  3. package/bin/lib/cmd-explain.js +2 -2
  4. package/bin/lib/cmd-run.js +2 -2
  5. package/bin/lib/shared.js +8 -2
  6. package/dist/cjs/cache.d.ts +2 -1
  7. package/dist/cjs/cache.d.ts.map +1 -1
  8. package/dist/cjs/cache.js +11 -6
  9. package/dist/cjs/cache.js.map +1 -1
  10. package/dist/cjs/engine.d.ts +30 -0
  11. package/dist/cjs/engine.d.ts.map +1 -1
  12. package/dist/cjs/engine.js +69 -25
  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 +16 -1
  16. package/dist/cjs/generator.js.map +1 -1
  17. package/dist/cjs/learning.d.ts +20 -10
  18. package/dist/cjs/learning.d.ts.map +1 -1
  19. package/dist/cjs/learning.js +146 -129
  20. package/dist/cjs/learning.js.map +1 -1
  21. package/dist/cjs/matcher.d.ts +5 -2
  22. package/dist/cjs/matcher.d.ts.map +1 -1
  23. package/dist/cjs/matcher.js +73 -10
  24. package/dist/cjs/matcher.js.map +1 -1
  25. package/dist/cjs/parser.js +8 -2
  26. package/dist/cjs/parser.js.map +1 -1
  27. package/dist/cjs/resolver.d.ts +7 -0
  28. package/dist/cjs/resolver.d.ts.map +1 -1
  29. package/dist/cjs/resolver.js +47 -23
  30. package/dist/cjs/resolver.js.map +1 -1
  31. package/dist/cjs/schema.d.ts +93 -1
  32. package/dist/cjs/schema.d.ts.map +1 -1
  33. package/dist/cjs/schema.js +5 -2
  34. package/dist/cjs/schema.js.map +1 -1
  35. package/dist/cjs/version.d.ts +1 -1
  36. package/dist/cjs/version.js +1 -1
  37. package/dist/esm/cache.d.ts +2 -1
  38. package/dist/esm/cache.js +11 -6
  39. package/dist/esm/engine.d.ts +30 -0
  40. package/dist/esm/engine.js +69 -25
  41. package/dist/esm/generator.js +16 -1
  42. package/dist/esm/learning.d.ts +20 -10
  43. package/dist/esm/learning.js +146 -129
  44. package/dist/esm/matcher.d.ts +5 -2
  45. package/dist/esm/matcher.js +70 -10
  46. package/dist/esm/parser.js +8 -2
  47. package/dist/esm/resolver.d.ts +7 -0
  48. package/dist/esm/resolver.js +47 -23
  49. package/dist/esm/schema.d.ts +93 -1
  50. package/dist/esm/schema.js +5 -2
  51. package/dist/esm/version.d.ts +1 -1
  52. package/dist/esm/version.js +1 -1
  53. package/package.json +11 -10
@@ -3,35 +3,16 @@ import * as path from 'path';
3
3
  import { logger } from './logger';
4
4
  const MAX_LEARNING_ENTRIES = 10_000;
5
5
  import { STOPWORDS } from './matcher';
6
- // ─── Shared computation helpers ───────────────────────────────────────────────
7
- function computeStats(entries) {
8
- const index = {};
9
- let totalQueries = 0;
10
- let llmQueries = 0;
11
- let cacheHits = 0;
12
- let outOfScope = 0;
13
- for (const entry of entries) {
14
- totalQueries++;
15
- if (entry.resolvedVia === 'llm')
16
- llmQueries++;
17
- if (entry.resolvedVia === 'cache')
18
- cacheHits++;
19
- if (!entry.capabilityId)
20
- outOfScope++;
21
- if (entry.capabilityId) {
22
- const words = entry.query.toLowerCase()
23
- .split(/\W+/)
24
- .filter(w => w.length > 2 && !STOPWORDS.has(w));
25
- for (const word of words) {
26
- if (!index[word])
27
- index[word] = {};
28
- index[word][entry.capabilityId] =
29
- (index[word][entry.capabilityId] ?? 0) + 1;
30
- }
31
- }
6
+ // Module-level registry tracks all active FileLearningStore instances
7
+ // for process exit flushing. Handlers registered once to avoid accumulation.
8
+ const activeStores = new Set();
9
+ let exitHandlersRegistered = false;
10
+ function flushAllStores() {
11
+ for (const store of activeStores) {
12
+ store.flushSync();
32
13
  }
33
- return { index, totalQueries, llmQueries, cacheHits, outOfScope };
34
14
  }
15
+ // ─── Shared computation helpers ───────────────────────────────────────────────
35
16
  function computeTopCapabilities(entries, limit) {
36
17
  const counts = {};
37
18
  for (const entry of entries) {
@@ -44,28 +25,18 @@ function computeTopCapabilities(entries, limit) {
44
25
  .slice(0, limit)
45
26
  .map(([id, hits]) => ({ id, hits }));
46
27
  }
47
- // ─── File Learning Store ──────────────────────────────────────────────────────
48
- export class FileLearningStore {
49
- constructor(filePath = '.capman/learning.json') {
50
- this.entries = [];
51
- this.loaded = false;
52
- this.saveQueue = Promise.resolve();
53
- // ── Incremental index — updated in record(), not rebuilt in getStats() ────
28
+ // ─── Shared Learning Index ────────────────────────────────────────────────────
29
+ // Encapsulates keyword index and stats counters.
30
+ // Both FileLearningStore and MemoryLearningStore compose this instead of
31
+ // duplicating the same ~80 lines of index management logic.
32
+ class LearningIndex {
33
+ constructor() {
54
34
  this.index = {};
55
35
  this.statsCounter = {
56
36
  totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0,
57
37
  };
58
- const cwd = process.cwd();
59
- const resolved = path.resolve(cwd, filePath);
60
- const allowedPrefix = cwd === '/' ? '/' : cwd + path.sep;
61
- if (!resolved.startsWith(allowedPrefix)) {
62
- throw new Error(`FileCache path "${filePath}" resolves outside the working directory.\n` +
63
- `Resolved: ${resolved}\nAllowed: ${cwd}`);
64
- }
65
- this.filePath = resolved;
66
- logger.info(`FileLearningStore initialized — writing to: ${this.filePath}`);
67
38
  }
68
- updateIndex(entry) {
39
+ update(entry) {
69
40
  this.statsCounter.totalQueries++;
70
41
  if (entry.resolvedVia === 'llm')
71
42
  this.statsCounter.llmQueries++;
@@ -84,21 +55,18 @@ export class FileLearningStore {
84
55
  }
85
56
  }
86
57
  }
87
- subtractFromIndex(entry) {
88
- if (!entry.capabilityId) {
89
- this.statsCounter.outOfScope = Math.max(0, this.statsCounter.outOfScope - 1);
90
- this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
91
- if (entry.resolvedVia === 'llm')
92
- this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
93
- if (entry.resolvedVia === 'cache')
94
- this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
95
- return;
96
- }
58
+ subtract(entry) {
59
+ // Shared counter decrements regardless of capabilityId
97
60
  this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
98
61
  if (entry.resolvedVia === 'llm')
99
62
  this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
100
63
  if (entry.resolvedVia === 'cache')
101
64
  this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
65
+ if (!entry.capabilityId) {
66
+ this.statsCounter.outOfScope = Math.max(0, this.statsCounter.outOfScope - 1);
67
+ return;
68
+ }
69
+ // Keyword index cleanup
102
70
  const words = entry.query.toLowerCase()
103
71
  .split(/\W+/)
104
72
  .filter(w => w.length > 2 && !STOPWORDS.has(w));
@@ -115,22 +83,102 @@ export class FileLearningStore {
115
83
  }
116
84
  }
117
85
  }
118
- rebuildIndex() {
86
+ rebuild(entries) {
87
+ this.index = {};
88
+ this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
89
+ for (const entry of entries) {
90
+ this.update(entry);
91
+ }
92
+ }
93
+ reset() {
119
94
  this.index = {};
120
95
  this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
121
- for (const entry of this.entries) {
122
- this.updateIndex(entry);
96
+ }
97
+ getStats() {
98
+ return { ...this.statsCounter, index: structuredClone(this.index) };
99
+ }
100
+ getIndex() {
101
+ return structuredClone(this.index);
102
+ }
103
+ }
104
+ // ─── File Learning Store ──────────────────────────────────────────────────────
105
+ export class FileLearningStore {
106
+ constructor(filePath = '.capman/learning.json') {
107
+ this.entries = [];
108
+ this.loadPromise = null;
109
+ this.saveQueue = Promise.resolve();
110
+ this.learningIndex = new LearningIndex();
111
+ this.dirty = false;
112
+ this.saveTimer = null;
113
+ const cwd = process.cwd();
114
+ const resolved = path.resolve(cwd, filePath);
115
+ const allowedPrefix = cwd === '/' ? '/' : cwd + path.sep;
116
+ if (!resolved.startsWith(allowedPrefix)) {
117
+ throw new Error(`FileLearningStore path "${filePath}" resolves outside the working directory.\n` +
118
+ `Resolved: ${resolved}\nAllowed: ${cwd}`);
119
+ }
120
+ this.filePath = resolved;
121
+ logger.info(`FileLearningStore initialized — writing to: ${this.filePath}`);
122
+ 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); });
123
128
  }
124
129
  }
125
- async load() {
126
- if (this.loaded)
130
+ flushSync() {
131
+ // Cancel pending timer — prevents scheduleSave firing after sync write
132
+ if (this.saveTimer) {
133
+ clearTimeout(this.saveTimer);
134
+ this.saveTimer = null;
135
+ }
136
+ if (!this.dirty)
127
137
  return;
138
+ this.dirty = false;
139
+ try {
140
+ const dir = path.dirname(this.filePath);
141
+ fs.mkdirSync(dir, { recursive: true });
142
+ const tmp = `${this.filePath}.tmp`;
143
+ const payload = JSON.stringify({ entries: this.entries, updatedAt: new Date().toISOString() }, null, 2);
144
+ // Write to .tmp then rename — matches _doSave() pattern so they can't interleave
145
+ fs.writeFileSync(tmp, payload);
146
+ fs.renameSync(tmp, this.filePath);
147
+ }
148
+ catch {
149
+ // Best-effort in exit handler
150
+ }
151
+ }
152
+ /**
153
+ * Removes this store from the exit flush registry and cancels any pending save timer.
154
+ * Call when the store is no longer needed to prevent memory leaks in long-running servers.
155
+ */
156
+ async destroy() {
157
+ if (this.saveTimer) {
158
+ clearTimeout(this.saveTimer);
159
+ this.saveTimer = null;
160
+ }
161
+ if (this.dirty) {
162
+ this.dirty = false;
163
+ // Await final flush before removing from registry —
164
+ // ensures data is written before the store becomes unreachable
165
+ await this.save();
166
+ }
167
+ activeStores.delete(this);
168
+ }
169
+ load() {
170
+ if (!this.loadPromise) {
171
+ this.loadPromise = this._doLoad();
172
+ }
173
+ return this.loadPromise;
174
+ }
175
+ async _doLoad() {
128
176
  try {
129
177
  const raw = await fs.promises.readFile(this.filePath, 'utf-8');
130
178
  const parsed = JSON.parse(raw);
131
179
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Array.isArray(parsed.entries)) {
132
180
  this.entries = parsed.entries;
133
- this.rebuildIndex();
181
+ this.learningIndex.rebuild(this.entries);
134
182
  logger.debug(`Learning store loaded: ${this.entries.length} entries`);
135
183
  }
136
184
  else {
@@ -140,7 +188,19 @@ export class FileLearningStore {
140
188
  catch {
141
189
  // File doesn't exist yet — start fresh
142
190
  }
143
- this.loaded = true;
191
+ }
192
+ scheduleSave(urgencyMs = 5_000) {
193
+ this.dirty = true;
194
+ if (!this.saveTimer) {
195
+ this.saveTimer = setTimeout(() => {
196
+ this.saveTimer = null;
197
+ if (this.dirty) {
198
+ this.dirty = false;
199
+ // Route through saveQueue — serializes with all other saves
200
+ this.save();
201
+ }
202
+ }, urgencyMs);
203
+ }
144
204
  }
145
205
  save() {
146
206
  this.saveQueue = this.saveQueue.then(() => this._doSave());
@@ -150,10 +210,12 @@ export class FileLearningStore {
150
210
  try {
151
211
  const dir = path.dirname(this.filePath);
152
212
  await fs.promises.mkdir(dir, { recursive: true });
153
- await fs.promises.writeFile(this.filePath, JSON.stringify({
213
+ const tmp = `${this.filePath}.tmp`;
214
+ await fs.promises.writeFile(tmp, JSON.stringify({
154
215
  entries: this.entries,
155
216
  updatedAt: new Date().toISOString(),
156
217
  }, null, 2));
218
+ await fs.promises.rename(tmp, this.filePath);
157
219
  }
158
220
  catch (err) {
159
221
  logger.warn(`Failed to save learning store to ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`);
@@ -173,34 +235,40 @@ export class FileLearningStore {
173
235
  .join(' '),
174
236
  };
175
237
  this.entries.push(sanitized);
176
- this.updateIndex(sanitized);
238
+ this.learningIndex.update(sanitized);
177
239
  if (this.entries.length > MAX_LEARNING_ENTRIES) {
178
240
  const excess = this.entries.length - MAX_LEARNING_ENTRIES;
179
241
  const pruned = this.entries.splice(0, excess);
180
242
  // Subtract pruned entries from index — O(pruned × w) instead of O(n × w) full rebuild
181
243
  for (const entry of pruned) {
182
- this.subtractFromIndex(entry);
244
+ this.learningIndex.subtract(entry);
183
245
  }
184
246
  logger.debug(`Learning store pruned ${excess} oldest entries (cap: ${MAX_LEARNING_ENTRIES})`);
185
247
  }
186
- await this.save();
248
+ this.scheduleSave();
187
249
  }
188
250
  async getStats() {
189
251
  await this.load();
190
- return { ...this.statsCounter, index: structuredClone(this.index) };
252
+ return this.learningIndex.getStats();
191
253
  }
192
254
  async getIndex() {
193
255
  await this.load();
194
- return structuredClone(this.index);
256
+ return this.learningIndex.getIndex();
195
257
  }
196
258
  async getTopCapabilities(limit = 5) {
197
259
  await this.load();
198
260
  return computeTopCapabilities(this.entries, limit);
199
261
  }
200
262
  async clear() {
263
+ // Cancel any pending debounced save — prevents stale data being written
264
+ // after clear() resets state
265
+ if (this.saveTimer) {
266
+ clearTimeout(this.saveTimer);
267
+ this.saveTimer = null;
268
+ }
269
+ this.dirty = false;
201
270
  this.entries = [];
202
- this.index = {};
203
- this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
271
+ this.learningIndex.reset();
204
272
  await this.save();
205
273
  }
206
274
  }
@@ -208,10 +276,7 @@ export class FileLearningStore {
208
276
  export class MemoryLearningStore {
209
277
  constructor() {
210
278
  this.entries = [];
211
- this.index = {};
212
- this.statsCounter = {
213
- totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0,
214
- };
279
+ this.learningIndex = new LearningIndex();
215
280
  }
216
281
  async record(entry) {
217
282
  const sanitized = {
@@ -223,77 +288,29 @@ export class MemoryLearningStore {
223
288
  .join(' '),
224
289
  };
225
290
  this.entries.push(sanitized);
226
- this.updateIndex(sanitized);
291
+ this.learningIndex.update(sanitized);
227
292
  if (this.entries.length > MAX_LEARNING_ENTRIES) {
228
293
  const excess = this.entries.length - MAX_LEARNING_ENTRIES;
229
294
  const pruned = this.entries.splice(0, excess);
230
295
  for (const entry of pruned) {
231
- this.subtractFromIndex(entry);
296
+ this.learningIndex.subtract(entry);
232
297
  }
233
298
  }
234
299
  }
235
300
  async getStats() {
236
- return { ...this.statsCounter, index: structuredClone(this.index) };
301
+ return this.learningIndex.getStats();
237
302
  }
238
303
  async getIndex() {
239
- return structuredClone(this.index);
240
- }
241
- updateIndex(entry) {
242
- this.statsCounter.totalQueries++;
243
- if (entry.resolvedVia === 'llm')
244
- this.statsCounter.llmQueries++;
245
- if (entry.resolvedVia === 'cache')
246
- this.statsCounter.cacheHits++;
247
- if (!entry.capabilityId)
248
- this.statsCounter.outOfScope++;
249
- if (entry.capabilityId) {
250
- const words = entry.query.toLowerCase()
251
- .split(/\W+/)
252
- .filter(w => w.length > 2 && !STOPWORDS.has(w));
253
- for (const word of words) {
254
- this.index[word] ??= {};
255
- this.index[word][entry.capabilityId] =
256
- (this.index[word][entry.capabilityId] ?? 0) + 1;
257
- }
258
- }
259
- }
260
- subtractFromIndex(entry) {
261
- if (!entry.capabilityId) {
262
- this.statsCounter.outOfScope = Math.max(0, this.statsCounter.outOfScope - 1);
263
- this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
264
- if (entry.resolvedVia === 'llm')
265
- this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
266
- if (entry.resolvedVia === 'cache')
267
- this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
268
- return;
269
- }
270
- this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
271
- if (entry.resolvedVia === 'llm')
272
- this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
273
- if (entry.resolvedVia === 'cache')
274
- this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
275
- const words = entry.query.toLowerCase()
276
- .split(/\W+/)
277
- .filter(w => w.length > 2 && !STOPWORDS.has(w));
278
- for (const word of words) {
279
- if (!this.index[word])
280
- continue;
281
- this.index[word][entry.capabilityId] =
282
- (this.index[word][entry.capabilityId] ?? 1) - 1;
283
- if (this.index[word][entry.capabilityId] <= 0) {
284
- delete this.index[word][entry.capabilityId];
285
- }
286
- if (Object.keys(this.index[word]).length === 0) {
287
- delete this.index[word];
288
- }
289
- }
304
+ return this.learningIndex.getIndex();
290
305
  }
291
306
  async getTopCapabilities(limit = 5) {
292
307
  return computeTopCapabilities(this.entries, limit);
293
308
  }
294
309
  async clear() {
295
310
  this.entries = [];
296
- this.index = {};
297
- this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
311
+ this.learningIndex.reset();
312
+ }
313
+ async destroy() {
314
+ // No-op for memory store — nothing to flush or deregister
298
315
  }
299
316
  }
@@ -6,7 +6,6 @@ export declare const STOPWORDS: Set<string>;
6
6
  export declare function resolverToIntent(cap: Capability): MatchResult['intent'];
7
7
  /**
8
8
  * Extracts parameter values from a user query using keyword heuristics.
9
- *
10
9
  * Known limits:
11
10
  * - Extracts single tokens only — "jane smith" would extract "jane"
12
11
  * - Keyword matching is positional — "articles from authors I follow"
@@ -15,7 +14,11 @@ export declare function resolverToIntent(cap: Capability): MatchResult['intent']
15
14
  * param extraction more accurately via the LLM prompt
16
15
  */
17
16
  export declare function extractParams(query: string, cap: Capability): Record<string, string | null>;
18
- export declare function match(query: string, manifest: Manifest): MatchResult;
17
+ export interface MatchOptions {
18
+ fuzzyMatch?: boolean;
19
+ fuzzyThreshold?: number;
20
+ }
21
+ export declare function match(query: string, manifest: Manifest, options?: MatchOptions): MatchResult;
19
22
  export interface LLMMatcherOptions {
20
23
  llm: (prompt: string) => Promise<string>;
21
24
  }
@@ -1,4 +1,5 @@
1
1
  import { logger } from './logger';
2
+ import Fuse from 'fuse.js';
2
3
  // ─── Typed error for LLM parse failures ──────────────────────────────────────
3
4
  export class LLMParseError extends Error {
4
5
  constructor(message) {
@@ -24,14 +25,20 @@ function scoreCapability(query, cap) {
24
25
  const q = query.toLowerCase();
25
26
  let score = 0;
26
27
  const qWords = filterStopwords(q.split(/\W+/).filter(Boolean));
27
- // Check examples — exact substring match is a strong signal
28
+ // Check examples — take the best single example match, not the sum.
29
+ // Accumulating across examples rewards bloated example lists over precise ones:
30
+ // 10 examples at 50% overlap = 300 points (clamped to 60) beats 1 perfect example at 60.
31
+ // Taking Math.max means quality of examples matters, not quantity.
32
+ let bestExampleScore = 0;
28
33
  for (const example of cap.examples ?? []) {
29
34
  const exWords = filterStopwords(example.toLowerCase().split(/\s+/));
30
35
  if (exWords.length === 0)
31
36
  continue;
32
37
  const overlap = exWords.filter(w => qWords.includes(w)).length;
33
- score += (overlap / exWords.length) * 60;
38
+ const contribution = (overlap / exWords.length) * 60;
39
+ bestExampleScore = Math.max(bestExampleScore, contribution);
34
40
  }
41
+ score += bestExampleScore;
35
42
  // Check description words
36
43
  const descWords = filterStopwords(cap.description.toLowerCase().split(/\W+/).filter(Boolean));
37
44
  if (descWords.length > 0) {
@@ -58,7 +65,6 @@ export function resolverToIntent(cap) {
58
65
  }
59
66
  /**
60
67
  * Extracts parameter values from a user query using keyword heuristics.
61
- *
62
68
  * Known limits:
63
69
  * - Extracts single tokens only — "jane smith" would extract "jane"
64
70
  * - Keyword matching is positional — "articles from authors I follow"
@@ -136,7 +142,7 @@ export function extractParams(query, cap) {
136
142
  }
137
143
  return result;
138
144
  }
139
- export function match(query, manifest) {
145
+ export function match(query, manifest, options = {}) {
140
146
  if (!query?.trim()) {
141
147
  logger.warn('Empty query received');
142
148
  return {
@@ -148,15 +154,65 @@ export function match(query, manifest) {
148
154
  candidates: [],
149
155
  };
150
156
  }
151
- logger.info(`Matching query: "${query}"`);
157
+ logger.info(`Matching query (${query.length} chars)`);
158
+ logger.debug(`Full query: "${query}"`);
152
159
  logger.debug(`Manifest has ${manifest.capabilities.length} capabilities`);
153
160
  let best = null;
154
161
  let bestScore = 0;
162
+ // ── Build Fuse index once per match() call ────────────────────────────────
163
+ // Flat corpus — each example/description/name is its own searchable record,
164
+ // tagged with the owning capability id. This avoids two pitfalls of using
165
+ // Fuse's multi-key mode here:
166
+ // (1) joining examples into one string dilutes single-example matches,
167
+ // (2) multi-key weighted aggregation mixes good and bad field matches
168
+ // when we actually want the best single match across all fields.
169
+ // After searching, we group hits by capability and take the BEST score.
170
+ // Field prioritization (examples > description > name) is already applied
171
+ // by the keyword scorer (60/30/10 weights in scoreCapability), so fuzzy
172
+ // here is a pure similarity signal.
173
+ const fuzzyScoreMap = new Map();
174
+ if (options.fuzzyMatch) {
175
+ const corpus = [];
176
+ for (const cap of manifest.capabilities) {
177
+ for (const ex of cap.examples ?? []) {
178
+ if (ex?.trim())
179
+ corpus.push({ capabilityId: cap.id, text: ex });
180
+ }
181
+ if (cap.description?.trim())
182
+ corpus.push({ capabilityId: cap.id, text: cap.description });
183
+ if (cap.name?.trim())
184
+ corpus.push({ capabilityId: cap.id, text: cap.name });
185
+ }
186
+ if (corpus.length > 0) {
187
+ const fuse = new Fuse(corpus, {
188
+ keys: ['text'],
189
+ threshold: options.fuzzyThreshold ?? 0.4,
190
+ includeScore: true,
191
+ ignoreLocation: true,
192
+ minMatchCharLength: 3,
193
+ });
194
+ // Group hits by capability, keeping the best (lowest fuse score = highest similarity).
195
+ // Convert to 0-100 contribution: fuseScore 0.0 = 100%, fuseScore 1.0 = 0%.
196
+ // Multiplier 100 (not 60) lets a strong fuzzy match alone reach the standard
197
+ // 50% confidence cutoff for typo-only queries that have no keyword overlap.
198
+ for (const hit of fuse.search(query)) {
199
+ const capId = hit.item.capabilityId;
200
+ const contribution = (1 - (hit.score ?? 1)) * 100;
201
+ const existing = fuzzyScoreMap.get(capId) ?? 0;
202
+ if (contribution > existing)
203
+ fuzzyScoreMap.set(capId, contribution);
204
+ }
205
+ }
206
+ }
207
+ // ── Score all capabilities ────────────────────────────────────────────────
155
208
  const allScores = [];
156
209
  for (const cap of manifest.capabilities) {
157
- const score = scoreCapability(query, cap);
158
- logger.debug(` scored "${cap.id}": ${score}%`);
159
- allScores.push({ cap, score });
210
+ const keywordScore = scoreCapability(query, cap);
211
+ const fuzzyScore = fuzzyScoreMap.get(cap.id) ?? 0;
212
+ const via = fuzzyScore > keywordScore ? 'fuzzy' : 'keyword';
213
+ const score = Math.min(100, Math.round(Math.max(keywordScore, fuzzyScore)));
214
+ logger.debug(` scored "${cap.id}": ${score}% (keyword: ${keywordScore}%, fuzzy: ${Math.round(fuzzyScore)}%)`);
215
+ allScores.push({ cap, score, via });
160
216
  if (score > bestScore) {
161
217
  bestScore = score;
162
218
  best = cap;
@@ -168,7 +224,8 @@ export function match(query, manifest) {
168
224
  matched: cap.id === best?.id,
169
225
  }));
170
226
  if (!best || bestScore < 50) {
171
- logger.info(`No match above threshold (best: ${bestScore}% for "${best?.id ?? 'none'}")`);
227
+ const bestId = best ? best.id : 'none';
228
+ logger.info(`No match above threshold (best: ${bestScore}% for "${bestId}")`);
172
229
  // Out of scope return:
173
230
  return {
174
231
  capability: null,
@@ -182,13 +239,16 @@ export function match(query, manifest) {
182
239
  const params = extractParams(query, best);
183
240
  logger.info(`Matched "${best.id}" at ${bestScore}% confidence`);
184
241
  logger.debug(`Extracted params: ${JSON.stringify(Object.fromEntries(Object.entries(params).map(([k, v]) => [k, v != null ? '[REDACTED]' : 'null'])))}`);
242
+ // Use the via tag tracked during scoring — avoids redundant scoreCapability call.
243
+ const bestEntry = allScores.find(s => s.cap.id === best.id);
244
+ const winner = bestEntry?.via === 'fuzzy' ? 'fuzzy match' : 'keyword scoring';
185
245
  // Matched return:
186
246
  return {
187
247
  capability: best,
188
248
  confidence: bestScore,
189
249
  intent: resolverToIntent(best),
190
250
  extractedParams: params,
191
- reasoning: `Matched "${best.id}" via keyword scoring (score: ${bestScore})`,
251
+ reasoning: `Matched "${best.id}" via ${winner} (score: ${bestScore})`,
192
252
  candidates,
193
253
  };
194
254
  }
@@ -50,8 +50,14 @@ function parseSpecText(text, source) {
50
50
  const yaml = require('js-yaml');
51
51
  return yaml.load(text);
52
52
  }
53
- catch {
54
- // js-yaml not installed try basic YAML detection
53
+ catch (err) {
54
+ const msg = err instanceof Error ? err.message : String(err);
55
+ // Distinguish "module not found" from actual YAML parse errors
56
+ const code = err.code;
57
+ if (code !== 'MODULE_NOT_FOUND') {
58
+ throw new Error(`YAML parse error in "${source}": ${msg}`);
59
+ }
60
+ // js-yaml not installed — fall through to extension check
55
61
  if (source.endsWith('.yaml') || source.endsWith('.yml')) {
56
62
  throw new Error('YAML spec detected but js-yaml is not installed.\n' +
57
63
  'Install it: npm install js-yaml\n' +
@@ -17,5 +17,12 @@ export interface ResolveOptions {
17
17
  retries?: number;
18
18
  /** Timeout in milliseconds (default: 5000) */
19
19
  timeoutMs?: number;
20
+ /**
21
+ * When true, retries all HTTP methods including POST/PUT/PATCH/DELETE.
22
+ * Use only for idempotent write operations — retrying non-idempotent
23
+ * methods can cause duplicate side effects (duplicate orders, double charges).
24
+ * @default false
25
+ */
26
+ retryAllMethods?: boolean;
20
27
  }
21
28
  export declare function resolve(matchResult: MatchResult, params?: Record<string, unknown>, options?: ResolveOptions): Promise<ResolveResult>;