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
@@ -3,20 +3,80 @@ export declare class LLMParseError extends Error {
3
3
  constructor(message: string);
4
4
  }
5
5
  export declare const STOPWORDS: Set<string>;
6
+ /**
7
+ * Regex patterns for common param types.
8
+ * Used when a CapabilityParam has `pattern` set to a named type.
9
+ */
10
+ export declare const TYPE_PATTERNS: Record<string, RegExp>;
11
+ /**
12
+ * Simplified suffix-stripping stemmer — 10 most common English morphological
13
+ * patterns covering ~80% of benefit at ~25% the complexity of Porter stemmer.
14
+ * Applied symmetrically to both query words and capability index words.
15
+ */
16
+ export declare function stem(word: string): string;
17
+ /**
18
+ * Shared tokenizer — used by scorer, learning index, and boost system.
19
+ * Applies stopword filtering AND stemming symmetrically.
20
+ * Any site that tokenizes text for matching MUST use this function
21
+ * to avoid silent mismatches between query and index tokens.
22
+ */
23
+ export declare function tokenize(text: string): string[];
24
+ export interface BM25Index {
25
+ /** Document frequency — how many capabilities contain each term */
26
+ df: Record<string, number>;
27
+ /** Average field length per field type */
28
+ avgdl: {
29
+ examples: number;
30
+ description: number;
31
+ name: number;
32
+ };
33
+ /** Total number of capabilities */
34
+ N: number;
35
+ /** Bigram sets per capability — post-stopword, post-stem, examples only */
36
+ bigrams: Record<string, Set<string>>;
37
+ }
38
+ /** Build a BM25 index over all capabilities. Call once at manifest load. */
39
+ export declare function buildBM25Index(capabilities: Capability[]): BM25Index;
40
+ /**
41
+ * BM25 scoring with field weights.
42
+ * k1 = 1.5 (TF saturation), b = 0.75 (length normalization)
43
+ * Field weights: examples 0.6, description 0.3, name 0.1
44
+ */
45
+ export declare function scoreCapability(qWordSet: Set<string>, cap: Capability, index: BM25Index, k1?: number, b?: number): number;
46
+ /**
47
+ * Extracts bigrams from a token array as "token1__token2" strings.
48
+ * Input must already be post-stopword and post-stem (use tokenize() first).
49
+ */
50
+ export declare function extractBigrams(tokens: string[]): Set<string>;
6
51
  export declare function resolverToIntent(cap: Capability): MatchResult['intent'];
52
+ /**
53
+ * Strips characters that could break LLM prompt structure from
54
+ * capability field values before injection into the system prompt.
55
+ * Removes control characters, newlines, and delimiter-like sequences.
56
+ */
57
+ export declare function sanitizeForPrompt(value: string, maxLen: number): string;
7
58
  /**
8
59
  * Extracts parameter values from a user query using keyword heuristics.
60
+ *
9
61
  * Known limits:
10
62
  * - Extracts single tokens only — "jane smith" would extract "jane"
11
63
  * - Keyword matching is positional — "articles from authors I follow"
12
64
  * may extract "authors" instead of nothing, since "from" is a keyword
13
- * - For complex or ambiguous queries, use matchWithLLM() which handles
14
- * param extraction more accurately via the LLM prompt
65
+ * - Required param fallback grabs the last meaningful word — "list all
66
+ * recent orders" may extract "orders" even with the denylist extended.
67
+ * For precise extraction of complex queries, use matchWithLLM() which
68
+ * handles param extraction via structured LLM prompt.
69
+ * - To support richer extraction patterns, add a `pattern` field to
70
+ * CapabilityParam in a future version.
15
71
  */
16
72
  export declare function extractParams(query: string, cap: Capability): Record<string, string | null>;
17
73
  export interface MatchOptions {
18
74
  fuzzyMatch?: boolean;
19
75
  fuzzyThreshold?: number;
76
+ bm25Index?: BM25Index;
77
+ bm25K1?: number;
78
+ bm25B?: number;
79
+ bm25Ceiling?: number;
20
80
  }
21
81
  export declare function match(query: string, manifest: Manifest, options?: MatchOptions): MatchResult;
