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.
- package/CHANGELOG.md +61 -0
- package/CODEBASE.md +115 -65
- package/README.md +45 -4
- package/bin/lib/cmd-explain.js +2 -2
- package/bin/lib/cmd-generate.js +44 -28
- package/bin/lib/cmd-run.js +2 -2
- package/bin/lib/shared.js +8 -2
- package/dist/cjs/cache.d.ts.map +1 -1
- package/dist/cjs/cache.js +22 -5
- package/dist/cjs/cache.js.map +1 -1
- package/dist/cjs/engine.d.ts +30 -0
- package/dist/cjs/engine.d.ts.map +1 -1
- package/dist/cjs/engine.js +87 -36
- package/dist/cjs/engine.js.map +1 -1
- package/dist/cjs/generator.d.ts.map +1 -1
- package/dist/cjs/generator.js +7 -1
- package/dist/cjs/generator.js.map +1 -1
- package/dist/cjs/learning.d.ts.map +1 -1
- package/dist/cjs/learning.js +39 -12
- package/dist/cjs/learning.js.map +1 -1
- package/dist/cjs/matcher.d.ts +18 -10
- package/dist/cjs/matcher.d.ts.map +1 -1
- package/dist/cjs/matcher.js +140 -29
- package/dist/cjs/matcher.js.map +1 -1
- package/dist/cjs/parser.d.ts.map +1 -1
- package/dist/cjs/parser.js +15 -8
- package/dist/cjs/parser.js.map +1 -1
- package/dist/cjs/resolver.d.ts +1 -0
- package/dist/cjs/resolver.d.ts.map +1 -1
- package/dist/cjs/resolver.js +16 -5
- package/dist/cjs/resolver.js.map +1 -1
- package/dist/cjs/schema.d.ts +18 -18
- package/dist/cjs/schema.js +1 -1
- package/dist/cjs/schema.js.map +1 -1
- package/dist/cjs/types.d.ts +1 -1
- package/dist/cjs/types.d.ts.map +1 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/cache.js +22 -5
- package/dist/esm/engine.d.ts +30 -0
- package/dist/esm/engine.js +89 -38
- package/dist/esm/generator.js +7 -1
- package/dist/esm/learning.js +39 -12
- package/dist/esm/matcher.d.ts +18 -10
- package/dist/esm/matcher.js +137 -29
- package/dist/esm/parser.js +15 -8
- package/dist/esm/resolver.d.ts +1 -0
- package/dist/esm/resolver.js +16 -6
- package/dist/esm/schema.d.ts +18 -18
- package/dist/esm/schema.js +1 -1
- package/dist/esm/types.d.ts +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +11 -10
package/dist/esm/matcher.js
CHANGED
|
@@ -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) {
|
|
@@ -20,28 +21,34 @@ export const STOPWORDS = new Set([
|
|
|
20
21
|
function filterStopwords(words) {
|
|
21
22
|
return words.filter(w => !STOPWORDS.has(w.toLowerCase()) && w.length > 1);
|
|
22
23
|
}
|
|
23
|
-
function scoreCapability(
|
|
24
|
-
const q = query.toLowerCase();
|
|
24
|
+
function scoreCapability(qWordSet, cap) {
|
|
25
25
|
let score = 0;
|
|
26
|
-
|
|
27
|
-
//
|
|
26
|
+
// Check examples — take the best single example match, not the sum.
|
|
27
|
+
// Accumulating across examples rewards bloated example lists over precise ones:
|
|
28
|
+
// 10 examples at 50% overlap = 300 points (clamped to 60) beats 1 perfect example at 60.
|
|
29
|
+
// Taking Math.max means quality of examples matters, not quantity.
|
|
30
|
+
let bestExampleScore = 0;
|
|
28
31
|
for (const example of cap.examples ?? []) {
|
|
29
32
|
const exWords = filterStopwords(example.toLowerCase().split(/\s+/));
|
|
30
33
|
if (exWords.length === 0)
|
|
31
34
|
continue;
|
|
32
|
-
const overlap = exWords.filter(w =>
|
|
33
|
-
|
|
35
|
+
const overlap = exWords.filter(w => qWordSet.has(w)).length;
|
|
36
|
+
const contribution = (overlap / exWords.length) * 60;
|
|
37
|
+
bestExampleScore = Math.max(bestExampleScore, contribution);
|
|
34
38
|
}
|
|
35
|
-
|
|
39
|
+
score += bestExampleScore;
|
|
40
|
+
// Check description words — normalize against min(length, 10) to avoid
|
|
41
|
+
// penalizing rich documentation (many words = lower ratio) while also
|
|
42
|
+
// preventing single-word descriptions from maxing out on any match.
|
|
36
43
|
const descWords = filterStopwords(cap.description.toLowerCase().split(/\W+/).filter(Boolean));
|
|
37
44
|
if (descWords.length > 0) {
|
|
38
|
-
const descOverlap = descWords.filter(w =>
|
|
39
|
-
score += (descOverlap / descWords.length) * 30;
|
|
45
|
+
const descOverlap = descWords.filter(w => qWordSet.has(w)).length;
|
|
46
|
+
score += Math.min((descOverlap / Math.min(descWords.length, 10)) * 30, 30);
|
|
40
47
|
}
|
|
41
48
|
// Check name words
|
|
42
49
|
const nameWords = filterStopwords(cap.name.toLowerCase().split(/\W+/).filter(Boolean));
|
|
43
50
|
if (nameWords.length > 0) {
|
|
44
|
-
const nameOverlap = nameWords.filter(w =>
|
|
51
|
+
const nameOverlap = nameWords.filter(w => qWordSet.has(w)).length;
|
|
45
52
|
score += (nameOverlap / nameWords.length) * 10;
|
|
46
53
|
}
|
|
47
54
|
return Math.min(Math.round(score), 100);
|
|
@@ -56,6 +63,20 @@ export function resolverToIntent(cap) {
|
|
|
56
63
|
return 'hybrid';
|
|
57
64
|
return 'out_of_scope';
|
|
58
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Strips characters that could break LLM prompt structure from
|
|
68
|
+
* capability field values before injection into the system prompt.
|
|
69
|
+
* Removes control characters, newlines, and delimiter-like sequences.
|
|
70
|
+
*/
|
|
71
|
+
function sanitizeForPrompt(value, maxLen) {
|
|
72
|
+
return value
|
|
73
|
+
.replace(/[\r\n\t]/g, ' ') // newlines → space
|
|
74
|
+
.replace(/---+/g, '—') // horizontal rules → em dash
|
|
75
|
+
.replace(/^\s*[{}\[\]]/gm, ' ') // leading braces/brackets → space
|
|
76
|
+
.replace(/\s+/g, ' ') // collapse whitespace
|
|
77
|
+
.trim()
|
|
78
|
+
.slice(0, maxLen);
|
|
79
|
+
}
|
|
59
80
|
/**
|
|
60
81
|
* Extracts parameter values from a user query using keyword heuristics.
|
|
61
82
|
*
|
|
@@ -63,8 +84,12 @@ export function resolverToIntent(cap) {
|
|
|
63
84
|
* - Extracts single tokens only — "jane smith" would extract "jane"
|
|
64
85
|
* - Keyword matching is positional — "articles from authors I follow"
|
|
65
86
|
* may extract "authors" instead of nothing, since "from" is a keyword
|
|
66
|
-
* -
|
|
67
|
-
*
|
|
87
|
+
* - Required param fallback grabs the last meaningful word — "list all
|
|
88
|
+
* recent orders" may extract "orders" even with the denylist extended.
|
|
89
|
+
* For precise extraction of complex queries, use matchWithLLM() which
|
|
90
|
+
* handles param extraction via structured LLM prompt.
|
|
91
|
+
* - To support richer extraction patterns, add a `pattern` field to
|
|
92
|
+
* CapabilityParam in a future version.
|
|
68
93
|
*/
|
|
69
94
|
export function extractParams(query, cap) {
|
|
70
95
|
const result = {};
|
|
@@ -136,7 +161,7 @@ export function extractParams(query, cap) {
|
|
|
136
161
|
}
|
|
137
162
|
return result;
|
|
138
163
|
}
|
|
139
|
-
export function match(query, manifest) {
|
|
164
|
+
export function match(query, manifest, options = {}) {
|
|
140
165
|
if (!query?.trim()) {
|
|
141
166
|
logger.warn('Empty query received');
|
|
142
167
|
return {
|
|
@@ -153,11 +178,62 @@ export function match(query, manifest) {
|
|
|
153
178
|
logger.debug(`Manifest has ${manifest.capabilities.length} capabilities`);
|
|
154
179
|
let best = null;
|
|
155
180
|
let bestScore = 0;
|
|
181
|
+
// ── Build Fuse index once per match() call ────────────────────────────────
|
|
182
|
+
// Flat corpus — each example/description/name is its own searchable record,
|
|
183
|
+
// tagged with the owning capability id. This avoids two pitfalls of using
|
|
184
|
+
// Fuse's multi-key mode here:
|
|
185
|
+
// (1) joining examples into one string dilutes single-example matches,
|
|
186
|
+
// (2) multi-key weighted aggregation mixes good and bad field matches
|
|
187
|
+
// when we actually want the best single match across all fields.
|
|
188
|
+
// After searching, we group hits by capability and take the BEST score.
|
|
189
|
+
// Field prioritization (examples > description > name) is already applied
|
|
190
|
+
// by the keyword scorer (60/30/10 weights in scoreCapability), so fuzzy
|
|
191
|
+
// here is a pure similarity signal.
|
|
192
|
+
const fuzzyScoreMap = new Map();
|
|
193
|
+
if (options.fuzzyMatch) {
|
|
194
|
+
const corpus = [];
|
|
195
|
+
for (const cap of manifest.capabilities) {
|
|
196
|
+
for (const ex of cap.examples ?? []) {
|
|
197
|
+
if (ex?.trim())
|
|
198
|
+
corpus.push({ capabilityId: cap.id, text: ex });
|
|
199
|
+
}
|
|
200
|
+
if (cap.description?.trim())
|
|
201
|
+
corpus.push({ capabilityId: cap.id, text: cap.description });
|
|
202
|
+
if (cap.name?.trim())
|
|
203
|
+
corpus.push({ capabilityId: cap.id, text: cap.name });
|
|
204
|
+
}
|
|
205
|
+
if (corpus.length > 0) {
|
|
206
|
+
const fuse = new Fuse(corpus, {
|
|
207
|
+
keys: ['text'],
|
|
208
|
+
threshold: options.fuzzyThreshold ?? 0.4,
|
|
209
|
+
includeScore: true,
|
|
210
|
+
ignoreLocation: true,
|
|
211
|
+
minMatchCharLength: 3,
|
|
212
|
+
});
|
|
213
|
+
// Group hits by capability, keeping the best (lowest fuse score = highest similarity).
|
|
214
|
+
// Convert to 0-100 contribution: fuseScore 0.0 = 100%, fuseScore 1.0 = 0%.
|
|
215
|
+
// Multiplier 100 (not 60) lets a strong fuzzy match alone reach the standard
|
|
216
|
+
// 50% confidence cutoff for typo-only queries that have no keyword overlap.
|
|
217
|
+
for (const hit of fuse.search(query)) {
|
|
218
|
+
const capId = hit.item.capabilityId;
|
|
219
|
+
const contribution = (1 - (hit.score ?? 1)) * 100;
|
|
220
|
+
const existing = fuzzyScoreMap.get(capId) ?? 0;
|
|
221
|
+
if (contribution > existing)
|
|
222
|
+
fuzzyScoreMap.set(capId, contribution);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// ── Score all capabilities ────────────────────────────────────────────────
|
|
227
|
+
// Build qWordSet once — O(1) lookups instead of O(n) Array.includes per word
|
|
228
|
+
const qWordSet = new Set(filterStopwords(query.toLowerCase().split(/\W+/).filter(Boolean)));
|
|
156
229
|
const allScores = [];
|
|
157
230
|
for (const cap of manifest.capabilities) {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
231
|
+
const keywordScore = scoreCapability(qWordSet, cap);
|
|
232
|
+
const fuzzyScore = fuzzyScoreMap.get(cap.id) ?? 0;
|
|
233
|
+
const via = fuzzyScore > keywordScore ? 'fuzzy' : 'keyword';
|
|
234
|
+
const score = Math.min(100, Math.round(Math.max(keywordScore, fuzzyScore)));
|
|
235
|
+
logger.debug(` scored "${cap.id}": ${score}% (keyword: ${keywordScore}%, fuzzy: ${Math.round(fuzzyScore)}%)`);
|
|
236
|
+
allScores.push({ cap, score, via });
|
|
161
237
|
if (score > bestScore) {
|
|
162
238
|
bestScore = score;
|
|
163
239
|
best = cap;
|
|
@@ -169,7 +245,8 @@ export function match(query, manifest) {
|
|
|
169
245
|
matched: cap.id === best?.id,
|
|
170
246
|
}));
|
|
171
247
|
if (!best || bestScore < 50) {
|
|
172
|
-
|
|
248
|
+
const bestId = best ? best.id : 'none';
|
|
249
|
+
logger.info(`No match above threshold (best: ${bestScore}% for "${bestId}")`);
|
|
173
250
|
// Out of scope return:
|
|
174
251
|
return {
|
|
175
252
|
capability: null,
|
|
@@ -183,38 +260,44 @@ export function match(query, manifest) {
|
|
|
183
260
|
const params = extractParams(query, best);
|
|
184
261
|
logger.info(`Matched "${best.id}" at ${bestScore}% confidence`);
|
|
185
262
|
logger.debug(`Extracted params: ${JSON.stringify(Object.fromEntries(Object.entries(params).map(([k, v]) => [k, v != null ? '[REDACTED]' : 'null'])))}`);
|
|
263
|
+
// Use the via tag tracked during scoring — avoids redundant scoreCapability call.
|
|
264
|
+
const bestEntry = allScores.find(s => s.cap.id === best.id);
|
|
265
|
+
const winner = bestEntry?.via === 'fuzzy' ? 'fuzzy match' : 'keyword scoring';
|
|
186
266
|
// Matched return:
|
|
187
267
|
return {
|
|
188
268
|
capability: best,
|
|
189
269
|
confidence: bestScore,
|
|
190
270
|
intent: resolverToIntent(best),
|
|
191
271
|
extractedParams: params,
|
|
192
|
-
reasoning: `Matched "${best.id}" via
|
|
272
|
+
reasoning: `Matched "${best.id}" via ${winner} (score: ${bestScore})`,
|
|
193
273
|
candidates,
|
|
194
274
|
};
|
|
195
275
|
}
|
|
196
276
|
/**
|
|
197
277
|
* Matches a query to a capability using an LLM.
|
|
198
278
|
*
|
|
199
|
-
* ⚠️ SECURITY NOTE: Capability
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
279
|
+
* ⚠️ SECURITY NOTE: Capability fields are sanitized before injection into
|
|
280
|
+
* the LLM prompt (newlines stripped, delimiters neutralized, length capped).
|
|
281
|
+
* However, the current interface passes a single prompt string — it cannot
|
|
282
|
+
* provide true system/user message separation that some LLM APIs support.
|
|
283
|
+
* For maximum injection resistance in high-security deployments, use an LLM
|
|
284
|
+
* wrapper that maps the prompt to a proper system message, keeping user query
|
|
285
|
+
* data in the user turn only.
|
|
206
286
|
*/
|
|
207
287
|
export async function matchWithLLM(query, manifest, options) {
|
|
208
288
|
// Truncate description and examples — prevents context window overflow and
|
|
209
289
|
// reduces prompt injection surface from third-party OpenAPI spec content.
|
|
210
290
|
const MAX_DESC_LEN = 200;
|
|
211
291
|
const MAX_EXAMPLE_LEN = 100;
|
|
212
|
-
const manifestSummary = manifest.capabilities.map(c => `- ${c.id} (${c.resolver.type}): ${c.description
|
|
213
|
-
? `\n examples: ${c.examples.slice(0, 2).map(e => e
|
|
292
|
+
const manifestSummary = manifest.capabilities.map(c => `- ${c.id} (${c.resolver.type}): ${sanitizeForPrompt(c.description, MAX_DESC_LEN)}${c.examples?.length
|
|
293
|
+
? `\n examples: ${c.examples.slice(0, 2).map(e => sanitizeForPrompt(e, MAX_EXAMPLE_LEN)).join(', ')}`
|
|
214
294
|
: ''}`).join('\n');
|
|
295
|
+
// Sanitize app name — strip newlines and control characters that could
|
|
296
|
+
// break the prompt structure or inject additional instructions.
|
|
297
|
+
const safeApp = sanitizeForPrompt(manifest.app, 100);
|
|
215
298
|
const prompt = `You are an intent matcher for an AI agent system.
|
|
216
299
|
|
|
217
|
-
App: ${
|
|
300
|
+
App: ${safeApp}
|
|
218
301
|
|
|
219
302
|
Available capabilities:
|
|
220
303
|
${manifestSummary}
|
|
@@ -272,7 +355,32 @@ ${JSON.stringify({ user_query: query })}
|
|
|
272
355
|
capability,
|
|
273
356
|
confidence: llmConfidence,
|
|
274
357
|
intent: effectivelyOOS ? 'out_of_scope' : parsed.intent,
|
|
275
|
-
extractedParams: (
|
|
358
|
+
extractedParams: (() => {
|
|
359
|
+
// Validate extracted params against declared capability params.
|
|
360
|
+
// Rejects nested objects ("[object Object]" in URLs), unknown keys,
|
|
361
|
+
// and non-scalar values. For OOS results (capability === null),
|
|
362
|
+
// drops all params — correct since there's no capability to match against.
|
|
363
|
+
const rawParams = (parsed.extracted_params ?? {});
|
|
364
|
+
const validParams = {};
|
|
365
|
+
for (const param of capability?.params ?? []) {
|
|
366
|
+
const val = rawParams[param.name];
|
|
367
|
+
if (val === null || val === undefined) {
|
|
368
|
+
validParams[param.name] = null;
|
|
369
|
+
}
|
|
370
|
+
else if (typeof val === 'string') {
|
|
371
|
+
validParams[param.name] = val;
|
|
372
|
+
}
|
|
373
|
+
else if (typeof val === 'number' || typeof val === 'boolean') {
|
|
374
|
+
validParams[param.name] = String(val);
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
// Reject complex types (objects, arrays) — would produce "[object Object]" in URLs
|
|
378
|
+
logger.warn(`LLM returned non-scalar value for param "${param.name}" — dropping`);
|
|
379
|
+
validParams[param.name] = null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return validParams;
|
|
383
|
+
})(),
|
|
276
384
|
reasoning: parsed.reasoning ?? 'No reasoning provided',
|
|
277
385
|
candidates: allCandidates,
|
|
278
386
|
};
|
package/dist/esm/parser.js
CHANGED
|
@@ -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
|
-
'
|
|
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
|
-
|
|
206
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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) {
|
package/dist/esm/resolver.d.ts
CHANGED
|
@@ -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>;
|
package/dist/esm/resolver.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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;
|
package/dist/esm/schema.d.ts
CHANGED
|
@@ -11,19 +11,19 @@ export declare const CapmanConfigSchema: z.ZodEffects<z.ZodObject<{
|
|
|
11
11
|
name: z.ZodString;
|
|
12
12
|
description: z.ZodString;
|
|
13
13
|
required: z.ZodBoolean;
|
|
14
|
-
source: z.ZodEnum<["user_query", "session"
|
|
14
|
+
source: z.ZodEnum<["user_query", "session"]>;
|
|
15
15
|
default: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
|
|
16
16
|
}, "strip", z.ZodTypeAny, {
|
|
17
17
|
name: string;
|
|
18
18
|
required: boolean;
|
|
19
19
|
description: string;
|
|
20
|
-
source: "user_query" | "session"
|
|
20
|
+
source: "user_query" | "session";
|
|
21
21
|
default?: string | number | boolean | undefined;
|
|
22
22
|
}, {
|
|
23
23
|
name: string;
|
|
24
24
|
required: boolean;
|
|
25
25
|
description: string;
|
|
26
|
-
source: "user_query" | "session"
|
|
26
|
+
source: "user_query" | "session";
|
|
27
27
|
default?: string | number | boolean | undefined;
|
|
28
28
|
}>, "many">;
|
|
29
29
|
returns: z.ZodArray<z.ZodString, "many">;
|
|
@@ -151,7 +151,7 @@ export declare const CapmanConfigSchema: z.ZodEffects<z.ZodObject<{
|
|
|
151
151
|
name: string;
|
|
152
152
|
required: boolean;
|
|
153
153
|
description: string;
|
|
154
|
-
source: "user_query" | "session"
|
|
154
|
+
source: "user_query" | "session";
|
|
155
155
|
default?: string | number | boolean | undefined;
|
|
156
156
|
}[];
|
|
157
157
|
description: string;
|
|
@@ -193,7 +193,7 @@ export declare const CapmanConfigSchema: z.ZodEffects<z.ZodObject<{
|
|
|
193
193
|
name: string;
|
|
194
194
|
required: boolean;
|
|
195
195
|
description: string;
|
|
196
|
-
source: "user_query" | "session"
|
|
196
|
+
source: "user_query" | "session";
|
|
197
197
|
default?: string | number | boolean | undefined;
|
|
198
198
|
}[];
|
|
199
199
|
description: string;
|
|
@@ -235,7 +235,7 @@ export declare const CapmanConfigSchema: z.ZodEffects<z.ZodObject<{
|
|
|
235
235
|
name: string;
|
|
236
236
|
required: boolean;
|
|
237
237
|
description: string;
|
|
238
|
-
source: "user_query" | "session"
|
|
238
|
+
source: "user_query" | "session";
|
|
239
239
|
default?: string | number | boolean | undefined;
|
|
240
240
|
}[];
|
|
241
241
|
description: string;
|
|
@@ -277,7 +277,7 @@ export declare const CapmanConfigSchema: z.ZodEffects<z.ZodObject<{
|
|
|
277
277
|
name: string;
|
|
278
278
|
required: boolean;
|
|
279
279
|
description: string;
|
|
280
|
-
source: "user_query" | "session"
|
|
280
|
+
source: "user_query" | "session";
|
|
281
281
|
default?: string | number | boolean | undefined;
|
|
282
282
|
}[];
|
|
283
283
|
description: string;
|
|
@@ -322,7 +322,7 @@ export declare const CapmanConfigSchema: z.ZodEffects<z.ZodObject<{
|
|
|
322
322
|
name: string;
|
|
323
323
|
required: boolean;
|
|
324
324
|
description: string;
|
|
325
|
-
source: "user_query" | "session"
|
|
325
|
+
source: "user_query" | "session";
|
|
326
326
|
default?: string | number | boolean | undefined;
|
|
327
327
|
}[];
|
|
328
328
|
description: string;
|
|
@@ -368,7 +368,7 @@ export declare const CapmanConfigSchema: z.ZodEffects<z.ZodObject<{
|
|
|
368
368
|
name: string;
|
|
369
369
|
required: boolean;
|
|
370
370
|
description: string;
|
|
371
|
-
source: "user_query" | "session"
|
|
371
|
+
source: "user_query" | "session";
|
|
372
372
|
default?: string | number | boolean | undefined;
|
|
373
373
|
}[];
|
|
374
374
|
description: string;
|
|
@@ -414,7 +414,7 @@ export declare const CapmanConfigSchema: z.ZodEffects<z.ZodObject<{
|
|
|
414
414
|
name: string;
|
|
415
415
|
required: boolean;
|
|
416
416
|
description: string;
|
|
417
|
-
source: "user_query" | "session"
|
|
417
|
+
source: "user_query" | "session";
|
|
418
418
|
default?: string | number | boolean | undefined;
|
|
419
419
|
}[];
|
|
420
420
|
description: string;
|
|
@@ -460,7 +460,7 @@ export declare const CapmanConfigSchema: z.ZodEffects<z.ZodObject<{
|
|
|
460
460
|
name: string;
|
|
461
461
|
required: boolean;
|
|
462
462
|
description: string;
|
|
463
|
-
source: "user_query" | "session"
|
|
463
|
+
source: "user_query" | "session";
|
|
464
464
|
default?: string | number | boolean | undefined;
|
|
465
465
|
}[];
|
|
466
466
|
description: string;
|
|
@@ -511,19 +511,19 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
511
511
|
name: z.ZodString;
|
|
512
512
|
description: z.ZodString;
|
|
513
513
|
required: z.ZodBoolean;
|
|
514
|
-
source: z.ZodEnum<["user_query", "session"
|
|
514
|
+
source: z.ZodEnum<["user_query", "session"]>;
|
|
515
515
|
default: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
|
|
516
516
|
}, "strip", z.ZodTypeAny, {
|
|
517
517
|
name: string;
|
|
518
518
|
required: boolean;
|
|
519
519
|
description: string;
|
|
520
|
-
source: "user_query" | "session"
|
|
520
|
+
source: "user_query" | "session";
|
|
521
521
|
default?: string | number | boolean | undefined;
|
|
522
522
|
}, {
|
|
523
523
|
name: string;
|
|
524
524
|
required: boolean;
|
|
525
525
|
description: string;
|
|
526
|
-
source: "user_query" | "session"
|
|
526
|
+
source: "user_query" | "session";
|
|
527
527
|
default?: string | number | boolean | undefined;
|
|
528
528
|
}>, "many">;
|
|
529
529
|
returns: z.ZodArray<z.ZodString, "many">;
|
|
@@ -651,7 +651,7 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
651
651
|
name: string;
|
|
652
652
|
required: boolean;
|
|
653
653
|
description: string;
|
|
654
|
-
source: "user_query" | "session"
|
|
654
|
+
source: "user_query" | "session";
|
|
655
655
|
default?: string | number | boolean | undefined;
|
|
656
656
|
}[];
|
|
657
657
|
description: string;
|
|
@@ -693,7 +693,7 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
693
693
|
name: string;
|
|
694
694
|
required: boolean;
|
|
695
695
|
description: string;
|
|
696
|
-
source: "user_query" | "session"
|
|
696
|
+
source: "user_query" | "session";
|
|
697
697
|
default?: string | number | boolean | undefined;
|
|
698
698
|
}[];
|
|
699
699
|
description: string;
|
|
@@ -739,7 +739,7 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
739
739
|
name: string;
|
|
740
740
|
required: boolean;
|
|
741
741
|
description: string;
|
|
742
|
-
source: "user_query" | "session"
|
|
742
|
+
source: "user_query" | "session";
|
|
743
743
|
default?: string | number | boolean | undefined;
|
|
744
744
|
}[];
|
|
745
745
|
description: string;
|
|
@@ -786,7 +786,7 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
786
786
|
name: string;
|
|
787
787
|
required: boolean;
|
|
788
788
|
description: string;
|
|
789
|
-
source: "user_query" | "session"
|
|
789
|
+
source: "user_query" | "session";
|
|
790
790
|
default?: string | number | boolean | undefined;
|
|
791
791
|
}[];
|
|
792
792
|
description: string;
|
package/dist/esm/schema.js
CHANGED
|
@@ -4,7 +4,7 @@ const CapabilityParamSchema = z.object({
|
|
|
4
4
|
name: z.string().min(1, 'param name is required'),
|
|
5
5
|
description: z.string().min(1, 'param description is required'),
|
|
6
6
|
required: z.boolean(),
|
|
7
|
-
source: z.enum(['user_query', 'session'
|
|
7
|
+
source: z.enum(['user_query', 'session']),
|
|
8
8
|
default: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
|
9
9
|
});
|
|
10
10
|
// ─── Resolver Schemas ─────────────────────────────────────────────────────────
|
package/dist/esm/types.d.ts
CHANGED
package/dist/esm/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.5.
|
|
1
|
+
export declare const VERSION = "0.5.5";
|
package/dist/esm/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated by scripts/version.js — do not edit manually
|
|
2
|
-
export const VERSION = '0.5.
|
|
2
|
+
export const VERSION = '0.5.5';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "capman",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"description": "Capability Manifest Engine — let AI agents interact with your app without navigating the UI",
|
|
5
5
|
"main": "./dist/cjs/index.js",
|
|
6
6
|
"module": "./dist/esm/index.js",
|
|
@@ -29,15 +29,15 @@
|
|
|
29
29
|
"CODEBASE.md"
|
|
30
30
|
],
|
|
31
31
|
"scripts": {
|
|
32
|
-
"prebuild":
|
|
33
|
-
"build:cjs":
|
|
34
|
-
"build:esm":
|
|
35
|
-
"build":
|
|
36
|
-
"dev":
|
|
37
|
-
"example":
|
|
38
|
-
"validate":
|
|
39
|
-
"inspect":
|
|
40
|
-
"test":
|
|
32
|
+
"prebuild": "node scripts/version.js",
|
|
33
|
+
"build:cjs": "tsc --project tsconfig.json",
|
|
34
|
+
"build:esm": "tsc --project tsconfig.esm.json",
|
|
35
|
+
"build": "pnpm run build:cjs && pnpm run build:esm",
|
|
36
|
+
"dev": "tsc --watch",
|
|
37
|
+
"example": "tsx examples/basic.ts",
|
|
38
|
+
"validate": "node bin/capman.js validate",
|
|
39
|
+
"inspect": "node bin/capman.js inspect",
|
|
40
|
+
"test": "vitest run"
|
|
41
41
|
},
|
|
42
42
|
"keywords": [
|
|
43
43
|
"ai",
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"dotenv": "^17.3.1",
|
|
59
|
+
"fuse.js": "^7.3.0",
|
|
59
60
|
"zod": "^3.23.0"
|
|
60
61
|
}
|
|
61
62
|
}
|