capman 0.6.0 → 0.6.2
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/CODEBASE.md +6 -5
- package/dist/cjs/cache.d.ts +9 -0
- package/dist/cjs/cache.d.ts.map +1 -1
- package/dist/cjs/cache.js +37 -7
- package/dist/cjs/cache.js.map +1 -1
- package/dist/cjs/concurrent.d.ts +53 -0
- package/dist/cjs/concurrent.d.ts.map +1 -0
- package/dist/cjs/concurrent.js +71 -0
- package/dist/cjs/concurrent.js.map +1 -0
- package/dist/cjs/engine.d.ts +92 -7
- package/dist/cjs/engine.d.ts.map +1 -1
- package/dist/cjs/engine.js +269 -57
- package/dist/cjs/engine.js.map +1 -1
- package/dist/cjs/generator.d.ts.map +1 -1
- package/dist/cjs/generator.js +28 -6
- package/dist/cjs/generator.js.map +1 -1
- package/dist/cjs/index.d.ts +3 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/learning.d.ts +16 -1
- package/dist/cjs/learning.d.ts.map +1 -1
- package/dist/cjs/learning.js +95 -14
- package/dist/cjs/learning.js.map +1 -1
- package/dist/cjs/matcher.d.ts +51 -2
- package/dist/cjs/matcher.d.ts.map +1 -1
- package/dist/cjs/matcher.js +173 -33
- package/dist/cjs/matcher.js.map +1 -1
- package/dist/cjs/parser.js +27 -9
- package/dist/cjs/parser.js.map +1 -1
- package/dist/cjs/resolver.d.ts +2 -2
- package/dist/cjs/resolver.d.ts.map +1 -1
- package/dist/cjs/resolver.js +66 -26
- package/dist/cjs/resolver.js.map +1 -1
- package/dist/cjs/schema.d.ts +821 -68
- package/dist/cjs/schema.d.ts.map +1 -1
- package/dist/cjs/schema.js +62 -13
- package/dist/cjs/schema.js.map +1 -1
- package/dist/cjs/types.d.ts +156 -9
- 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.d.ts +9 -0
- package/dist/esm/cache.js +37 -7
- package/dist/esm/concurrent.d.ts +52 -0
- package/dist/esm/concurrent.js +66 -0
- package/dist/esm/engine.d.ts +92 -7
- package/dist/esm/engine.js +270 -58
- package/dist/esm/generator.js +28 -6
- package/dist/esm/index.d.ts +3 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/learning.d.ts +16 -1
- package/dist/esm/learning.js +95 -14
- package/dist/esm/matcher.d.ts +51 -2
- package/dist/esm/matcher.js +170 -33
- package/dist/esm/parser.js +27 -9
- package/dist/esm/resolver.d.ts +2 -2
- package/dist/esm/resolver.js +66 -26
- package/dist/esm/schema.d.ts +821 -68
- package/dist/esm/schema.js +62 -13
- package/dist/esm/types.d.ts +156 -9
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/dist/esm/matcher.js
CHANGED
|
@@ -136,15 +136,19 @@ export function tokenize(text) {
|
|
|
136
136
|
export function buildBM25Index(capabilities) {
|
|
137
137
|
const N = capabilities.length;
|
|
138
138
|
if (N === 0)
|
|
139
|
-
return { df: {}, avgdl: { examples: 0, description: 0, name: 0 }, N: 0, bigrams: {}, };
|
|
139
|
+
return { df: {}, avgdl: { examples: 0, description: 0, name: 0 }, N: 0, bigrams: {}, capTokens: {}, };
|
|
140
140
|
const df = {};
|
|
141
141
|
let totalExLen = 0;
|
|
142
142
|
let totalDescLen = 0;
|
|
143
143
|
let totalNameLen = 0;
|
|
144
|
+
// Pre-compute token arrays for every capability in a single pass.
|
|
145
|
+
// scoreCapability() reads from capTokens instead of re-tokenizing on every call.
|
|
146
|
+
const capTokens = {};
|
|
144
147
|
for (const cap of capabilities) {
|
|
145
148
|
const exTokens = tokenize((cap.examples ?? []).join(' '));
|
|
146
149
|
const descTokens = tokenize(cap.description);
|
|
147
150
|
const nameTokens = tokenize(cap.name);
|
|
151
|
+
capTokens[cap.id] = { examples: exTokens, description: descTokens, name: nameTokens };
|
|
148
152
|
totalExLen += exTokens.length;
|
|
149
153
|
totalDescLen += descTokens.length;
|
|
150
154
|
totalNameLen += nameTokens.length;
|
|
@@ -177,6 +181,7 @@ export function buildBM25Index(capabilities) {
|
|
|
177
181
|
},
|
|
178
182
|
N,
|
|
179
183
|
bigrams,
|
|
184
|
+
capTokens,
|
|
180
185
|
};
|
|
181
186
|
}
|
|
182
187
|
/**
|
|
@@ -187,9 +192,17 @@ export function buildBM25Index(capabilities) {
|
|
|
187
192
|
export function scoreCapability(qWordSet, cap, index, k1 = 1.5, b = 0.75) {
|
|
188
193
|
if (index.N === 0)
|
|
189
194
|
return 0;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
195
|
+
// Use pre-computed token arrays from the index — avoids re-tokenizing
|
|
196
|
+
// capability text on every call. Falls back to live tokenization only when
|
|
197
|
+
// scoreCapability() is called outside CapmanEngine (e.g. unit tests that
|
|
198
|
+
// build a BM25Index manually without capTokens populated).
|
|
199
|
+
const tokens = index.capTokens[cap.id];
|
|
200
|
+
const exTokens = tokens?.examples ?? tokenize((cap.examples ?? []).join(' '));
|
|
201
|
+
const descTokens = tokens?.description ?? tokenize(cap.description);
|
|
202
|
+
const nameTokens = tokens?.name ?? tokenize(cap.name);
|
|
203
|
+
const score = bm25Field(qWordSet, exTokens, index, 'examples', k1, b) * 0.6
|
|
204
|
+
+ bm25Field(qWordSet, descTokens, index, 'description', k1, b) * 0.3
|
|
205
|
+
+ bm25Field(qWordSet, nameTokens, index, 'name', k1, b) * 0.1;
|
|
193
206
|
return score;
|
|
194
207
|
}
|
|
195
208
|
function bm25Field(queryTerms, fieldTokens, index, field, k1, b) {
|
|
@@ -224,6 +237,46 @@ export function extractBigrams(tokens) {
|
|
|
224
237
|
}
|
|
225
238
|
return bigrams;
|
|
226
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Reciprocal Rank Fusion — fuses multiple ranked lists into a single score map.
|
|
242
|
+
* k=60 is the standard literature default.
|
|
243
|
+
*/
|
|
244
|
+
export function rrf(rankings, k = 60) {
|
|
245
|
+
const scores = new Map();
|
|
246
|
+
for (const ranking of rankings) {
|
|
247
|
+
const sorted = [...ranking].sort((a, b) => b.score - a.score);
|
|
248
|
+
sorted.forEach((item, rank) => {
|
|
249
|
+
scores.set(item.id, (scores.get(item.id) ?? 0) + 1 / (k + rank + 1));
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return scores;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Returns a sub-manifest containing only capabilities that match ALL provided tags.
|
|
256
|
+
* Capabilities without tags are excluded when tags filter is active.
|
|
257
|
+
* Enables token-efficient LLM prompts for large manifests:
|
|
258
|
+
*
|
|
259
|
+
* @example
|
|
260
|
+
* // Only send order-related capabilities to LLM
|
|
261
|
+
* const orderManifest = filterByTags(manifest, ['orders'])
|
|
262
|
+
* const result = await matchWithLLM(query, orderManifest, { llm })
|
|
263
|
+
*
|
|
264
|
+
* @example
|
|
265
|
+
* // Match by any of multiple tags (union) — call filterByTags per tag and merge
|
|
266
|
+
* const ordersOrPayments = [
|
|
267
|
+
* ...filterByTags(manifest, ['orders']).capabilities,
|
|
268
|
+
* ...filterByTags(manifest, ['payments']).capabilities,
|
|
269
|
+
* ]
|
|
270
|
+
*/
|
|
271
|
+
export function filterByTags(manifest, tags) {
|
|
272
|
+
if (tags.length === 0)
|
|
273
|
+
return manifest;
|
|
274
|
+
const tagSet = new Set(tags);
|
|
275
|
+
return {
|
|
276
|
+
...manifest,
|
|
277
|
+
capabilities: manifest.capabilities.filter(cap => cap.tags?.length && tags.every(t => cap.tags.includes(t))),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
227
280
|
/**
|
|
228
281
|
* Returns a fixed bonus in normalized points (0–15), applied after BM25 normalization.
|
|
229
282
|
* 5 points per matching bigram, saturates at 3 bigrams (15 points).
|
|
@@ -252,13 +305,18 @@ export function resolverToIntent(cap) {
|
|
|
252
305
|
/**
|
|
253
306
|
* Strips characters that could break LLM prompt structure from
|
|
254
307
|
* capability field values before injection into the system prompt.
|
|
255
|
-
* Removes control characters, newlines,
|
|
308
|
+
* Removes control characters, newlines, delimiter sequences, and braces
|
|
309
|
+
* anywhere in the string (not just at line starts) to resist prompt injection
|
|
310
|
+
* from third-party OpenAPI spec content ingested via parseOpenAPI().
|
|
256
311
|
*/
|
|
257
312
|
export function sanitizeForPrompt(value, maxLen) {
|
|
258
313
|
return value
|
|
259
|
-
.replace(/[\r\n\t]/g, ' ') // newlines → space
|
|
314
|
+
.replace(/[\r\n\t]/g, ' ') // newlines/tabs → space
|
|
260
315
|
.replace(/---+/g, '—') // horizontal rules → em dash
|
|
261
|
-
.replace(
|
|
316
|
+
.replace(/[{}\[\]]/g, ' ') // all braces/brackets anywhere → space (was: leading only)
|
|
317
|
+
.split(' ') // per-word cap — limits injection payload per token
|
|
318
|
+
.map(w => w.slice(0, 200)) // no single token longer than 200 chars
|
|
319
|
+
.join(' ')
|
|
262
320
|
.replace(/\s+/g, ' ') // collapse whitespace
|
|
263
321
|
.trim()
|
|
264
322
|
.slice(0, maxLen);
|
|
@@ -290,11 +348,28 @@ export function extractParams(query, cap) {
|
|
|
290
348
|
result[param.name] = null;
|
|
291
349
|
continue;
|
|
292
350
|
}
|
|
293
|
-
// ──
|
|
351
|
+
// ── Type-implied pattern extraction ───────────────────────────────────
|
|
352
|
+
// param.type implies a TYPE_PATTERNS match — no need to set pattern explicitly
|
|
353
|
+
if (param.type && !param.pattern) {
|
|
354
|
+
// Map param types that have direct regex equivalents
|
|
355
|
+
const typeToPattern = {
|
|
356
|
+
email: TYPE_PATTERNS.email,
|
|
357
|
+
date: TYPE_PATTERNS.date,
|
|
358
|
+
url: TYPE_PATTERNS.url,
|
|
359
|
+
};
|
|
360
|
+
const impliedPattern = typeToPattern[param.type];
|
|
361
|
+
if (impliedPattern) {
|
|
362
|
+
const match = query.match(impliedPattern);
|
|
363
|
+
if (match) {
|
|
364
|
+
result[param.name] = match[0];
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// ── Explicit pattern extraction (highest priority when set) ───────────
|
|
294
370
|
if (param.pattern) {
|
|
295
371
|
const namedPattern = TYPE_PATTERNS[param.pattern];
|
|
296
372
|
if (namedPattern) {
|
|
297
|
-
// Named type pattern — match regex directly against full query
|
|
298
373
|
const match = query.match(namedPattern);
|
|
299
374
|
if (match) {
|
|
300
375
|
result[param.name] = match[0];
|
|
@@ -302,7 +377,6 @@ export function extractParams(query, cap) {
|
|
|
302
377
|
}
|
|
303
378
|
}
|
|
304
379
|
else if (param.pattern.includes(`{${param.name}}`)) {
|
|
305
|
-
// Example template — positional extraction
|
|
306
380
|
const extracted = extractFromTemplate(query, param.pattern, param.name);
|
|
307
381
|
if (extracted) {
|
|
308
382
|
result[param.name] = extracted;
|
|
@@ -363,10 +437,36 @@ export function extractParams(query, cap) {
|
|
|
363
437
|
extracted = candidate;
|
|
364
438
|
}
|
|
365
439
|
}
|
|
440
|
+
// ── Enum validation ───────────────────────────────────────────────────
|
|
441
|
+
if (extracted !== null && param.type === 'enum' && param.enum?.length) {
|
|
442
|
+
if (!param.enum.includes(extracted)) {
|
|
443
|
+
// Extracted value not in allowed list — treat as not found
|
|
444
|
+
extracted = null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
366
447
|
result[param.name] = extracted;
|
|
367
448
|
}
|
|
368
449
|
return result;
|
|
369
450
|
}
|
|
451
|
+
/**
|
|
452
|
+
* Calibrates a BM25 normalization ceiling from the manifest.
|
|
453
|
+
* Scores each capability against all of its own examples and returns the maximum.
|
|
454
|
+
* Call once at manifest load time — O(capabilities × examples).
|
|
455
|
+
*/
|
|
456
|
+
export function calibrateCeiling(capabilities, bm25Index, k1, b) {
|
|
457
|
+
let max = 0;
|
|
458
|
+
for (const cap of capabilities) {
|
|
459
|
+
if (!cap.examples?.length)
|
|
460
|
+
continue;
|
|
461
|
+
for (const example of cap.examples) {
|
|
462
|
+
const selfWords = new Set(tokenize(example));
|
|
463
|
+
const raw = scoreCapability(selfWords, cap, bm25Index, k1, b);
|
|
464
|
+
if (raw > max)
|
|
465
|
+
max = raw;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return max > 0 ? max : 100;
|
|
469
|
+
}
|
|
370
470
|
export function match(query, manifest, options = {}) {
|
|
371
471
|
if (!query?.trim()) {
|
|
372
472
|
logger.warn('Empty query received');
|
|
@@ -441,28 +541,58 @@ export function match(query, manifest, options = {}) {
|
|
|
441
541
|
const k1 = options.bm25K1 ?? 1.5;
|
|
442
542
|
const b = options.bm25B ?? 0.75;
|
|
443
543
|
// Calibrate ceiling — max self-score for normalization
|
|
444
|
-
const ceiling = options.bm25Ceiling ?? (
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
const raw = scoreCapability(selfWords, cap, bm25Index, k1, b);
|
|
451
|
-
if (raw > max)
|
|
452
|
-
max = raw;
|
|
453
|
-
}
|
|
454
|
-
return max > 0 ? max : 100;
|
|
455
|
-
})();
|
|
456
|
-
const allScores = [];
|
|
544
|
+
const ceiling = options.bm25Ceiling ?? calibrateCeiling(manifest.capabilities, bm25Index, k1, b);
|
|
545
|
+
// Build per-source ranked lists for RRF fusion
|
|
546
|
+
const keywordRanking = [];
|
|
547
|
+
const fuzzyRanking = [];
|
|
548
|
+
const embeddingRanking = [];
|
|
549
|
+
const keywordScoreMap = new Map();
|
|
457
550
|
for (const cap of manifest.capabilities) {
|
|
458
551
|
const rawBM25 = scoreCapability(qWordSet, cap, bm25Index, k1, b);
|
|
459
552
|
const bm25Score = Math.min(100, Math.round((rawBM25 / ceiling) * 100));
|
|
460
553
|
const bonusPoints = bigramBonus(qBigrams, bm25Index.bigrams[cap.id] ?? new Set());
|
|
461
554
|
const keywordScore = Math.min(100, bm25Score + bonusPoints);
|
|
462
555
|
const fuzzyScore = fuzzyScoreMap.get(cap.id) ?? 0;
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
556
|
+
const embScore = options.embeddingScores?.get(cap.id) ?? 0;
|
|
557
|
+
if (keywordScore > 0)
|
|
558
|
+
keywordRanking.push({ id: cap.id, score: keywordScore });
|
|
559
|
+
keywordScoreMap.set(cap.id, keywordScore);
|
|
560
|
+
if (fuzzyScore > 0)
|
|
561
|
+
fuzzyRanking.push({ id: cap.id, score: fuzzyScore });
|
|
562
|
+
if (embScore > 0)
|
|
563
|
+
embeddingRanking.push({ id: cap.id, score: embScore });
|
|
564
|
+
}
|
|
565
|
+
// RRF fusion. Anchor to theoretical max — a rank-1 entry in all lists scores
|
|
566
|
+
// rankings.length/(k+1). Using observed max instead inflates zero-overlap queries
|
|
567
|
+
// (all capabilities rank equally) to 100%, breaking out-of-scope rejection.
|
|
568
|
+
const rrfK = 60;
|
|
569
|
+
const rankings = [
|
|
570
|
+
keywordRanking,
|
|
571
|
+
...(fuzzyRanking.length > 0 ? [fuzzyRanking] : []),
|
|
572
|
+
...(embeddingRanking.length > 0 ? [embeddingRanking] : []),
|
|
573
|
+
];
|
|
574
|
+
const rrfScores = rrf(rankings, rrfK);
|
|
575
|
+
const theoreticalMax = rankings.length / (rrfK + 1);
|
|
576
|
+
// Pre-compute rank maps — rank 0 = best. Used for accurate via attribution.
|
|
577
|
+
const rankIn = (list, id) => {
|
|
578
|
+
const idx = list.findIndex(e => e.id === id);
|
|
579
|
+
return idx === -1 ? Infinity : idx;
|
|
580
|
+
};
|
|
581
|
+
const allScores = [];
|
|
582
|
+
for (const cap of manifest.capabilities) {
|
|
583
|
+
const rrfScore = rrfScores.get(cap.id) ?? 0;
|
|
584
|
+
const score = Math.min(100, Math.round((rrfScore / theoreticalMax) * 100));
|
|
585
|
+
const keywordScore = keywordScoreMap.get(cap.id) ?? 0;
|
|
586
|
+
const fuzzyScore = fuzzyScoreMap.get(cap.id) ?? 0;
|
|
587
|
+
const embScore = options.embeddingScores?.get(cap.id) ?? 0;
|
|
588
|
+
// via = whichever signal ranked this capability highest (lowest rank index).
|
|
589
|
+
// Uses rank position rather than raw score — RRF is rank-based, not score-based.
|
|
590
|
+
const kRank = rankIn(keywordRanking, cap.id);
|
|
591
|
+
const fRank = rankIn(fuzzyRanking, cap.id);
|
|
592
|
+
const eRank = rankIn(embeddingRanking, cap.id);
|
|
593
|
+
const via = eRank < fRank && eRank < kRank ? 'embedding' :
|
|
594
|
+
fRank < kRank ? 'fuzzy' : 'keyword';
|
|
595
|
+
logger.debug(` scored "${cap.id}": ${score}% (keyword: ${keywordScore}%, fuzzy: ${Math.round(fuzzyScore)}%, emb: ${Math.round(embScore)}%, rrf: ${rrfScore.toFixed(4)})`);
|
|
466
596
|
allScores.push({ cap, score, via });
|
|
467
597
|
if (score > bestScore) {
|
|
468
598
|
bestScore = score;
|
|
@@ -492,7 +622,8 @@ export function match(query, manifest, options = {}) {
|
|
|
492
622
|
logger.debug(`Extracted params: ${JSON.stringify(Object.fromEntries(Object.entries(params).map(([k, v]) => [k, v != null ? '[REDACTED]' : 'null'])))}`);
|
|
493
623
|
// Use the via tag tracked during scoring — avoids redundant scoreCapability call.
|
|
494
624
|
const bestEntry = allScores.find(s => s.cap.id === best.id);
|
|
495
|
-
const winner = bestEntry?.via === '
|
|
625
|
+
const winner = bestEntry?.via === 'embedding' ? 'embedding match' :
|
|
626
|
+
bestEntry?.via === 'fuzzy' ? 'fuzzy match' : 'keyword scoring';
|
|
496
627
|
// Matched return:
|
|
497
628
|
return {
|
|
498
629
|
capability: best,
|
|
@@ -514,17 +645,17 @@ export function match(query, manifest, options = {}) {
|
|
|
514
645
|
* wrapper that maps the prompt to a proper system message, keeping user query
|
|
515
646
|
* data in the user turn only.
|
|
516
647
|
*/
|
|
517
|
-
export async function matchWithLLM(query,
|
|
648
|
+
export async function matchWithLLM(query, topCandidates, options) {
|
|
518
649
|
// Truncate description and examples — prevents context window overflow and
|
|
519
650
|
// reduces prompt injection surface from third-party OpenAPI spec content.
|
|
520
651
|
const MAX_DESC_LEN = 200;
|
|
521
652
|
const MAX_EXAMPLE_LEN = 100;
|
|
522
|
-
const manifestSummary =
|
|
653
|
+
const manifestSummary = topCandidates.map(c => `- ${c.id} (${c.resolver.type}): ${sanitizeForPrompt(c.description, MAX_DESC_LEN)}${c.examples?.length
|
|
523
654
|
? `\n examples: ${c.examples.slice(0, 2).map(e => sanitizeForPrompt(e, MAX_EXAMPLE_LEN)).join(', ')}`
|
|
524
655
|
: ''}`).join('\n');
|
|
525
656
|
// Sanitize app name — strip newlines and control characters that could
|
|
526
657
|
// break the prompt structure or inject additional instructions.
|
|
527
|
-
const safeApp = sanitizeForPrompt(
|
|
658
|
+
const safeApp = sanitizeForPrompt(options.app ?? 'the application', 100);
|
|
528
659
|
const prompt = `You are an intent matcher for an AI agent system.
|
|
529
660
|
|
|
530
661
|
App: ${safeApp}
|
|
@@ -566,7 +697,7 @@ ${JSON.stringify({ user_query: query })}
|
|
|
566
697
|
const isOOS = parsed.matched_capability === 'OUT_OF_SCOPE';
|
|
567
698
|
const capability = isOOS
|
|
568
699
|
? null
|
|
569
|
-
:
|
|
700
|
+
: topCandidates.find(c => c.id === parsed.matched_capability) ?? null;
|
|
570
701
|
// If LLM returned an unknown capability ID, treat as out of scope
|
|
571
702
|
const effectivelyOOS = isOOS || capability === null;
|
|
572
703
|
if (!isOOS && capability === null) {
|
|
@@ -575,8 +706,14 @@ ${JSON.stringify({ user_query: query })}
|
|
|
575
706
|
// Build full candidate list — all capabilities scored, LLM winner marked as matched.
|
|
576
707
|
// This aligns the shape with keyword match results and allows the learning boost
|
|
577
708
|
// to surface alternatives if the LLM made a wrong call.
|
|
578
|
-
|
|
579
|
-
|
|
709
|
+
// Clamp and round confidence — LLM may return values outside 0–100 with
|
|
710
|
+
// misconfigured models or prompt drift. Unclamped values corrupt learning
|
|
711
|
+
// weights (weight = confidence/100 can exceed 1.0) and verdict margins.
|
|
712
|
+
// disambiguateLLM() already does this; apply the same treatment here.
|
|
713
|
+
const llmConfidence = effectivelyOOS
|
|
714
|
+
? 0
|
|
715
|
+
: Math.min(100, Math.max(0, Math.round(parsed.confidence)));
|
|
716
|
+
const allCandidates = topCandidates.map(c => ({
|
|
580
717
|
capabilityId: c.id,
|
|
581
718
|
score: c.id === capability?.id ? llmConfidence : 0,
|
|
582
719
|
matched: c.id === capability?.id,
|
package/dist/esm/parser.js
CHANGED
|
@@ -31,7 +31,16 @@ async function loadSpec(source) {
|
|
|
31
31
|
return parseSpecText(text, source);
|
|
32
32
|
}
|
|
33
33
|
// Local file
|
|
34
|
-
const
|
|
34
|
+
const cwd = process.cwd();
|
|
35
|
+
const resolved = path.resolve(cwd, source);
|
|
36
|
+
// Guard against path traversal — same check used by FileCache and FileLearningStore.
|
|
37
|
+
// Prevents parseOpenAPI('../../etc/passwd') from reading arbitrary files when
|
|
38
|
+
// the source argument comes from user input (CLI args, UI, CI scripts).
|
|
39
|
+
const allowedPrefix = cwd === '/' ? '/' : cwd + path.sep;
|
|
40
|
+
if (!resolved.startsWith(allowedPrefix)) {
|
|
41
|
+
throw new Error(`Spec path "${source}" resolves outside the working directory.\n` +
|
|
42
|
+
`Resolved: ${resolved}\nAllowed: ${cwd}`);
|
|
43
|
+
}
|
|
35
44
|
if (!fs.existsSync(resolved)) {
|
|
36
45
|
throw new Error(`Spec file not found: ${resolved}`);
|
|
37
46
|
}
|
|
@@ -101,11 +110,20 @@ function convertSpec(spec) {
|
|
|
101
110
|
warnings.push(`Skipped ${method} ${urlPath} — no useful info to generate capability`);
|
|
102
111
|
continue;
|
|
103
112
|
}
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
113
|
+
// De-conflict duplicate IDs — loop until the candidate ID is unique.
|
|
114
|
+
// A single find() check is insufficient: if two operations both produce
|
|
115
|
+
// `get_user`, the second becomes `get_user_get`. A third `get_user` would
|
|
116
|
+
// then collide with `get_user_get` only when it also uses GET — the general
|
|
117
|
+
// multi-collision case is only caught by looping.
|
|
118
|
+
let candidateId = result.id;
|
|
119
|
+
let dedupeCount = 0;
|
|
120
|
+
while (capabilities.find(c => c.id === candidateId)) {
|
|
121
|
+
dedupeCount++;
|
|
122
|
+
candidateId = `${result.id}_${method.toLowerCase()}${dedupeCount > 1 ? `_${dedupeCount}` : ''}`;
|
|
123
|
+
}
|
|
124
|
+
if (candidateId !== result.id) {
|
|
125
|
+
warnings.push(`Duplicate ID resolved: ${result.id} → ${candidateId}`);
|
|
126
|
+
result.id = candidateId;
|
|
109
127
|
}
|
|
110
128
|
capabilities.push(result);
|
|
111
129
|
}
|
|
@@ -259,9 +277,9 @@ function extractBaseUrl(spec) {
|
|
|
259
277
|
const base = spec.basePath ?? '';
|
|
260
278
|
return `${scheme}://${spec.host}${base}`.replace(/\/$/, '');
|
|
261
279
|
}
|
|
262
|
-
|
|
263
|
-
`
|
|
264
|
-
|
|
280
|
+
throw new Error(`No server URL found in OpenAPI spec — cannot determine base URL.\n` +
|
|
281
|
+
`Add a "servers" entry (OpenAPI 3.x) or "host" + "basePath" (Swagger 2.x), ` +
|
|
282
|
+
`or set baseUrl manually in capman.config.js after generating.`);
|
|
265
283
|
}
|
|
266
284
|
function sanitizeAppName(title) {
|
|
267
285
|
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
package/dist/esm/resolver.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MatchResult, ResolveResult } from './types';
|
|
1
|
+
import type { MatchResult, ResolveResult, Capability } from './types';
|
|
2
2
|
export interface AuthContext {
|
|
3
3
|
/** Whether the current request is authenticated */
|
|
4
4
|
isAuthenticated: boolean;
|
|
@@ -25,5 +25,5 @@ export interface ResolveOptions {
|
|
|
25
25
|
*/
|
|
26
26
|
retryAllMethods?: boolean;
|
|
27
27
|
}
|
|
28
|
-
export declare function checkPrivacy(capability:
|
|
28
|
+
export declare function checkPrivacy(capability: Capability, auth?: AuthContext): string | null;
|
|
29
29
|
export declare function resolve(matchResult: MatchResult, params?: Record<string, unknown>, options?: ResolveOptions): Promise<ResolveResult>;
|
package/dist/esm/resolver.js
CHANGED
|
@@ -68,13 +68,13 @@ export async function resolve(matchResult, params = {}, options = {}) {
|
|
|
68
68
|
.map(p => p.name));
|
|
69
69
|
switch (resolver.type) {
|
|
70
70
|
case 'api':
|
|
71
|
-
return await resolveApi(resolver, enrichedParams, options, sessionParamNames);
|
|
71
|
+
return await resolveApi(resolver, enrichedParams, options, sessionParamNames, capability.errors ?? []);
|
|
72
72
|
case 'nav':
|
|
73
73
|
return resolveNav(resolver, enrichedParams);
|
|
74
74
|
case 'hybrid': {
|
|
75
75
|
logger.debug('Hybrid resolver — running API and nav in parallel');
|
|
76
76
|
const [apiResult, navResult] = await Promise.all([
|
|
77
|
-
resolveApi(resolver.api, enrichedParams, options, sessionParamNames),
|
|
77
|
+
resolveApi(resolver.api, enrichedParams, options, sessionParamNames, capability.errors ?? []),
|
|
78
78
|
Promise.resolve(resolveNav(resolver.nav, enrichedParams)),
|
|
79
79
|
]);
|
|
80
80
|
return {
|
|
@@ -116,23 +116,27 @@ export async function resolve(matchResult, params = {}, options = {}) {
|
|
|
116
116
|
* Full partial success reporting (partialSuccess, completedCalls, failedCalls)
|
|
117
117
|
* is planned for a future version.
|
|
118
118
|
*/
|
|
119
|
-
async function resolveApi(resolver, params, options, sessionParamNames = new Set()) {
|
|
119
|
+
async function resolveApi(resolver, params, options, sessionParamNames = new Set(), capabilityErrors = []) {
|
|
120
120
|
const startTime = Date.now();
|
|
121
121
|
const retries = options.retries ?? 0;
|
|
122
122
|
const timeoutMs = options.timeoutMs ?? 5000;
|
|
123
|
+
// Map url → endpoint metadata for idempotency and Idempotency-Key injection
|
|
124
|
+
const endpointMeta = new Map();
|
|
123
125
|
const apiCalls = resolver.endpoints.map(endpoint => {
|
|
124
|
-
// Build per-endpoint params — only inject session params if this
|
|
125
|
-
// specific endpoint has the placeholder. Prevents userId leaking
|
|
126
|
-
// as ?user_id=xyz on endpoints that don't use it in their path.
|
|
127
126
|
const endpointParams = { ...params };
|
|
128
127
|
for (const name of sessionParamNames) {
|
|
129
128
|
if (!endpoint.path.includes(`{${name}}`)) {
|
|
130
|
-
delete endpointParams[name];
|
|
129
|
+
delete endpointParams[name];
|
|
131
130
|
}
|
|
132
131
|
}
|
|
132
|
+
const url = buildUrl(options.baseUrl ?? '', endpoint.path, endpointParams, sessionParamNames);
|
|
133
|
+
endpointMeta.set(url, {
|
|
134
|
+
idempotent: endpoint.idempotent,
|
|
135
|
+
idempotencyKey: endpoint.idempotencyKey,
|
|
136
|
+
});
|
|
133
137
|
return {
|
|
134
138
|
method: endpoint.method,
|
|
135
|
-
url
|
|
139
|
+
url,
|
|
136
140
|
params: Object.fromEntries(Object.entries(endpointParams).filter(([, v]) => v !== null && v !== undefined)),
|
|
137
141
|
};
|
|
138
142
|
});
|
|
@@ -151,23 +155,42 @@ async function resolveApi(resolver, params, options, sessionParamNames = new Set
|
|
|
151
155
|
// Only retry safe/idempotent methods — retrying POST/PUT/PATCH/DELETE
|
|
152
156
|
// can cause duplicate side effects (e.g. duplicate orders, double charges).
|
|
153
157
|
async function fetchWithRetry(call) {
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
+
const meta = endpointMeta.get(call.url);
|
|
159
|
+
// Explicit idempotent flag overrides method-based default
|
|
160
|
+
const isIdempotent = meta?.idempotent !== undefined
|
|
161
|
+
? meta.idempotent
|
|
162
|
+
: SAFE_METHODS.has(call.method);
|
|
163
|
+
const effectiveRetries = (options.retryAllMethods || isIdempotent) ? retries : 0;
|
|
164
|
+
let lastErr = new Error('fetchWithRetry: exhausted all attempts without result');
|
|
158
165
|
for (let attempt = 0; attempt <= effectiveRetries; attempt++) {
|
|
159
166
|
const controller = new AbortController();
|
|
160
167
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
161
168
|
try {
|
|
169
|
+
// Inject Idempotency-Key header when configured
|
|
170
|
+
const idempotencyHeaders = {};
|
|
171
|
+
if (meta?.idempotencyKey) {
|
|
172
|
+
const keyValue = call.params[meta.idempotencyKey];
|
|
173
|
+
if (keyValue !== null && keyValue !== undefined) {
|
|
174
|
+
idempotencyHeaders['Idempotency-Key'] = String(keyValue);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
162
177
|
const res = await fetchFn(call.url, {
|
|
163
178
|
method: call.method,
|
|
164
|
-
headers: options.headers ?? {},
|
|
179
|
+
headers: { ...options.headers ?? {}, ...idempotencyHeaders },
|
|
165
180
|
signal: controller.signal,
|
|
166
181
|
body: ['POST', 'PUT', 'PATCH'].includes(call.method)
|
|
167
182
|
? JSON.stringify(Object.fromEntries(Object.entries(call.params).filter(([, v]) => v !== null && v !== undefined)))
|
|
168
183
|
: undefined,
|
|
169
184
|
});
|
|
170
185
|
clearTimeout(timer);
|
|
186
|
+
// Throw on retryable 5xx — fetch() resolves (doesn't throw) on HTTP errors,
|
|
187
|
+
// so without this check a 503 is returned immediately with no retry.
|
|
188
|
+
// 4xx errors are not retried — they are client errors that won't change.
|
|
189
|
+
if (res.status >= 500 && attempt < effectiveRetries) {
|
|
190
|
+
lastErr = new Error(`HTTP ${res.status}`);
|
|
191
|
+
logger.warn(`Server error ${res.status} (attempt ${attempt + 1}/${effectiveRetries + 1}) — retrying`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
171
194
|
return res;
|
|
172
195
|
}
|
|
173
196
|
catch (err) {
|
|
@@ -184,32 +207,49 @@ async function resolveApi(resolver, params, options, sessionParamNames = new Set
|
|
|
184
207
|
}
|
|
185
208
|
throw lastErr;
|
|
186
209
|
}
|
|
210
|
+
let enrichedCalls = apiCalls.map(c => ({ ...c }));
|
|
187
211
|
try {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
212
|
+
const settled = await Promise.allSettled(apiCalls.map(c => fetchWithRetry(c)));
|
|
213
|
+
enrichedCalls = await Promise.all(settled.map(async (result, i) => {
|
|
214
|
+
if (result.status === 'rejected') {
|
|
215
|
+
const reason = result.reason;
|
|
216
|
+
logger.warn(`Endpoint ${apiCalls[i].method} ${apiCalls[i].url} failed: ${reason}`);
|
|
217
|
+
return {
|
|
218
|
+
...apiCalls[i],
|
|
219
|
+
status: 0,
|
|
220
|
+
error: reason instanceof Error ? reason.message : String(reason),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const res = result.value;
|
|
199
224
|
let data = undefined;
|
|
200
225
|
try {
|
|
201
226
|
const text = await res.text();
|
|
202
227
|
data = text ? JSON.parse(text) : undefined;
|
|
203
228
|
}
|
|
204
|
-
catch { /* non-JSON response */ }
|
|
229
|
+
catch { /* non-JSON response body */ }
|
|
205
230
|
return { ...apiCalls[i], status: res.status, data };
|
|
206
231
|
}));
|
|
232
|
+
const failedCall = enrichedCalls.find(c => typeof c.status === 'number' && (c.status === 0 || c.status >= 400));
|
|
233
|
+
if (failedCall) {
|
|
234
|
+
const matchedError = capabilityErrors.find(e => e.httpStatus === failedCall.status);
|
|
235
|
+
const statusLabel = failedCall.status === 0 ? 'network failure' : String(failedCall.status);
|
|
236
|
+
return {
|
|
237
|
+
success: false,
|
|
238
|
+
resolverType: 'api',
|
|
239
|
+
apiCalls: enrichedCalls,
|
|
240
|
+
durationMs: Date.now() - startTime,
|
|
241
|
+
error: matchedError
|
|
242
|
+
? `${matchedError.code}: ${matchedError.description}`
|
|
243
|
+
: `API request failed: ${statusLabel} on ${failedCall.method} ${failedCall.url}`,
|
|
244
|
+
matchedError,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
207
247
|
logger.debug(`API calls completed in ${Date.now() - startTime}ms`);
|
|
208
248
|
return { success: true, resolverType: 'api', apiCalls: enrichedCalls, durationMs: Date.now() - startTime };
|
|
209
249
|
}
|
|
210
250
|
catch (err) {
|
|
211
251
|
return {
|
|
212
|
-
success: false, resolverType: 'api', apiCalls,
|
|
252
|
+
success: false, resolverType: 'api', apiCalls: enrichedCalls,
|
|
213
253
|
durationMs: Date.now() - startTime,
|
|
214
254
|
error: err instanceof Error ? err.message : String(err),
|
|
215
255
|
};
|