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.
Files changed (64) hide show
  1. package/CODEBASE.md +6 -5
  2. package/dist/cjs/cache.d.ts +9 -0
  3. package/dist/cjs/cache.d.ts.map +1 -1
  4. package/dist/cjs/cache.js +37 -7
  5. package/dist/cjs/cache.js.map +1 -1
  6. package/dist/cjs/concurrent.d.ts +53 -0
  7. package/dist/cjs/concurrent.d.ts.map +1 -0
  8. package/dist/cjs/concurrent.js +71 -0
  9. package/dist/cjs/concurrent.js.map +1 -0
  10. package/dist/cjs/engine.d.ts +92 -7
  11. package/dist/cjs/engine.d.ts.map +1 -1
  12. package/dist/cjs/engine.js +269 -57
  13. package/dist/cjs/engine.js.map +1 -1
  14. package/dist/cjs/generator.d.ts.map +1 -1
  15. package/dist/cjs/generator.js +28 -6
  16. package/dist/cjs/generator.js.map +1 -1
  17. package/dist/cjs/index.d.ts +3 -1
  18. package/dist/cjs/index.d.ts.map +1 -1
  19. package/dist/cjs/index.js +5 -1
  20. package/dist/cjs/index.js.map +1 -1
  21. package/dist/cjs/learning.d.ts +16 -1
  22. package/dist/cjs/learning.d.ts.map +1 -1
  23. package/dist/cjs/learning.js +95 -14
  24. package/dist/cjs/learning.js.map +1 -1
  25. package/dist/cjs/matcher.d.ts +51 -2
  26. package/dist/cjs/matcher.d.ts.map +1 -1
  27. package/dist/cjs/matcher.js +173 -33
  28. package/dist/cjs/matcher.js.map +1 -1
  29. package/dist/cjs/parser.js +27 -9
  30. package/dist/cjs/parser.js.map +1 -1
  31. package/dist/cjs/resolver.d.ts +2 -2
  32. package/dist/cjs/resolver.d.ts.map +1 -1
  33. package/dist/cjs/resolver.js +66 -26
  34. package/dist/cjs/resolver.js.map +1 -1
  35. package/dist/cjs/schema.d.ts +821 -68
  36. package/dist/cjs/schema.d.ts.map +1 -1
  37. package/dist/cjs/schema.js +62 -13
  38. package/dist/cjs/schema.js.map +1 -1
  39. package/dist/cjs/types.d.ts +156 -9
  40. package/dist/cjs/types.d.ts.map +1 -1
  41. package/dist/cjs/version.d.ts +1 -1
  42. package/dist/cjs/version.js +1 -1
  43. package/dist/esm/cache.d.ts +9 -0
  44. package/dist/esm/cache.js +37 -7
  45. package/dist/esm/concurrent.d.ts +52 -0
  46. package/dist/esm/concurrent.js +66 -0
  47. package/dist/esm/engine.d.ts +92 -7
  48. package/dist/esm/engine.js +270 -58
  49. package/dist/esm/generator.js +28 -6
  50. package/dist/esm/index.d.ts +3 -1
  51. package/dist/esm/index.js +2 -0
  52. package/dist/esm/learning.d.ts +16 -1
  53. package/dist/esm/learning.js +95 -14
  54. package/dist/esm/matcher.d.ts +51 -2
  55. package/dist/esm/matcher.js +170 -33
  56. package/dist/esm/parser.js +27 -9
  57. package/dist/esm/resolver.d.ts +2 -2
  58. package/dist/esm/resolver.js +66 -26
  59. package/dist/esm/schema.d.ts +821 -68
  60. package/dist/esm/schema.js +62 -13
  61. package/dist/esm/types.d.ts +156 -9
  62. package/dist/esm/version.d.ts +1 -1
  63. package/dist/esm/version.js +1 -1
  64. package/package.json +1 -1
@@ -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
- 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;
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, and delimiter-like sequences.
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(/^\s*[{}\[\]]/gm, ' ') // leading braces/brackets → space
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
- // ── Pattern extraction (highest priority) ─────────────────────────────
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
- 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
- })();
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 via = fuzzyScore > keywordScore ? 'fuzzy' : 'keyword';
464
- const score = Math.min(100, Math.round(Math.max(keywordScore, fuzzyScore)));
465
- logger.debug(` scored "${cap.id}": ${score}% (keyword: ${keywordScore}%, fuzzy: ${Math.round(fuzzyScore)}%)`);
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 === 'fuzzy' ? 'fuzzy match' : 'keyword scoring';
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, manifest, options) {
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 = manifest.capabilities.map(c => `- ${c.id} (${c.resolver.type}): ${sanitizeForPrompt(c.description, MAX_DESC_LEN)}${c.examples?.length
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(manifest.app, 100);
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
- : manifest.capabilities.find(c => c.id === parsed.matched_capability) ?? null;
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
- const llmConfidence = effectivelyOOS ? 0 : parsed.confidence;
579
- const allCandidates = manifest.capabilities.map(c => ({
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,
@@ -31,7 +31,16 @@ async function loadSpec(source) {
31
31
  return parseSpecText(text, source);
32
32
  }
33
33
  // Local file
34
- const resolved = path.resolve(process.cwd(), source);
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
- // Check for duplicate IDs
105
- const existing = capabilities.find(c => c.id === result.id);
106
- if (existing) {
107
- result.id = `${result.id}_${method.toLowerCase()}`;
108
- warnings.push(`Duplicate ID resolved: ${result.id}`);
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
- 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.`);
264
- return 'https://api.your-app.com';
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, '');
@@ -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: import('./types').Capability, auth?: AuthContext): string | null;
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>;
@@ -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]; // strip session param — not in this endpoint's path
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: buildUrl(options.baseUrl ?? '', endpoint.path, endpointParams, sessionParamNames),
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 effectiveRetries = (options.retryAllMethods || SAFE_METHODS.has(call.method))
155
- ? retries
156
- : 0;
157
- let lastErr;
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 responses = await Promise.all(apiCalls.map(c => fetchWithRetry(c)));
189
- const failedIdx = responses.findIndex(r => !r.ok);
190
- if (failedIdx !== -1) {
191
- const failed = responses[failedIdx];
192
- return {
193
- success: false, resolverType: 'api', apiCalls,
194
- durationMs: Date.now() - startTime,
195
- error: `API request failed: ${failed.status} ${failed.statusText}`,
196
- };
197
- }
198
- const enrichedCalls = await Promise.all(responses.map(async (res, i) => {
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
  };