capman 0.5.3 → 0.5.5

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 (54) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/CODEBASE.md +115 -65
  3. package/README.md +45 -4
  4. package/bin/lib/cmd-explain.js +2 -2
  5. package/bin/lib/cmd-generate.js +44 -28
  6. package/bin/lib/cmd-run.js +2 -2
  7. package/bin/lib/shared.js +8 -2
  8. package/dist/cjs/cache.d.ts.map +1 -1
  9. package/dist/cjs/cache.js +22 -5
  10. package/dist/cjs/cache.js.map +1 -1
  11. package/dist/cjs/engine.d.ts +30 -0
  12. package/dist/cjs/engine.d.ts.map +1 -1
  13. package/dist/cjs/engine.js +87 -36
  14. package/dist/cjs/engine.js.map +1 -1
  15. package/dist/cjs/generator.d.ts.map +1 -1
  16. package/dist/cjs/generator.js +7 -1
  17. package/dist/cjs/generator.js.map +1 -1
  18. package/dist/cjs/learning.d.ts.map +1 -1
  19. package/dist/cjs/learning.js +39 -12
  20. package/dist/cjs/learning.js.map +1 -1
  21. package/dist/cjs/matcher.d.ts +18 -10
  22. package/dist/cjs/matcher.d.ts.map +1 -1
  23. package/dist/cjs/matcher.js +140 -29
  24. package/dist/cjs/matcher.js.map +1 -1
  25. package/dist/cjs/parser.d.ts.map +1 -1
  26. package/dist/cjs/parser.js +15 -8
  27. package/dist/cjs/parser.js.map +1 -1
  28. package/dist/cjs/resolver.d.ts +1 -0
  29. package/dist/cjs/resolver.d.ts.map +1 -1
  30. package/dist/cjs/resolver.js +16 -5
  31. package/dist/cjs/resolver.js.map +1 -1
  32. package/dist/cjs/schema.d.ts +18 -18
  33. package/dist/cjs/schema.js +1 -1
  34. package/dist/cjs/schema.js.map +1 -1
  35. package/dist/cjs/types.d.ts +1 -1
  36. package/dist/cjs/types.d.ts.map +1 -1
  37. package/dist/cjs/version.d.ts +1 -1
  38. package/dist/cjs/version.js +1 -1
  39. package/dist/esm/cache.js +22 -5
  40. package/dist/esm/engine.d.ts +30 -0
  41. package/dist/esm/engine.js +89 -38
  42. package/dist/esm/generator.js +7 -1
  43. package/dist/esm/learning.js +39 -12
  44. package/dist/esm/matcher.d.ts +18 -10
  45. package/dist/esm/matcher.js +137 -29
  46. package/dist/esm/parser.js +15 -8
  47. package/dist/esm/resolver.d.ts +1 -0
  48. package/dist/esm/resolver.js +16 -6
  49. package/dist/esm/schema.d.ts +18 -18
  50. package/dist/esm/schema.js +1 -1
  51. package/dist/esm/types.d.ts +1 -1
  52. package/dist/esm/version.d.ts +1 -1
  53. package/dist/esm/version.js +1 -1
  54. package/package.json +11 -10
@@ -1,8 +1,8 @@
1
1
  import { match as _match, matchWithLLM as _matchWithLLM, resolverToIntent, extractParams, STOPWORDS, LLMParseError } from './matcher';
