capman 0.5.1 → 0.5.3

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 (60) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/CODEBASE.md +2 -1
  3. package/bin/lib/cmd-demo.js +2 -2
  4. package/dist/cjs/cache.d.ts +4 -1
  5. package/dist/cjs/cache.d.ts.map +1 -1
  6. package/dist/cjs/cache.js +42 -20
  7. package/dist/cjs/cache.js.map +1 -1
  8. package/dist/cjs/engine.d.ts +3 -1
  9. package/dist/cjs/engine.d.ts.map +1 -1
  10. package/dist/cjs/engine.js +103 -34
  11. package/dist/cjs/engine.js.map +1 -1
  12. package/dist/cjs/generator.d.ts.map +1 -1
  13. package/dist/cjs/generator.js +16 -1
  14. package/dist/cjs/generator.js.map +1 -1
  15. package/dist/cjs/index.d.ts +3 -2
  16. package/dist/cjs/index.d.ts.map +1 -1
  17. package/dist/cjs/index.js +3 -1
  18. package/dist/cjs/index.js.map +1 -1
  19. package/dist/cjs/learning.d.ts +20 -11
  20. package/dist/cjs/learning.d.ts.map +1 -1
  21. package/dist/cjs/learning.js +169 -135
  22. package/dist/cjs/learning.js.map +1 -1
  23. package/dist/cjs/matcher.d.ts +4 -1
  24. package/dist/cjs/matcher.d.ts.map +1 -1
  25. package/dist/cjs/matcher.js +33 -13
  26. package/dist/cjs/matcher.js.map +1 -1
  27. package/dist/cjs/parser.js +10 -4
  28. package/dist/cjs/parser.js.map +1 -1
  29. package/dist/cjs/resolver.d.ts +7 -0
  30. package/dist/cjs/resolver.d.ts.map +1 -1
  31. package/dist/cjs/resolver.js +63 -19
  32. package/dist/cjs/resolver.js.map +1 -1
  33. package/dist/cjs/schema.d.ts +106 -14
  34. package/dist/cjs/schema.d.ts.map +1 -1
  35. package/dist/cjs/schema.js +9 -4
  36. package/dist/cjs/schema.js.map +1 -1
  37. package/dist/cjs/types.d.ts +1 -0
  38. package/dist/cjs/types.d.ts.map +1 -1
  39. package/dist/cjs/version.d.ts +1 -1
  40. package/dist/cjs/version.js +1 -1
  41. package/dist/esm/cache.d.ts +4 -1
  42. package/dist/esm/cache.js +42 -20
  43. package/dist/esm/engine.d.ts +3 -1
  44. package/dist/esm/engine.js +104 -35
  45. package/dist/esm/generator.js +16 -1
  46. package/dist/esm/index.d.ts +3 -2
  47. package/dist/esm/index.js +1 -0
  48. package/dist/esm/learning.d.ts +20 -11
  49. package/dist/esm/learning.js +169 -135
  50. package/dist/esm/matcher.d.ts +4 -1
  51. package/dist/esm/matcher.js +31 -12
  52. package/dist/esm/parser.js +10 -4
  53. package/dist/esm/resolver.d.ts +7 -0
  54. package/dist/esm/resolver.js +63 -19
  55. package/dist/esm/schema.d.ts +106 -14
  56. package/dist/esm/schema.js +9 -4
  57. package/dist/esm/types.d.ts +1 -0
  58. package/dist/esm/version.d.ts +1 -1
  59. package/dist/esm/version.js +1 -1
  60. package/package.json +1 -1
@@ -1,37 +1,18 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { logger } from './logger';
4
- const MAX_LEARNING_ENTRIES = 10000;
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,22 +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
- this.filePath = path.resolve(process.cwd(), filePath);
59
- logger.info(`FileLearningStore initialized — writing to: ${this.filePath}`);
60
38
  }