22
82
  export interface LLMMatcherOptions {
@@ -25,12 +85,12 @@ export interface LLMMatcherOptions {
25
85
  /**
26
86
  * Matches a query to a capability using an LLM.
27
87
  *
28
- * ⚠️ SECURITY NOTE: Capability `description` and `examples` fields from the
29
- * manifest are injected verbatim into the LLM prompt (system portion).
30
- * In a solo deployment with a developer-controlled manifest this is safe.
31
- * If your manifest is generated from third-party OpenAPI specs, user-controlled
32
- * sources, or any external input, sanitize `description` and `examples` fields
33
- * before passing the manifest to this function adversarial content in those
34
- * fields can influence LLM routing decisions.
88
+ * ⚠️ SECURITY NOTE: Capability fields are sanitized before injection into
89
+ * the LLM prompt (newlines stripped, delimiters neutralized, length capped).
90
+ * However, the current interface passes a single prompt string it cannot
91
+ * provide true system/user message separation that some LLM APIs support.
92
+ * For maximum injection resistance in high-security deployments, use an LLM
93
+ * wrapper that maps the prompt to a proper system message, keeping user query
94
+ * data in the user turn only.
35
95
  */
36
96
  export declare function matchWithLLM(query: string, manifest: Manifest, options: LLMMatcherOptions): Promise<MatchResult>;
@@ -18,40 +18,226 @@ export const STOPWORDS = new Set([
18
18
  'it', 'its', 'how', 'when', 'where', 'who', 'which', 'all',
19
19
  'just', 'some', 'any', 'there', 'their', 'them', 'they',
20
20
  ]);
21
- function filterStopwords(words) {
22
- return words.filter(w => !STOPWORDS.has(w.toLowerCase()) && w.length > 1);
23
- }
24
- function scoreCapability(query, cap) {
21
+ // ─── Type Patterns ────────────────────────────────────────────────────────────
22
+ /**
23
+ * Regex patterns for common param types.
24
+ * Used when a CapabilityParam has `pattern` set to a named type.
25
+ */
26
+ export const TYPE_PATTERNS = {
27
+ email: /\b[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}\b/,
28
+ date: /\b\d{4}-\d{2}-\d{2}\b|\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\s+\d{1,2}\b/i,
29
+ orderId: /\b[A-Z]{2,}-?\d{4,}\b|\b\d{6,}\b/,
30
+ url: /https?:\/\/[^\s]+/,
31
+ };
32
+ /**
33
+ * Extracts a value from a query using an example template pattern.
34
+ * e.g. template "order {orderId}", query "track order 12345" → "12345"
35
+ * e.g. template "booking {ref}", query "cancel booking ABC-001" → "ABC-001"
36
+ */
37
+ function extractFromTemplate(query, template, paramName) {
38
+ // Split template on {paramName} to get prefix and suffix
39
+ const placeholder = `{${paramName}}`;
40
+ const idx = template.indexOf(placeholder);
41
+ if (idx === -1)
42
+ return null;
43
+ const prefix = template.slice(0, idx).trim().toLowerCase();
44
+ const suffix = template.slice(idx + placeholder.length).trim().toLowerCase();
25
45
  const q = query.toLowerCase();
46
+ if (prefix) {
47
+ const prefixIdx = q.indexOf(prefix);
48
+ if (prefixIdx === -1)
49
+ return null;
50
+ const after = query.slice(prefixIdx + prefix.length).trim();
51
+ const tokens = after.split(/\s+/).filter(t => t.length > 0);
52
+ if (!tokens.length)
53
+ return null;
54
+ // If there's a suffix, find it and take what's between
55
+ if (suffix) {
56
+ const suffixIdx = after.toLowerCase().indexOf(suffix);
57
+ if (suffixIdx > 0) {
58
+ return after.slice(0, suffixIdx).trim().split(/\s+/)[0] ?? null;
59
+ }
60
+ }
61
+ return tokens[0].replace(/[^a-zA-Z0-9\-_.@]/g, '') || null;
62
+ }
63
+ // Prefix is empty — placeholder is at start of template e.g. "{email} unsubscribe"
64
+ if (!prefix) {
65
+ if (suffix) {
66
+ // Find suffix in query — take what comes before it
67
+ const suffixIdx = query.toLowerCase().indexOf(suffix);
68
+ if (suffixIdx > 0) {
69
+ return query.slice(0, suffixIdx).trim().split(/\s+/).pop()
70
+ ?.replace(/[^a-zA-Z0-9\-_.@]/g, '') || null;
71
+ }
72
+ }
73
+ // No prefix, no suffix — template is just "{paramName}"; take last meaningful word
74
+ const words = query.trim().split(/\s+/);
75
+ return words[words.length - 1]?.replace(/[^a-zA-Z0-9\-_.@]/g, '') || null;
76
+ }
77
+ return null;
78
+ }
79
+ // ─── Stem cache ───────────────────────────────────────────────────────────────
80
+ // Each word stemmed exactly once per process — O(1) on repeat lookups.
81
+ // Module-level — persists for the process lifetime. Vocabulary in production
82
+ // is finite (capability names + user query vocabulary) so growth is bounded
83
+ // in practice. In test environments with synthetic random strings, this may
84
+ // grow larger but remains functionally harmless.
85
+ const stemCache = new Map();
86
+ /**
87
+ * Simplified suffix-stripping stemmer — 10 most common English morphological
88
+ * patterns covering ~80% of benefit at ~25% the complexity of Porter stemmer.
89
+ * Applied symmetrically to both query words and capability index words.
90
+ */
91
+ export function stem(word) {
92
+ const cached = stemCache.get(word);
93
+ if (cached !== undefined)
94
+ return cached;
95
+ let s = word;
96
+ if (s.length > 7 && s.endsWith('ation'))
97
+ s = s.slice(0, -5); // cancellation → cancell
98
+ else if (s.length > 6 && s.endsWith('tion'))
99
+ s = s.slice(0, -4); // completion → comple
100
+ else if (s.length > 6 && s.endsWith('ing'))
101
+ s = s.slice(0, -3); // tracking → track
102
+ else if (s.length > 6 && s.endsWith('ity'))
103
+ s = s.slice(0, -3); // availability → availabil
104
+ else if (s.length > 5 && s.endsWith('ion'))
105
+ s = s.slice(0, -3); // version → vers
106
+ else if (s.length > 6 && s.endsWith('est'))
107
+ s = s.slice(0, -3); // fastest → fast
108
+ else if (s.length > 4 && s.endsWith('er'))
109
+ s = s.slice(0, -2); // tracker → track
110
+ else if (s.length > 4 && s.endsWith('ed'))
111
+ s = s.slice(0, -2); // ordered → order
112
+ else if (s.length > 4 && s.endsWith('ly'))
113
+ s = s.slice(0, -2); // quickly → quick
114
+ else if (s.length > 4 && s.endsWith('es'))
115
+ s = s.slice(0, -2); // fetches → fetch
116
+ else if (s.length > 3 && s.endsWith('s') &&
117
+ !s.endsWith('ss'))
118
+ s = s.slice(0, -1); // orders → order
119
+ stemCache.set(word, s);
120
+ return s;
121
+ }
122
+ /**
123
+ * Shared tokenizer — used by scorer, learning index, and boost system.
124
+ * Applies stopword filtering AND stemming symmetrically.
125
+ * Any site that tokenizes text for matching MUST use this function
126
+ * to avoid silent mismatches between query and index tokens.
127
+ */
128
+ export function tokenize(text) {
129
+ return text
130
+ .toLowerCase()
131
+ .split(/\W+/)
132
+ .filter(w => w.length > 2 && !STOPWORDS.has(w))
133
+ .map(stem);
134
+ }
135
+ /** Build a BM25 index over all capabilities. Call once at manifest load. */
136
+ export function buildBM25Index(capabilities) {
137
+ const N = capabilities.length;
138
+ if (N === 0)
139
+ return { df: {}, avgdl: { examples: 0, description: 0, name: 0 }, N: 0, bigrams: {}, };
140
+ const df = {};
141
+ let totalExLen = 0;
142
+ let totalDescLen = 0;
143
+ let totalNameLen = 0;
144
+ for (const cap of capabilities) {
145
+ const exTokens = tokenize((cap.examples ?? []).join(' '));
146
+ const descTokens = tokenize(cap.description);
147
+ const nameTokens = tokenize(cap.name);
148
+ totalExLen += exTokens.length;
149
+ totalDescLen += descTokens.length;
150
+ totalNameLen += nameTokens.length;
151
+ // Count document frequency — each term counted once per capability
152
+ const seen = new Set();
153
+ for (const t of [...exTokens, ...descTokens, ...nameTokens]) {
154
+ if (!seen.has(t)) {
155
+ df[t] = (df[t] ?? 0) + 1;
156
+ seen.add(t);
157
+ }
158
+ }
159
+ }
160
+ // Build bigram sets per capability — examples field only
161
+ // Clean bigrams only: post-stopword, post-stem tokens
162
+ const bigrams = {};
163
+ for (const cap of capabilities) {
164
+ const set = new Set();
165
+ for (const example of cap.examples ?? []) {
166
+ for (const bg of extractBigrams(tokenize(example)))
167
+ set.add(bg);
168
+ }
169
+ bigrams[cap.id] = set;
170
+ }
171
+ return {
172
+ df,
173
+ avgdl: {
174
+ examples: totalExLen / N,
175
+ description: totalDescLen / N,
176
+ name: totalNameLen / N,
177
+ },
178
+ N,
179
+ bigrams,
180
+ };
181
+ }
182
+ /**
183
+ * BM25 scoring with field weights.
184
+ * k1 = 1.5 (TF saturation), b = 0.75 (length normalization)
185
+ * Field weights: examples 0.6, description 0.3, name 0.1
186
+ */
187
+ export function scoreCapability(qWordSet, cap, index, k1 = 1.5, b = 0.75) {
188
+ if (index.N === 0)
189
+ return 0;
190
+ const score = bm25Field(qWordSet, tokenize((cap.examples ?? []).join(' ')), index, 'examples', k1, b) * 0.6
191
+ + bm25Field(qWordSet, tokenize(cap.description), index, 'description', k1, b) * 0.3
192
+ + bm25Field(qWordSet, tokenize(cap.name), index, 'name', k1, b) * 0.1;
193
+ return score;
194
+ }
195
+ function bm25Field(queryTerms, fieldTokens, index, field, k1, b) {
196
+ if (fieldTokens.length === 0)
197
+ return 0;
198
+ const avgdl = index.avgdl[field] || 1;
199
+ const dl = fieldTokens.length;
200
+ const tf = new Map();
201
+ for (const t of fieldTokens) {
202
+ tf.set(t, (tf.get(t) ?? 0) + 1);
203
+ }
26
204
  let score = 0;
27
- const qWords = filterStopwords(q.split(/\W+/).filter(Boolean));
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;
33
- for (const example of cap.examples ?? []) {
34
- const exWords = filterStopwords(example.toLowerCase().split(/\s+/));
35
- if (exWords.length === 0)
205
+ for (const term of queryTerms) {
206
+ const termTf = tf.get(term) ?? 0;
207
+ if (termTf === 0)
36
208
  continue;
37
- const overlap = exWords.filter(w => qWords.includes(w)).length;
38
- const contribution = (overlap / exWords.length) * 60;
39
- bestExampleScore = Math.max(bestExampleScore, contribution);
209
+ const df = index.df[term] ?? 0;
210
+ const idf = Math.log((index.N - df + 0.5) / (df + 0.5) + 1);
211
+ const tfNorm = (termTf * (k1 + 1)) / (termTf + k1 * (1 - b + b * (dl / avgdl)));
212
+ score += idf * tfNorm;
40
213
  }
41
- score += bestExampleScore;
42
- // Check description words
43
- const descWords = filterStopwords(cap.description.toLowerCase().split(/\W+/).filter(Boolean));
44
- if (descWords.length > 0) {
45
- const descOverlap = descWords.filter(w => qWords.includes(w)).length;
46
- score += (descOverlap / descWords.length) * 30;
214
+ return score;
215
+ }
216
+ /**
217
+ * Extracts bigrams from a token array as "token1__token2" strings.
218
+ * Input must already be post-stopword and post-stem (use tokenize() first).
219
+ */
220
+ export function extractBigrams(tokens) {
221
+ const bigrams = new Set();
222
+ for (let i = 0; i < tokens.length - 1; i++) {
223
+ bigrams.add(`${tokens[i]}__${tokens[i + 1]}`);
47
224
  }
48
- // Check name words
49
- const nameWords = filterStopwords(cap.name.toLowerCase().split(/\W+/).filter(Boolean));
50
- if (nameWords.length > 0) {
51
- const nameOverlap = nameWords.filter(w => qWords.includes(w)).length;
52
- score += (nameOverlap / nameWords.length) * 10;
225
+ return bigrams;
226
+ }
227
+ /**
228
+ * Returns a fixed bonus in normalized points (0–15), applied after BM25 normalization.
229
+ * 5 points per matching bigram, saturates at 3 bigrams (15 points).
230
+ * Fixed point value regardless of manifest size — ceiling-independent.
231
+ */
232
+ function bigramBonus(queryBigrams, capBigrams) {
233
+ if (queryBigrams.size === 0 || capBigrams.size === 0)
234
+ return 0;
235
+ let overlap = 0;
236
+ for (const bigram of queryBigrams) {
237
+ if (capBigrams.has(bigram))
238
+ overlap++;
53
239
  }
54
- return Math.min(Math.round(score), 100);
240
+ return Math.min(overlap * 5, 15); // normalized points — 3 bigrams saturate at 15
55
241
  }
56
242
  export function resolverToIntent(cap) {
57
243
  const t = cap.resolver.type;
@@ -63,14 +249,33 @@ export function resolverToIntent(cap) {
63
249
  return 'hybrid';
64
250
  return 'out_of_scope';
65
251
  }
252
+ /**
253
+ * Strips characters that could break LLM prompt structure from
254
+ * capability field values before injection into the system prompt.
255
+ * Removes control characters, newlines, and delimiter-like sequences.
256
+ */
257
+ export function sanitizeForPrompt(value, maxLen) {
258
+ return value
259
+ .replace(/[\r\n\t]/g, ' ') // newlines → space
260
+ .replace(/---+/g, '—') // horizontal rules → em dash
261
+ .replace(/^\s*[{}\[\]]/gm, ' ') // leading braces/brackets → space
262
+ .replace(/\s+/g, ' ') // collapse whitespace
263
+ .trim()
264
+ .slice(0, maxLen);
265
+ }
66
266
  /**
67
267
  * Extracts parameter values from a user query using keyword heuristics.
268
+ *
68
269
  * Known limits:
69
270
  * - Extracts single tokens only — "jane smith" would extract "jane"
70
271
  * - Keyword matching is positional — "articles from authors I follow"
71
272
  * may extract "authors" instead of nothing, since "from" is a keyword
72
- * - For complex or ambiguous queries, use matchWithLLM() which handles
73
- * param extraction more accurately via the LLM prompt
273
+ * - Required param fallback grabs the last meaningful word — "list all
274
+ * recent orders" may extract "orders" even with the denylist extended.
275
+ * For precise extraction of complex queries, use matchWithLLM() which
276
+ * handles param extraction via structured LLM prompt.
277
+ * - To support richer extraction patterns, add a `pattern` field to
278
+ * CapabilityParam in a future version.
74
279
  */
75
280
  export function extractParams(query, cap) {
76
281
  const result = {};
@@ -85,6 +290,26 @@ export function extractParams(query, cap) {
85
290
  result[param.name] = null;
86
291
  continue;
87
292
  }
293
+ // ── Pattern extraction (highest priority) ─────────────────────────────
294
+ if (param.pattern) {
295
+ const namedPattern = TYPE_PATTERNS[param.pattern];
296
+ if (namedPattern) {
297
+ // Named type pattern — match regex directly against full query
298
+ const match = query.match(namedPattern);
299
+ if (match) {
300
+ result[param.name] = match[0];
301
+ continue;
302
+ }
303
+ }
304
+ else if (param.pattern.includes(`{${param.name}}`)) {
305
+ // Example template — positional extraction
306
+ const extracted = extractFromTemplate(query, param.pattern, param.name);
307
+ if (extracted) {
308
+ result[param.name] = extracted;
309
+ continue;
310
+ }
311
+ }
312
+ }
88
313
  // Try to extract value after known keywords
89
314
  // e.g. "profile for johndoe" → johndoe
90
315
  // "articles by jane" → jane
@@ -205,9 +430,35 @@ export function match(query, manifest, options = {}) {
205
430
  }
206
431
  }
207
432
  // ── Score all capabilities ────────────────────────────────────────────────
433
+ // Build qWordSet once — O(1) lookups instead of O(n) Array.includes per word
434
+ const qTokens = tokenize(query);
435
+ const qWordSet = new Set(qTokens);
436
+ // Build query bigrams for phrase bonus
437
+ const qBigrams = extractBigrams(qTokens);
438
+ // Build BM25 index for this manifest — O(capabilities × tokens)
439
+ // In CapmanEngine this is pre-built; for direct match() calls it's built per-call
440
+ const bm25Index = options.bm25Index ?? buildBM25Index(manifest.capabilities);
441
+ const k1 = options.bm25K1 ?? 1.5;
442
+ const b = options.bm25B ?? 0.75;
443
+ // Calibrate ceiling — max self-score for normalization
444
+ const ceiling = options.bm25Ceiling ?? (() => {
445
+ let max = 0;
446
+ for (const cap of manifest.capabilities) {
447
+ if (!cap.examples?.length)
448
+ continue;
449
+ const selfWords = new Set(tokenize(cap.examples[0]));
450
+ const raw = scoreCapability(selfWords, cap, bm25Index, k1, b);
451
+ if (raw > max)
452
+ max = raw;
453
+ }
454
+ return max > 0 ? max : 100;
455
+ })();
208
456
  const allScores = [];
209
457
  for (const cap of manifest.capabilities) {
210
- const keywordScore = scoreCapability(query, cap);
458
+ const rawBM25 = scoreCapability(qWordSet, cap, bm25Index, k1, b);
459
+ const bm25Score = Math.min(100, Math.round((rawBM25 / ceiling) * 100));
460
+ const bonusPoints = bigramBonus(qBigrams, bm25Index.bigrams[cap.id] ?? new Set());
461
+ const keywordScore = Math.min(100, bm25Score + bonusPoints);
211
462
  const fuzzyScore = fuzzyScoreMap.get(cap.id) ?? 0;
212
463
  const via = fuzzyScore > keywordScore ? 'fuzzy' : 'keyword';
213
464
  const score = Math.min(100, Math.round(Math.max(keywordScore, fuzzyScore)));
@@ -255,25 +506,28 @@ export function match(query, manifest, options = {}) {
255
506
  /**
256
507
  * Matches a query to a capability using an LLM.
257
508
  *
258
- * ⚠️ SECURITY NOTE: Capability `description` and `examples` fields from the
259
- * manifest are injected verbatim into the LLM prompt (system portion).
260
- * In a solo deployment with a developer-controlled manifest this is safe.
261
- * If your manifest is generated from third-party OpenAPI specs, user-controlled
262
- * sources, or any external input, sanitize `description` and `examples` fields
263
- * before passing the manifest to this function adversarial content in those
264
- * fields can influence LLM routing decisions.
509
+ * ⚠️ SECURITY NOTE: Capability fields are sanitized before injection into
510
+ * the LLM prompt (newlines stripped, delimiters neutralized, length capped).
511
+ * However, the current interface passes a single prompt string it cannot
512
+ * provide true system/user message separation that some LLM APIs support.
513
+ * For maximum injection resistance in high-security deployments, use an LLM
514
+ * wrapper that maps the prompt to a proper system message, keeping user query
515
+ * data in the user turn only.
265
516
  */
266
517
  export async function matchWithLLM(query, manifest, options) {
267
518
  // Truncate description and examples — prevents context window overflow and
268
519
  // reduces prompt injection surface from third-party OpenAPI spec content.
269
520
  const MAX_DESC_LEN = 200;
270
521
  const MAX_EXAMPLE_LEN = 100;
271
- 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
272
- ? `\n examples: ${c.examples.slice(0, 2).map(e => e.slice(0, MAX_EXAMPLE_LEN)).join(', ')}`
522
+ const manifestSummary = manifest.capabilities.map(c => `- ${c.id} (${c.resolver.type}): ${sanitizeForPrompt(c.description, MAX_DESC_LEN)}${c.examples?.length
523
+ ? `\n examples: ${c.examples.slice(0, 2).map(e => sanitizeForPrompt(e, MAX_EXAMPLE_LEN)).join(', ')}`
273
524
  : ''}`).join('\n');
525
+ // Sanitize app name — strip newlines and control characters that could
526
+ // break the prompt structure or inject additional instructions.
527
+ const safeApp = sanitizeForPrompt(manifest.app, 100);
274
528
  const prompt = `You are an intent matcher for an AI agent system.
275
529
 
276
- App: ${manifest.app}
530
+ App: ${safeApp}
277
531
 
278
532
  Available capabilities:
279
533
  ${manifestSummary}
@@ -331,7 +585,32 @@ ${JSON.stringify({ user_query: query })}
331
585
  capability,
332
586
  confidence: llmConfidence,
333
587
  intent: effectivelyOOS ? 'out_of_scope' : parsed.intent,
334
- extractedParams: (parsed.extracted_params ?? {}),
588
+ extractedParams: (() => {
589
+ // Validate extracted params against declared capability params.
590
+ // Rejects nested objects ("[object Object]" in URLs), unknown keys,
591
+ // and non-scalar values. For OOS results (capability === null),
592
+ // drops all params — correct since there's no capability to match against.
593
+ const rawParams = (parsed.extracted_params ?? {});
594
+ const validParams = {};
595
+ for (const param of capability?.params ?? []) {
596
+ const val = rawParams[param.name];
597
+ if (val === null || val === undefined) {
598
+ validParams[param.name] = null;
599
+ }
600
+ else if (typeof val === 'string') {
601
+ validParams[param.name] = val;
602
+ }
603
+ else if (typeof val === 'number' || typeof val === 'boolean') {
604
+ validParams[param.name] = String(val);
605
+ }
606
+ else {
607
+ // Reject complex types (objects, arrays) — would produce "[object Object]" in URLs
608
+ logger.warn(`LLM returned non-scalar value for param "${param.name}" — dropping`);
609
+ validParams[param.name] = null;
610
+ }
611
+ }
612
+ return validParams;
613
+ })(),
335
614
  reasoning: parsed.reasoning ?? 'No reasoning provided',
336
615
  candidates: allCandidates,
337
616
  };
@@ -166,7 +166,7 @@ function extractParams(op) {
166
166
  continue;
167
167
  const source = p.in === 'path' ? 'user_query' :
168
168
  p.in === 'query' ? 'user_query' :
169
- 'context';
169
+ 'user_query'; // body/formData (Swagger 2.x) — treat as user_query
170
170
  params.push({
171
171
  name: toSnakeCase(p.name),
172
172
  description: p.description ?? toHumanName(p.name),
@@ -201,13 +201,17 @@ function inferPrivacy(op, hasGlobalAuth, securitySchemes) {
201
201
  // Explicitly no security on this operation
202
202
  if (op.security !== undefined && op.security.length === 0)
203
203
  return 'public';
204
- // Check operation tags for admin hints
205
- const tags = (op.tags ?? []).map(t => t.toLowerCase());
206
- if (tags.some(t => t.includes('admin') || t.includes('internal')))
204
+ // Check operation tags for admin hints — word-boundary match only.
205
+ // Avoids false positives like 'manageWishlist', 'fileManager', 'managedService'
206
+ // being classified as admin when they are user-facing operations.
207
+ const ADMIN_PATTERN = /\b(admin|administrator|backoffice|back-office|internal|superuser)\b/i;
208
+ const tags = op.tags ?? [];
209
+ if (tags.some(t => ADMIN_PATTERN.test(t)))
207
210
  return 'admin';
208
- // Check operation ID / summary for admin hints
211
+ // Check operation ID / summary same word-boundary pattern.
212
+ // 'manage' alone is NOT an admin signal — too many user-facing ops use it.
209
213
  const hint = `${op.operationId ?? ''} ${op.summary ?? ''}`.toLowerCase();
210
- if (hint.includes('admin') || hint.includes('manage') || hint.includes('internal')) {
214
+ if (ADMIN_PATTERN.test(hint)) {
211
215
  return 'admin';
212
216
  }
213
217
  // If global auth exists or operation has security, it's user_owned
@@ -248,12 +252,15 @@ function extractBaseUrl(spec) {
248
252
  if (spec.servers?.length) {
249
253
  return spec.servers[0].url.replace(/\/$/, '');
250
254
  }
251
- // Swagger 2.x
255
+ // Swagger 2.x — respect declared schemes, prefer https over http
252
256
  if (spec.host) {
253
- const scheme = 'https';
257
+ const schemes = spec.schemes ?? ['https'];
258
+ const scheme = schemes.includes('https') ? 'https' : schemes[0] ?? 'https';
254
259
  const base = spec.basePath ?? '';
255
260
  return `${scheme}://${spec.host}${base}`.replace(/\/$/, '');
256
261
  }
262
+ logger.warn(`No server URL found in spec — using placeholder "https://api.your-app.com". ` +
263
+ `Set baseUrl manually in the generated config before use.`);
257
264
  return 'https://api.your-app.com';
258
265
  }
259
266
  function sanitizeAppName(title) {
@@ -25,4 +25,5 @@ export interface ResolveOptions {
25
25
  */
26
26
  retryAllMethods?: boolean;
27
27
  }
28
+ export declare function checkPrivacy(capability: import('./types').Capability, auth?: AuthContext): string | null;
28
29
  export declare function resolve(matchResult: MatchResult, params?: Record<string, unknown>, options?: ResolveOptions): Promise<ResolveResult>;
@@ -4,7 +4,7 @@ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
4
4
  function redactParams(params) {
5
5
  return Object.fromEntries(Object.entries(params).map(([k, v]) => [k, v != null ? '[REDACTED]' : 'null']));
6
6
  }
7
- function checkPrivacy(capability, auth) {
7
+ export function checkPrivacy(capability, auth) {
8
8
  const level = capability.privacy.level;
9
9
  if (level === 'public')
10
10
  return null;
@@ -109,6 +109,12 @@ export async function resolve(matchResult, params = {}, options = {}) {
109
109
  *
110
110
  * For capabilities where ordering or rollback matters, define separate capabilities
111
111
  * with single endpoints and orchestrate them at the application layer.
112
+ *
113
+ * Note: the current ResolveResult does not expose which endpoints succeeded and
114
+ * which failed in a partial failure scenario. If your use case requires this
115
+ * granularity, use separate single-endpoint capabilities and inspect each result.
116
+ * Full partial success reporting (partialSuccess, completedCalls, failedCalls)
117
+ * is planned for a future version.
112
118
  */
113
119
  async function resolveApi(resolver, params, options, sessionParamNames = new Set()) {
114
120
  const startTime = Date.now();
@@ -126,7 +132,7 @@ async function resolveApi(resolver, params, options, sessionParamNames = new Set
126
132
  }
127
133
  return {
128
134
  method: endpoint.method,
129
- url: buildUrl(options.baseUrl ?? '', endpoint.path, endpointParams),
135
+ url: buildUrl(options.baseUrl ?? '', endpoint.path, endpointParams, sessionParamNames),
130
136
  params: Object.fromEntries(Object.entries(endpointParams).filter(([, v]) => v !== null && v !== undefined)),
131
137
  };
132
138
  });
@@ -210,9 +216,12 @@ async function resolveApi(resolver, params, options, sessionParamNames = new Set
210
216
  }
211
217
  }
212
218
  function validateNavParam(key, value) {
213
- if (!/^[a-zA-Z0-9_\-]+$/.test(value)) {
219
+ // Allowlist aligned with validateApiPathParam — permits dots, colons, @ for
220
+ // deep links (myapp://path), domain-qualified values (auth.tokens), and
221
+ // versioned routes (v1:resource). Rejects path separators and shell metacharacters.
222
+ if (!/^[a-zA-Z0-9_\-.:@]+$/.test(value)) {
214
223
  throw new Error(`Nav param "${key}" contains invalid characters: "${value}". ` +
215
- `Only alphanumeric, hyphens, and underscores are allowed.`);
224
+ `Only alphanumeric, hyphens, underscores, dots, colons, and @ are allowed.`);
216
225
  }
217
226
  }
218
227
  function resolveNav(resolver, params) {
@@ -237,7 +246,7 @@ function validateApiPathParam(key, value) {
237
246
  }
238
247
  // Both buildUrl (API) and resolveNav (nav) validate path param values against
239
248
  // an allowlist before substitution — prevents path traversal via unencoded slashes.
240
- function buildUrl(baseUrl, urlPath, params) {
249
+ function buildUrl(baseUrl, urlPath, params, blockedQsParams) {
241
250
  let resolved = urlPath;
242
251
  const unused = {};
243
252
  for (const [key, value] of Object.entries(params)) {
@@ -254,7 +263,8 @@ function buildUrl(baseUrl, urlPath, params) {
254
263
  }
255
264
  const base = `${baseUrl.replace(/\/$/, '')}${resolved}`;
256
265
  const qs = Object.entries(unused)
257
- .filter(([, v]) => v !== null && v !== undefined)
266
+ .filter(([k, v]) => v !== null && v !== undefined
267
+ && (!blockedQsParams || !blockedQsParams.has(k)))
258
268
  .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
259
269
  .join('&');
260
270
  return qs ? `${base}?${qs}` : base;