2
- import { resolve as _resolve } from './resolver';
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 {
@@ -25,6 +25,8 @@ export class CapmanEngine {
25
25
  this.llmCooldownMs = options.llmCooldownMs ?? 0;
26
26
  this.llmCircuitBreakerThreshold = options.llmCircuitBreakerThreshold ?? 3;
27
27
  this.llmCircuitBreakerResetMs = options.llmCircuitBreakerResetMs ?? 60_000;
28
+ this.fuzzyMatch = options.fuzzyMatch ?? false;
29
+ this.fuzzyThreshold = options.fuzzyThreshold ?? 0.4;
28
30
  // Cache — default MemoryCache (no filesystem writes), or disabled with false
29
31
  // Use FileCache or ComboCache explicitly for persistence across restarts
30
32
  this.cache = options.cache === false
@@ -37,23 +39,7 @@ export class CapmanEngine {
37
39
  : (options.learning ?? new MemoryLearningStore());
38
40
  logger.info(`CapmanEngine initialized — mode: ${this.mode}, cache: ${this.cache ? 'enabled' : 'disabled'}, learning: ${this.learning ? 'enabled' : 'disabled'}`);
39
41
  // ── Manifest version compatibility check ─────────────────────────────────
40
- if (options.manifest.version) {
41
- const SEMVER_RE = /^\d+\.\d+\.\d+$/;
42
- if (SEMVER_RE.test(options.manifest.version) && SEMVER_RE.test(VERSION)) {
43
- const [mMaj, mMin] = options.manifest.version.split('.').map(Number);
44
- const [eMaj, eMin] = VERSION.split('.').map(Number);
45
- if (mMaj !== eMaj || mMin !== eMin) {
46
- console.warn(`[capman] Manifest version "${options.manifest.version}" was generated with a ` +
47
- `different engine version than "${VERSION}". This is usually fine across patch versions. ` +
48
- `If you experience unexpected matching issues, regenerate with: npx capman generate`);
49
- }
50
- }
51
- else if (options.manifest.version !== VERSION) {
52
- //console.warn is used instead of logger.warn to avoid the warning being logged to the console
53
- console.warn(`[capman] Manifest version "${options.manifest.version}" could not be compared ` +
54
- `to engine version "${VERSION}" — version strings are not valid semver.`);
55
- }
56
- }
42
+ this.checkManifestVersion(options.manifest);
57
43
  }
58
44
  /**
59
45
  * Ask the engine a natural language query.
@@ -121,17 +107,20 @@ export class CapmanEngine {
121
107
  }
122
108
  // ── Step 2: Match ────────────────────────────────────────────────────────
123
109
  let { matchResult, resolvedVia } = await this._runMatch(query, steps);
124
- const preBoostMatchResult = matchResult; // kept for learning recording onlyprevents feedback loop
110
+ // Shallow copy with candidates slicenot a reference alias.
111
+ // applyBoostToMatchResult() returns a new object today, but an explicit
112
+ // copy makes the invariant clear and safe against future in-place mutation.
113
+ const preBoostMatchResult = { ...matchResult, candidates: matchResult.candidates.slice() };
125
114
  // ── Step 2.5: Apply learning boost ───────────────────────────────────────
126
- matchResult = await this.applyBoostToMatchResult(query, matchResult);
115
+ matchResult = await this.applyBoostToMatchResult(query, matchResult, resolvedVia);
127
116
  // ── Step 3: Privacy check ────────────────────────────────────────────────
128
117
  if (matchResult.capability) {
129
- const privacyLevel = matchResult.capability.privacy.level;
118
+ const privacyError = checkPrivacy(matchResult.capability, this.auth);
130
119
  steps.push({
131
120
  type: 'privacy_check',
132
- status: 'pass',
121
+ status: privacyError ? 'fail' : 'pass',
133
122
  durationMs: 0,
134
- detail: `level: ${privacyLevel}`,
123
+ detail: privacyError ?? `level: ${matchResult.capability.privacy.level}`,
135
124
  });
136
125
  }
137
126
  // ── Step 4: Resolve ──────────────────────────────────────────────────────
@@ -144,14 +133,17 @@ export class CapmanEngine {
144
133
  detail: resolution.error ?? `via ${resolution.resolverType}`,
145
134
  });
146
135
  // ── Step 5: Cache after successful resolution ────────────────────────────
147
- // Only cache when resolution succeeded — a failed resolution (network error,
148
- // auth failure, bad params) must not poison the cache. A cached failed match
149
- // would cause every subsequent cache hit to attempt the same failing resolution
150
- // until TTL expires.
136
+ // Write under two keys:
137
+ // 1. normalizeQuery exact phrasing lookup for this query
138
+ // 2. buildCacheKey semantic key (capability + params) so differently-phrased
139
+ // queries that resolve to the same capability share a cache entry
151
140
  if (this.cache && resolution.success && matchResult.capability
152
141
  && matchResult.capability.privacy.level === 'public') {
153
142
  const queryKey = normalizeQuery(query);
143
+ const capKey = buildCacheKey(query, matchResult.capability.id, matchResult.extractedParams);
154
144
  await this.cache.set(queryKey, matchResult);
145
+ await this.cache.set(capKey, matchResult);
146
+ // capKey always starts with 'cap:' — structurally distinct from queryKey
155
147
  }
156
148
  // ── Step 6: Build reasoning array ────────────────────────────────────────
157
149
  const reasoning = [];
@@ -223,6 +215,45 @@ export class CapmanEngine {
223
215
  if (this.cache)
224
216
  await this.cache.clear();
225
217
  }
218
+ checkManifestVersion(manifest) {
219
+ if (!manifest.version)
220
+ return;
221
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
222
+ if (SEMVER_RE.test(manifest.version) && SEMVER_RE.test(VERSION)) {
223
+ const [mMaj, mMin] = manifest.version.split('.').map(Number);
224
+ const [eMaj, eMin] = VERSION.split('.').map(Number);
225
+ if (mMaj !== eMaj || mMin !== eMin) {
226
+ console.warn(`[capman] Manifest version "${manifest.version}" was generated with a ` +
227
+ `different engine version than "${VERSION}". This is usually fine across patch versions. ` +
228
+ `If you experience unexpected matching issues, regenerate with: npx capman generate`);
229
+ }
230
+ }
231
+ else if (manifest.version !== VERSION) {
232
+ console.warn(`[capman] Manifest version "${manifest.version}" could not be compared ` +
233
+ `to engine version "${VERSION}" — version strings are not valid semver.`);
234
+ }
235
+ }
236
+ /**
237
+ * Replaces the active manifest without creating a new engine instance.
238
+ * Useful for hot-reloading manifests in long-running servers without
239
+ * losing cache, learning history, or rate limiter state.
240
+ *
241
+ * Note: clears the cache automatically — cached results from the old
242
+ * manifest are no longer valid after the manifest changes.
243
+ *
244
+ * @example
245
+ * const newManifest = generate(updatedConfig)
246
+ * await engine.loadManifest(newManifest)
247
+ */
248
+ async loadManifest(manifest) {
249
+ this.checkManifestVersion(manifest);
250
+ this.manifest = manifest;
251
+ await this.clearCache();
252
+ // Note: LLM rate limiter state (llmCallsThisMinute, llmConsecutiveFails,
253
+ // llmCircuitOpenAt) is intentionally preserved across manifest reloads.
254
+ // The LLM provider has not changed, so circuit breaker state remains valid.
255
+ // If you need a clean rate limiter state, create a new CapmanEngine instance.
256
+ }
226
257
  /**
227
258
  * Explain what would happen for a query — without executing it.
228
259
  * Shows matched capability, all candidate scores with reasoning,
@@ -258,8 +289,9 @@ export class CapmanEngine {
258
289
  }
259
290
  let resolvedVia = _resolvedVia;
260
291
  // ── Apply learning boost (same as ask()) ─────────────────────────────────
261
- matchResult = await this.applyBoostToMatchResult(query, matchResult);
292
+ matchResult = await this.applyBoostToMatchResult(query, matchResult, resolvedVia);
262
293
  // ── Build candidate explanations ─────────────────────────────────────────
294
+ const qWordSet = new Set(query.toLowerCase().split(/\W+/).filter(Boolean));
263
295
  const candidates = matchResult.candidates
264
296
  .sort((a, b) => b.score - a.score)
265
297
  .map(c => {
@@ -272,10 +304,9 @@ export class CapmanEngine {
272
304
  explanation = `Strong match (${c.score}%) — query closely matches examples`;
273
305
  }
274
306
  else if (c.score >= 50) {
275
- const qWords = query.toLowerCase().split(/\W+/).filter(Boolean);
276
307
  const matchedWords = (cap?.examples ?? [])
277
308
  .flatMap(e => e.toLowerCase().split(/\s+/))
278
- .filter(w => qWords.includes(w) && w.length > 2);
309
+ .filter(w => qWordSet.has(w) && w.length > 2);
279
310
  const unique = [...new Set(matchedWords)].slice(0, 3);
280
311
  explanation = unique.length
281
312
  ? `Matched keywords: ${unique.join(', ')} (${c.score}%)`
@@ -421,8 +452,10 @@ export class CapmanEngine {
421
452
  this.llmCallsThisMinute = 0;
422
453
  this.llmWindowStart = now;
423
454
  }
455
+ if (this.maxLLMCallsPerMinute === 0) {
456
+ return 'LLM disabled — maxLLMCallsPerMinute is 0';
457
+ }
424
458
  if (this.llmCallsThisMinute >= this.maxLLMCallsPerMinute) {
425
- // Recalculate elapsed after possible window reset above
426
459
  const resetIn = Math.ceil((60_000 - (now - this.llmWindowStart)) / 1000);
427
460
  return `rate limit reached (${this.maxLLMCallsPerMinute}/min) — resets in ${Math.max(0, resetIn)}s`;
428
461
  }
@@ -441,6 +474,10 @@ export class CapmanEngine {
441
474
  * Records a failed LLM call — may open the circuit breaker.
442
475
  */
443
476
  recordLLMFailure() {
477
+ // Refund the rate-limit slot — the call failed so it shouldn't count
478
+ // against the per-minute quota. Without this, sustained failures
479
+ // exhaust the limit prematurely and silently degrade to keyword-only.
480
+ this.llmCallsThisMinute = Math.max(0, this.llmCallsThisMinute - 1);
444
481
  this.llmConsecutiveFails++;
445
482
  if (this.llmConsecutiveFails >= this.llmCircuitBreakerThreshold) {
446
483
  this.llmCircuitOpenAt = Date.now();
@@ -455,6 +492,11 @@ export class CapmanEngine {
455
492
  async _runMatch(query, steps) {
456
493
  let matchResult;
457
494
  let resolvedVia = 'keyword';
495
+ // Fuzzy options — never applied in cheap mode
496
+ const fuzzyOpts = {
497
+ fuzzyMatch: this.fuzzyMatch,
498
+ fuzzyThreshold: this.fuzzyThreshold,
499
+ };
458
500
  switch (this.mode) {
459
501
  case 'cheap': {
460
502
  const t = Date.now();
@@ -464,11 +506,13 @@ export class CapmanEngine {
464
506
  }
465
507
  case 'accurate': {
466
508
  if (this.llm) {
509
+ // Rate limiter shared between ask() and explain() — explain() counts
510
+ // against the same quota since it makes real LLM calls.
467
511
  const skipReason = this.checkLLMAllowed();
468
512
  if (skipReason) {
469
513
  logger.warn(`LLM skipped — ${skipReason} — falling back to keyword`);
470
514
  const t = Date.now();
471
- matchResult = _match(query, this.manifest);
515
+ matchResult = _match(query, this.manifest, fuzzyOpts);
472
516
  steps?.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t, detail: `llm skipped: ${skipReason}` });
473
517
  }
474
518
  else {
@@ -478,7 +522,7 @@ export class CapmanEngine {
478
522
  this.recordLLMSuccess();
479
523
  resolvedVia = 'llm';
480
524
  // Merge keyword scores into LLM candidates so boost has real signal for alternatives
481
- const kwResult = _match(query, this.manifest);
525
+ const kwResult = _match(query, this.manifest, fuzzyOpts);
482
526
  matchResult = {
483
527
  ...matchResult,
484
528
  candidates: matchResult.candidates.map(c => ({
@@ -496,7 +540,7 @@ export class CapmanEngine {
496
540
  this.recordLLMFailure();
497
541
  logger.warn(`LLM call failed — falling back to keyword: ${err instanceof Error ? err.message : String(err)}`);
498
542
  const t2 = Date.now();
499
- matchResult = _match(query, this.manifest);
543
+ matchResult = _match(query, this.manifest, fuzzyOpts);
500
544
  steps?.push({ type: 'llm_match', status: 'fail', durationMs: Date.now() - t, detail: String(err) });
501
545
  steps?.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t2, detail: 'fallback after llm failure' });
502
546
  }
@@ -505,7 +549,7 @@ export class CapmanEngine {
505
549
  else {
506
550
  logger.warn('accurate mode requires llm — falling back to keyword');
507
551
  const t = Date.now();
508
- matchResult = _match(query, this.manifest);
552
+ matchResult = _match(query, this.manifest, fuzzyOpts);
509
553
  steps?.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t, detail: 'llm not provided, used keyword' });
510
554
  }
511
555
  break;
@@ -513,12 +557,14 @@ export class CapmanEngine {
513
557
  case 'balanced':
514
558
  default: {
515
559
  const t1 = Date.now();
516
- const keywordResult = _match(query, this.manifest);
560
+ const keywordResult = _match(query, this.manifest, fuzzyOpts);
517
561
  steps?.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t1, detail: `confidence: ${keywordResult.confidence}%` });
518
562
  if (keywordResult.confidence >= this.threshold || !this.llm) {
519
563
  matchResult = keywordResult;
520
564
  }
521
565
  else {
566
+ // Rate limiter shared between ask() and explain() — explain() counts
567
+ // against the same quota since it makes real LLM calls.
522
568
  const skipReason = this.checkLLMAllowed();
523
569
  if (skipReason) {
524
570
  logger.warn(`LLM skipped — ${skipReason}`);
@@ -564,7 +610,12 @@ export class CapmanEngine {
564
610
  * Applies learning boost to a MatchResult and returns the updated result.
565
611
  * Shared by ask() and explain() to avoid logic divergence.
566
612
  */
567
- async applyBoostToMatchResult(query, matchResult) {
613
+ async applyBoostToMatchResult(query, matchResult, resolvedVia = 'keyword') {
614
+ // Skip boost when LLM matched with high confidence — learning signal is
615
+ // less reliable than a strong LLM result and could incorrectly override it.
616
+ // Threshold 80% leaves room for boost to help on borderline LLM matches.
617
+ if (resolvedVia === 'llm' && matchResult.confidence > 80)
618
+ return matchResult;
568
619
  const hasKeywordSignal = matchResult.candidates.some(c => c.score > 0);
569
620
  if (!hasKeywordSignal || matchResult.candidates.length === 0 || !this.learning || this.mode === 'cheap') {
570
621
  return matchResult;
@@ -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
  }
@@ -6,12 +6,38 @@ import { STOPWORDS } 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 = {};
@@ -120,12 +146,7 @@ export class FileLearningStore {
120
146
  this.filePath = resolved;
121
147
  logger.info(`FileLearningStore initialized — writing to: ${this.filePath}`);
122
148
  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
- }
149
+ registerExitHandlers();
129
150
  }
130
151
  flushSync() {
131
152
  // Cancel pending timer — prevents scheduleSave firing after sync write
@@ -160,15 +181,17 @@ export class FileLearningStore {
160
181
  }
161
182
  if (this.dirty) {
162
183
  this.dirty = false;
163
- // Await final flush before removing from registry —
164
- // ensures data is written before the store becomes unreachable
165
184
  await this.save();
166
185
  }
167
186
  activeStores.delete(this);
187
+ unregisterExitHandlers(); // remove handlers if no stores remain
168
188
  }
169
189
  load() {
170
190
  if (!this.loadPromise) {
171
- this.loadPromise = this._doLoad();
191
+ this.loadPromise = this._doLoad().catch(err => {
192
+ this.loadPromise = null; // allow retry on next call
193
+ throw err;
194
+ });
172
195
  }
173
196
  return this.loadPromise;
174
197
  }
@@ -185,8 +208,12 @@ export class FileLearningStore {
185
208
  logger.warn(`Learning store at ${this.filePath} contained unexpected format — starting fresh`);
186
209
  }
187
210
  }
188
- catch {
189
- // File doesn't exist yet — start fresh
211
+ catch (err) {
212
+ const code = err.code;
213
+ if (code !== 'ENOENT') {
214
+ logger.warn(`Failed to load learning store from ${this.filePath} (${code ?? 'unknown error'}) — starting fresh`);
215
+ }
216
+ // ENOENT = file doesn't exist yet — expected on first run, no warning needed
190
217
  }
191
218
  }
192
219
  scheduleSave(urgencyMs = 5_000) {
@@ -11,23 +11,31 @@ export declare function resolverToIntent(cap: Capability): MatchResult['intent']
11
11
  * - Extracts single tokens only — "jane smith" would extract "jane"
12
12
  * - Keyword matching is positional — "articles from authors I follow"
13
13
  * may extract "authors" instead of nothing, since "from" is a keyword
14
- * - For complex or ambiguous queries, use matchWithLLM() which handles
15
- * param extraction more accurately via the LLM prompt
14
+ * - Required param fallback grabs the last meaningful word — "list all
15
+ * recent orders" may extract "orders" even with the denylist extended.
16
+ * For precise extraction of complex queries, use matchWithLLM() which
17
+ * handles param extraction via structured LLM prompt.
18
+ * - To support richer extraction patterns, add a `pattern` field to
19
+ * CapabilityParam in a future version.
16
20
  */
17
21
  export declare function extractParams(query: string, cap: Capability): Record<string, string | null>;
18
- export declare function match(query: string, manifest: Manifest): MatchResult;
22
+ export interface MatchOptions {
23
+ fuzzyMatch?: boolean;
24
+ fuzzyThreshold?: number;
25
+ }
26
+ export declare function match(query: string, manifest: Manifest, options?: MatchOptions): MatchResult;
19
27
  export interface LLMMatcherOptions {
20
28
  llm: (prompt: string) => Promise<string>;
21
29
  }
22
30
  /**
23
31
  * Matches a query to a capability using an LLM.
24
32
  *
25
- * ⚠️ SECURITY NOTE: Capability `description` and `examples` fields from the
26
- * manifest are injected verbatim into the LLM prompt (system portion).
27
- * In a solo deployment with a developer-controlled manifest this is safe.
28
- * If your manifest is generated from third-party OpenAPI specs, user-controlled
29
- * sources, or any external input, sanitize `description` and `examples` fields
30
- * before passing the manifest to this function adversarial content in those
31
- * fields can influence LLM routing decisions.
33
+ * ⚠️ SECURITY NOTE: Capability fields are sanitized before injection into
34
+ * the LLM prompt (newlines stripped, delimiters neutralized, length capped).
35
+ * However, the current interface passes a single prompt string it cannot
36
+ * provide true system/user message separation that some LLM APIs support.
37
+ * For maximum injection resistance in high-security deployments, use an LLM
38
+ * wrapper that maps the prompt to a proper system message, keeping user query
39
+ * data in the user turn only.
32
40
  */
33
41
  export declare function matchWithLLM(query: string, manifest: Manifest, options: LLMMatcherOptions): Promise<MatchResult>;