61
- updateIndex(entry) {
62
- var _a;
39
+ update(entry) {
63
40
  this.statsCounter.totalQueries++;
64
41
  if (entry.resolvedVia === 'llm')
65
42
  this.statsCounter.llmQueries++;
@@ -72,27 +49,24 @@ export class FileLearningStore {
72
49
  .split(/\W+/)
73
50
  .filter(w => w.length > 2 && !STOPWORDS.has(w));
74
51
  for (const word of words) {
75
- (_a = this.index)[word] ?? (_a[word] = {});
52
+ this.index[word] ??= {};
76
53
  this.index[word][entry.capabilityId] =
77
54
  (this.index[word][entry.capabilityId] ?? 0) + 1;
78
55
  }
79
56
  }
80
57
  }
81
- subtractFromIndex(entry) {
82
- if (!entry.capabilityId) {
83
- this.statsCounter.outOfScope = Math.max(0, this.statsCounter.outOfScope - 1);
84
- this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
85
- if (entry.resolvedVia === 'llm')
86
- this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
87
- if (entry.resolvedVia === 'cache')
88
- this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
89
- return;
90
- }
58
+ subtract(entry) {
59
+ // Shared counter decrements regardless of capabilityId
91
60
  this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
92
61
  if (entry.resolvedVia === 'llm')
93
62
  this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
94
63
  if (entry.resolvedVia === 'cache')
95
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
96
70
  const words = entry.query.toLowerCase()
97
71
  .split(/\W+/)
98
72
  .filter(w => w.length > 2 && !STOPWORDS.has(w));
@@ -109,22 +83,102 @@ export class FileLearningStore {
109
83
  }
110
84
  }
111
85
  }
112
- 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() {
113
94
  this.index = {};
114
95
  this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
115
- for (const entry of this.entries) {
116
- 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); });
117
128
  }
118
129
  }
119
- async load() {
120
- 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)
121
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() {
122
176
  try {
123
177
  const raw = await fs.promises.readFile(this.filePath, 'utf-8');
124
178
  const parsed = JSON.parse(raw);
125
179
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Array.isArray(parsed.entries)) {
126
180
  this.entries = parsed.entries;
127
- this.rebuildIndex();
181
+ this.learningIndex.rebuild(this.entries);
128
182
  logger.debug(`Learning store loaded: ${this.entries.length} entries`);
129
183
  }
130
184
  else {
@@ -134,7 +188,19 @@ export class FileLearningStore {
134
188
  catch {
135
189
  // File doesn't exist yet — start fresh
136
190
  }
137
- 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
+ }
138
204
  }
139
205
  save() {
140
206
  this.saveQueue = this.saveQueue.then(() => this._doSave());
@@ -144,10 +210,12 @@ export class FileLearningStore {
144
210
  try {
145
211
  const dir = path.dirname(this.filePath);
146
212
  await fs.promises.mkdir(dir, { recursive: true });
147
- await fs.promises.writeFile(this.filePath, JSON.stringify({
213
+ const tmp = `${this.filePath}.tmp`;
214
+ await fs.promises.writeFile(tmp, JSON.stringify({
148
215
  entries: this.entries,
149
216
  updatedAt: new Date().toISOString(),
150
217
  }, null, 2));
218
+ await fs.promises.rename(tmp, this.filePath);
151
219
  }
152
220
  catch (err) {
153
221
  logger.warn(`Failed to save learning store to ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`);
@@ -155,35 +223,52 @@ export class FileLearningStore {
155
223
  }
156
224
  async record(entry) {
157
225
  await this.load();
158
- this.entries.push(entry);
159
- this.updateIndex(entry);
226
+ // Store only tokenized keywords — never raw query text.
227
+ // Raw queries may contain PII (emails, names, order IDs) that should
228
+ // not be persisted to disk under GDPR/CCPA data retention requirements.
229
+ const sanitized = {
230
+ ...entry,
231
+ query: entry.query
232
+ .toLowerCase()
233
+ .split(/\W+/)
234
+ .filter(w => w.length > 2 && !STOPWORDS.has(w))
235
+ .join(' '),
236
+ };
237
+ this.entries.push(sanitized);
238
+ this.learningIndex.update(sanitized);
160
239
  if (this.entries.length > MAX_LEARNING_ENTRIES) {
161
240
  const excess = this.entries.length - MAX_LEARNING_ENTRIES;
162
241
  const pruned = this.entries.splice(0, excess);
163
242
  // Subtract pruned entries from index — O(pruned × w) instead of O(n × w) full rebuild
164
243
  for (const entry of pruned) {
165
- this.subtractFromIndex(entry);
244
+ this.learningIndex.subtract(entry);
166
245
  }
167
246
  logger.debug(`Learning store pruned ${excess} oldest entries (cap: ${MAX_LEARNING_ENTRIES})`);
168
247
  }
169
- await this.save();
248
+ this.scheduleSave();
170
249
  }
171
250
  async getStats() {
172
251
  await this.load();
173
- return { ...this.statsCounter, index: structuredClone(this.index) };
252
+ return this.learningIndex.getStats();
174
253
  }
175
254
  async getIndex() {
176
255
  await this.load();
177
- return structuredClone(this.index);
256
+ return this.learningIndex.getIndex();
178
257
  }
179
258
  async getTopCapabilities(limit = 5) {
180
259
  await this.load();
181
260
  return computeTopCapabilities(this.entries, limit);
182
261
  }
183
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;
184
270
  this.entries = [];
185
- this.index = {};
186
- this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
271
+ this.learningIndex.reset();
187
272
  await this.save();
188
273
  }
189
274
  }
@@ -191,92 +276,41 @@ export class FileLearningStore {
191
276
  export class MemoryLearningStore {
192
277
  constructor() {
193
278
  this.entries = [];
194
- this.index = {};
195
- this.statsCounter = {
196
- totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0,
197
- };
279
+ this.learningIndex = new LearningIndex();
198
280
  }
199
281
  async record(entry) {
200
- this.entries.push(entry);
201
- this.updateIndex(entry);
282
+ const sanitized = {
283
+ ...entry,
284
+ query: entry.query
285
+ .toLowerCase()
286
+ .split(/\W+/)
287
+ .filter(w => w.length > 2 && !STOPWORDS.has(w))
288
+ .join(' '),
289
+ };
290
+ this.entries.push(sanitized);
291
+ this.learningIndex.update(sanitized);
202
292
  if (this.entries.length > MAX_LEARNING_ENTRIES) {
203
293
  const excess = this.entries.length - MAX_LEARNING_ENTRIES;
204
294
  const pruned = this.entries.splice(0, excess);
205
295
  for (const entry of pruned) {
206
- this.subtractFromIndex(entry);
296
+ this.learningIndex.subtract(entry);
207
297
  }
208
298
  }
209
299
  }
210
300
  async getStats() {
211
- return { ...this.statsCounter, index: structuredClone(this.index) };
301
+ return this.learningIndex.getStats();
212
302
  }
213
303
  async getIndex() {
214
- return structuredClone(this.index);
215
- }
216
- updateIndex(entry) {
217
- var _a;
218
- this.statsCounter.totalQueries++;
219
- if (entry.resolvedVia === 'llm')
220
- this.statsCounter.llmQueries++;
221
- if (entry.resolvedVia === 'cache')
222
- this.statsCounter.cacheHits++;
223
- if (!entry.capabilityId)
224
- this.statsCounter.outOfScope++;
225
- if (entry.capabilityId) {
226
- const words = entry.query.toLowerCase()
227
- .split(/\W+/)
228
- .filter(w => w.length > 2 && !STOPWORDS.has(w));
229
- for (const word of words) {
230
- (_a = this.index)[word] ?? (_a[word] = {});
231
- this.index[word][entry.capabilityId] =
232
- (this.index[word][entry.capabilityId] ?? 0) + 1;
233
- }
234
- }
235
- }
236
- subtractFromIndex(entry) {
237
- if (!entry.capabilityId) {
238
- this.statsCounter.outOfScope = Math.max(0, this.statsCounter.outOfScope - 1);
239
- this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
240
- if (entry.resolvedVia === 'llm')
241
- this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
242
- if (entry.resolvedVia === 'cache')
243
- this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
244
- return;
245
- }
246
- this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
247
- if (entry.resolvedVia === 'llm')
248
- this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
249
- if (entry.resolvedVia === 'cache')
250
- this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
251
- const words = entry.query.toLowerCase()
252
- .split(/\W+/)
253
- .filter(w => w.length > 2 && !STOPWORDS.has(w));
254
- for (const word of words) {
255
- if (!this.index[word])
256
- continue;
257
- this.index[word][entry.capabilityId] =
258
- (this.index[word][entry.capabilityId] ?? 1) - 1;
259
- if (this.index[word][entry.capabilityId] <= 0) {
260
- delete this.index[word][entry.capabilityId];
261
- }
262
- if (Object.keys(this.index[word]).length === 0) {
263
- delete this.index[word];
264
- }
265
- }
266
- }
267
- rebuildIndex() {
268
- this.index = {};
269
- this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
270
- for (const entry of this.entries) {
271
- this.updateIndex(entry);
272
- }
304
+ return this.learningIndex.getIndex();
273
305
  }
274
306
  async getTopCapabilities(limit = 5) {
275
307
  return computeTopCapabilities(this.entries, limit);
276
308
  }
277
309
  async clear() {
278
310
  this.entries = [];
279
- this.index = {};
280
- 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
281
315
  }
282
316
  }
@@ -1,4 +1,7 @@
1
- import type { Capability, Manifest, MatchResult } from './types';
1
+ import type { Manifest, Capability, MatchResult } from './types';
2
+ export declare class LLMParseError extends Error {
3
+ constructor(message: string);
4
+ }
2
5
  export declare const STOPWORDS: Set<string>;
3
6
  export declare function resolverToIntent(cap: Capability): MatchResult['intent'];
4
7
  /**
@@ -1,4 +1,11 @@
1
1
  import { logger } from './logger';
2
+ // ─── Typed error for LLM parse failures ──────────────────────────────────────
3
+ export class LLMParseError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'LLMParseError';
7
+ }
8
+ }
2
9
  export const STOPWORDS = new Set([
3
10
  'show', 'me', 'the', 'get', 'find', 'fetch', 'give', 'please',
4
11
  'can', 'you', 'i', 'want', 'to', 'a', 'an', 'my', 'our', 'your',
@@ -65,7 +72,7 @@ export function extractParams(query, cap) {
65
72
  for (const param of cap.params) {
66
73
  // Session params come from auth context, not query
67
74
  if (param.source === 'session') {
68
- result[param.name] = '[from_session]';
75
+ result[param.name] = null; // injected by resolver from auth context — not extracted from query
69
76
  continue;
70
77
  }
71
78
  if (param.source !== 'user_query') {
@@ -141,7 +148,8 @@ export function match(query, manifest) {
141
148
  candidates: [],
142
149
  };
143
150
  }
144
- logger.info(`Matching query: "${query}"`);
151
+ logger.info(`Matching query (${query.length} chars)`);
152
+ logger.debug(`Full query: "${query}"`);
145
153
  logger.debug(`Manifest has ${manifest.capabilities.length} capabilities`);
146
154
  let best = null;
147
155
  let bestScore = 0;
@@ -197,7 +205,13 @@ export function match(query, manifest) {
197
205
  * fields can influence LLM routing decisions.
198
206
  */
199
207
  export async function matchWithLLM(query, manifest, options) {
200
- const manifestSummary = manifest.capabilities.map(c => `- ${c.id} (${c.resolver.type}): ${c.description}${c.examples?.length ? `\n examples: ${c.examples.slice(0, 2).join(', ')}` : ''}`).join('\n');
208
+ // Truncate description and examples prevents context window overflow and
209
+ // reduces prompt injection surface from third-party OpenAPI spec content.
210
+ const MAX_DESC_LEN = 200;
211
+ const MAX_EXAMPLE_LEN = 100;
212
+ const manifestSummary = manifest.capabilities.map(c => `- ${c.id} (${c.resolver.type}): ${c.description.slice(0, MAX_DESC_LEN)}${c.description.length > MAX_DESC_LEN ? '…' : ''}${c.examples?.length
213
+ ? `\n examples: ${c.examples.slice(0, 2).map(e => e.slice(0, MAX_EXAMPLE_LEN)).join(', ')}`
214
+ : ''}`).join('\n');
201
215
  const prompt = `You are an intent matcher for an AI agent system.
202
216
 
203
217
  App: ${manifest.app}
@@ -228,13 +242,13 @@ ${JSON.stringify({ user_query: query })}
228
242
  parsed = JSON.parse(clean);
229
243
  }
230
244
  catch {
231
- throw new Error(`LLM_PARSE_ERROR: LLM returned invalid JSON. First 300 chars: ${clean.slice(0, 300)}`);
245
+ throw new LLMParseError(`LLM returned invalid JSON. First 300 chars: ${clean.slice(0, 300)}`);
232
246
  }
233
247
  if (typeof parsed.matched_capability !== 'string') {
234
- throw new Error(`LLM_PARSE_ERROR: missing "matched_capability" field in response`);
248
+ throw new LLMParseError(`missing "matched_capability" field in response`);
235
249
  }
236
250
  if (typeof parsed.confidence !== 'number') {
237
- throw new Error(`LLM_PARSE_ERROR: missing numeric "confidence" field in response`);
251
+ throw new LLMParseError(`missing numeric "confidence" field in response`);
238
252
  }
239
253
  const isOOS = parsed.matched_capability === 'OUT_OF_SCOPE';
240
254
  const capability = isOOS
@@ -245,16 +259,21 @@ ${JSON.stringify({ user_query: query })}
245
259
  if (!isOOS && capability === null) {
246
260
  logger.warn(`LLM returned unknown capability ID: "${parsed.matched_capability}" — treating as out_of_scope`);
247
261
  }
262
+ // Build full candidate list — all capabilities scored, LLM winner marked as matched.
263
+ // This aligns the shape with keyword match results and allows the learning boost
264
+ // to surface alternatives if the LLM made a wrong call.
265
+ const llmConfidence = effectivelyOOS ? 0 : parsed.confidence;
266
+ const allCandidates = manifest.capabilities.map(c => ({
267
+ capabilityId: c.id,
268
+ score: c.id === capability?.id ? llmConfidence : 0,
269
+ matched: c.id === capability?.id,
270
+ }));
248
271
  return {
249
272
  capability,
250
- confidence: effectivelyOOS ? 0 : parsed.confidence,
273
+ confidence: llmConfidence,
251
274
  intent: effectivelyOOS ? 'out_of_scope' : parsed.intent,
252
275
  extractedParams: (parsed.extracted_params ?? {}),
253
276
  reasoning: parsed.reasoning ?? 'No reasoning provided',
254
- candidates: capability ? [{
255
- capabilityId: capability.id,
256
- score: parsed.confidence,
257
- matched: true,
258
- }] : [],
277
+ candidates: allCandidates,
259
278
  };
260
279
  }
@@ -11,7 +11,7 @@ async function loadSpec(source) {
11
11
  if (source.startsWith('http://') || source.startsWith('https://')) {
12
12
  logger.info(`Fetching OpenAPI spec from: ${source}`);
13
13
  const controller = new AbortController();
14
- const timer = setTimeout(() => controller.abort(), 10000);
14
+ const timer = setTimeout(() => controller.abort(), 10_000);
15
15
  // eslint-disable-next-line prefer-const
16
16
  let res;
17
17
  try {
@@ -36,7 +36,7 @@ async function loadSpec(source) {
36
36
  throw new Error(`Spec file not found: ${resolved}`);
37
37
  }
38
38
  logger.info(`Reading OpenAPI spec from: ${resolved}`);
39
- const text = fs.readFileSync(resolved, 'utf-8');
39
+ const text = await fs.promises.readFile(resolved, 'utf-8');
40
40
  return parseSpecText(text, source);
41
41
  }
42
42
  function parseSpecText(text, source) {
@@ -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>;