capman 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/bin/capman.js +221 -5
  2. package/dist/cjs/cache.d.ts +42 -0
  3. package/dist/cjs/cache.d.ts.map +1 -0
  4. package/dist/cjs/cache.js +179 -0
  5. package/dist/cjs/cache.js.map +1 -0
  6. package/dist/cjs/engine.d.ts +82 -0
  7. package/dist/cjs/engine.d.ts.map +1 -0
  8. package/dist/cjs/engine.js +233 -0
  9. package/dist/cjs/engine.js.map +1 -0
  10. package/dist/cjs/generator.js +2 -1
  11. package/dist/cjs/generator.js.map +1 -1
  12. package/dist/cjs/index.d.ts +7 -1
  13. package/dist/cjs/index.d.ts.map +1 -1
  14. package/dist/cjs/index.js +13 -1
  15. package/dist/cjs/index.js.map +1 -1
  16. package/dist/cjs/learning.d.ts +56 -0
  17. package/dist/cjs/learning.d.ts.map +1 -0
  18. package/dist/cjs/learning.js +155 -0
  19. package/dist/cjs/learning.js.map +1 -0
  20. package/dist/cjs/matcher.d.ts.map +1 -1
  21. package/dist/cjs/matcher.js +38 -5
  22. package/dist/cjs/matcher.js.map +1 -1
  23. package/dist/cjs/resolver.d.ts +4 -1
  24. package/dist/cjs/resolver.d.ts.map +1 -1
  25. package/dist/cjs/resolver.js +51 -21
  26. package/dist/cjs/resolver.js.map +1 -1
  27. package/dist/cjs/schema.d.ts +10 -10
  28. package/dist/cjs/types.d.ts +38 -5
  29. package/dist/cjs/types.d.ts.map +1 -1
  30. package/dist/cjs/version.d.ts +2 -0
  31. package/dist/cjs/version.d.ts.map +1 -0
  32. package/dist/cjs/version.js +6 -0
  33. package/dist/cjs/version.js.map +1 -0
  34. package/dist/esm/cache.js +139 -0
  35. package/dist/esm/engine.js +228 -0
  36. package/dist/esm/generator.js +2 -1
  37. package/dist/esm/index.js +6 -0
  38. package/dist/esm/learning.js +116 -0
  39. package/dist/esm/matcher.js +38 -5
  40. package/dist/esm/resolver.js +51 -21
  41. package/dist/esm/version.js +2 -0
  42. package/package.json +18 -12
@@ -0,0 +1,139 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { logger } from './logger';
4
+ // ─── Normalize query for cache key ────────────────────────────────────────────
5
+ function normalizeQuery(query) {
6
+ return query.toLowerCase().trim().replace(/\s+/g, ' ');
7
+ }
8
+ // ─── Memory Cache ─────────────────────────────────────────────────────────────
9
+ export class MemoryCache {
10
+ constructor() {
11
+ this.store = new Map();
12
+ }
13
+ async get(query) {
14
+ const key = normalizeQuery(query);
15
+ const entry = this.store.get(key);
16
+ if (entry) {
17
+ entry.hits++;
18
+ logger.debug(`Cache hit (memory): "${query}"`);
19
+ return entry;
20
+ }
21
+ return null;
22
+ }
23
+ async set(query, result) {
24
+ const key = normalizeQuery(query);
25
+ this.store.set(key, {
26
+ query,
27
+ result,
28
+ cachedAt: new Date().toISOString(),
29
+ hits: 0,
30
+ });
31
+ logger.debug(`Cache set (memory): "${query}"`);
32
+ }
33
+ async clear() {
34
+ this.store.clear();
35
+ }
36
+ async size() {
37
+ return this.store.size;
38
+ }
39
+ }
40
+ // ─── File Cache ───────────────────────────────────────────────────────────────
41
+ export class FileCache {
42
+ constructor(filePath = '.capman/cache.json') {
43
+ this.store = new Map();
44
+ this.loaded = false;
45
+ this.filePath = path.resolve(process.cwd(), filePath);
46
+ logger.info(`FileCache initialized — writing to: ${this.filePath}`);
47
+ }
48
+ async load() {
49
+ if (this.loaded)
50
+ return;
51
+ try {
52
+ const raw = await fs.promises.readFile(this.filePath, 'utf-8');
53
+ this.store = new Map(Object.entries(JSON.parse(raw)));
54
+ logger.debug(`File cache loaded: ${this.store.size} entries`);
55
+ }
56
+ catch {
57
+ // File doesn't exist yet — start fresh
58
+ }
59
+ this.loaded = true;
60
+ }
61
+ async save() {
62
+ try {
63
+ const dir = path.dirname(this.filePath);
64
+ await fs.promises.mkdir(dir, { recursive: true });
65
+ await fs.promises.writeFile(this.filePath, JSON.stringify(Object.fromEntries(this.store), null, 2));
66
+ }
67
+ catch {
68
+ logger.warn(`Failed to save file cache to ${this.filePath}`);
69
+ }
70
+ }
71
+ async get(query) {
72
+ await this.load();
73
+ const key = normalizeQuery(query);
74
+ const entry = this.store.get(key);
75
+ if (entry) {
76
+ entry.hits++;
77
+ logger.debug(`Cache hit (file): "${query}"`);
78
+ return entry;
79
+ }
80
+ return null;
81
+ }
82
+ async set(query, result) {
83
+ await this.load();
84
+ const key = normalizeQuery(query);
85
+ this.store.set(key, {
86
+ query,
87
+ result,
88
+ cachedAt: new Date().toISOString(),
89
+ hits: 0,
90
+ });
91
+ await this.save();
92
+ logger.debug(`Cache set (file): "${query}"`);
93
+ }
94
+ async clear() {
95
+ this.store.clear();
96
+ await this.save();
97
+ }
98
+ async size() {
99
+ await this.load();
100
+ return this.store.size;
101
+ }
102
+ }
103
+ // ─── Combo Cache (memory first, file fallback) ────────────────────────────────
104
+ export class ComboCache {
105
+ constructor(filePath = '.capman/cache.json') {
106
+ this.memory = new MemoryCache();
107
+ this.file = new FileCache(filePath);
108
+ }
109
+ async get(query) {
110
+ // Memory first — fastest
111
+ const memHit = await this.memory.get(query);
112
+ if (memHit)
113
+ return memHit;
114
+ // File fallback — persists across restarts
115
+ const fileHit = await this.file.get(query);
116
+ if (fileHit) {
117
+ // Promote to memory for next time
118
+ await this.memory.set(query, fileHit.result);
119
+ logger.debug(`Cache promoted to memory: "${query}"`);
120
+ return fileHit;
121
+ }
122
+ return null;
123
+ }
124
+ async set(query, result) {
125
+ await Promise.all([
126
+ this.memory.set(query, result),
127
+ this.file.set(query, result),
128
+ ]);
129
+ }
130
+ async clear() {
131
+ await Promise.all([
132
+ this.memory.clear(),
133
+ this.file.clear(),
134
+ ]);
135
+ }
136
+ async size() {
137
+ return this.file.size();
138
+ }
139
+ }
@@ -0,0 +1,228 @@
1
+ import { match as _match, matchWithLLM as _matchWithLLM } from './matcher';
2
+ import { resolve as _resolve } from './resolver';
3
+ import { MemoryCache } from './cache';
4
+ import { MemoryLearningStore } from './learning';
5
+ import { logger } from './logger';
6
+ // ─── CapmanEngine ─────────────────────────────────────────────────────────────
7
+ export class CapmanEngine {
8
+ constructor(options) {
9
+ this.manifest = options.manifest;
10
+ this.mode = options.mode ?? 'balanced';
11
+ this.llm = options.llm;
12
+ this.baseUrl = options.baseUrl;
13
+ this.auth = options.auth;
14
+ this.headers = options.headers;
15
+ this.threshold = options.threshold ?? 50;
16
+ // Cache — default MemoryCache (no filesystem writes), or disabled with false
17
+ // Use FileCache or ComboCache explicitly for persistence across restarts
18
+ this.cache = options.cache === false
19
+ ? null
20
+ : (options.cache ?? new MemoryCache());
21
+ // Learning — default MemoryLearningStore (no filesystem writes), or disabled with false
22
+ // Use FileLearningStore explicitly for persistence across restarts
23
+ this.learning = options.learning === false
24
+ ? null
25
+ : (options.learning ?? new MemoryLearningStore());
26
+ logger.info(`CapmanEngine initialized — mode: ${this.mode}, cache: ${this.cache ? 'enabled' : 'disabled'}, learning: ${this.learning ? 'enabled' : 'disabled'}`);
27
+ }
28
+ /**
29
+ * Ask the engine a natural language query.
30
+ * Automatically handles caching, matching, resolution, and learning.
31
+ *
32
+ * @example
33
+ * const engine = new CapmanEngine({ manifest, llm: myLLM })
34
+ * const result = await engine.ask("Check availability for blue jacket")
35
+ * console.log(result.match.capability?.id) // check_product_availability
36
+ * console.log(result.resolution.apiCalls) // [{ url: '...', method: 'GET' }]
37
+ * console.log(result.resolvedVia) // 'keyword' | 'llm' | 'cache'
38
+ */
39
+ async ask(query, overrides = {}) {
40
+ const start = Date.now();
41
+ const steps = [];
42
+ let resolvedVia = 'keyword';
43
+ // ── Step 1: Check cache ──────────────────────────────────────────────────
44
+ const cacheStart = Date.now();
45
+ if (this.cache) {
46
+ const cached = await this.cache.get(query);
47
+ if (cached) {
48
+ steps.push({ type: 'cache_check', status: 'hit', durationMs: Date.now() - cacheStart, detail: 'Served from cache' });
49
+ logger.info(`Cache hit for: "${query}"`);
50
+ const resolution = await _resolve(cached.result, cached.result.extractedParams, this.resolveOptions(overrides));
51
+ const trace = {
52
+ query,
53
+ candidates: cached.result.candidates ?? [],
54
+ reasoning: [`Served from cache (original: ${cached.result.reasoning})`],
55
+ steps,
56
+ resolvedVia: 'cache',
57
+ totalMs: Date.now() - start,
58
+ };
59
+ const result = {
60
+ match: cached.result,
61
+ resolution,
62
+ resolvedVia: 'cache',
63
+ durationMs: Date.now() - start,
64
+ trace,
65
+ };
66
+ await this.recordLearning(query, cached.result, 'cache');
67
+ return result;
68
+ }
69
+ steps.push({ type: 'cache_check', status: 'miss', durationMs: Date.now() - cacheStart });
70
+ }
71
+ else {
72
+ steps.push({ type: 'cache_check', status: 'skip', durationMs: 0, detail: 'Cache disabled' });
73
+ }
74
+ // ── Step 2: Match ────────────────────────────────────────────────────────
75
+ let matchResult;
76
+ switch (this.mode) {
77
+ case 'cheap': {
78
+ const t = Date.now();
79
+ matchResult = _match(query, this.manifest);
80
+ steps.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t, detail: `confidence: ${matchResult.confidence}%` });
81
+ break;
82
+ }
83
+ case 'accurate': {
84
+ if (this.llm) {
85
+ const t = Date.now();
86
+ matchResult = await _matchWithLLM(query, this.manifest, { llm: this.llm });
87
+ resolvedVia = 'llm';
88
+ steps.push({ type: 'llm_match', status: 'pass', durationMs: Date.now() - t, detail: `confidence: ${matchResult.confidence}%` });
89
+ }
90
+ else {
91
+ logger.warn('accurate mode requires llm — falling back to keyword');
92
+ const t = Date.now();
93
+ matchResult = _match(query, this.manifest);
94
+ steps.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t, detail: 'llm not provided, used keyword' });
95
+ }
96
+ break;
97
+ }
98
+ case 'balanced':
99
+ default: {
100
+ const t1 = Date.now();
101
+ const keywordResult = _match(query, this.manifest);
102
+ steps.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t1, detail: `confidence: ${keywordResult.confidence}%` });
103
+ if (keywordResult.confidence >= this.threshold || !this.llm) {
104
+ matchResult = keywordResult;
105
+ }
106
+ else {
107
+ logger.info(`Low confidence (${keywordResult.confidence}%) — escalating to LLM`);
108
+ const t2 = Date.now();
109
+ matchResult = await _matchWithLLM(query, this.manifest, { llm: this.llm });
110
+ resolvedVia = 'llm';
111
+ steps.push({ type: 'llm_match', status: 'pass', durationMs: Date.now() - t2, detail: `confidence: ${matchResult.confidence}%` });
112
+ }
113
+ break;
114
+ }
115
+ }
116
+ // ── Step 3: Privacy check ────────────────────────────────────────────────
117
+ if (matchResult.capability) {
118
+ const privacyLevel = matchResult.capability.privacy.level;
119
+ steps.push({
120
+ type: 'privacy_check',
121
+ status: 'pass',
122
+ durationMs: 0,
123
+ detail: `level: ${privacyLevel}`,
124
+ });
125
+ }
126
+ // ── Step 4: Cache the match result ───────────────────────────────────────
127
+ if (this.cache && matchResult.capability) {
128
+ await this.cache.set(query, matchResult);
129
+ }
130
+ // ── Step 5: Resolve ──────────────────────────────────────────────────────
131
+ const resolveStart = Date.now();
132
+ const resolution = await _resolve(matchResult, matchResult.extractedParams, this.resolveOptions(overrides));
133
+ steps.push({
134
+ type: 'resolve',
135
+ status: resolution.success ? 'pass' : 'fail',
136
+ durationMs: Date.now() - resolveStart,
137
+ detail: resolution.error ?? `via ${resolution.resolverType}`,
138
+ });
139
+ // ── Step 6: Build reasoning array ────────────────────────────────────────
140
+ const reasoning = [];
141
+ if (matchResult.candidates?.length) {
142
+ const winner = matchResult.candidates.find(c => c.matched);
143
+ const rejected = matchResult.candidates
144
+ .filter(c => !c.matched && c.score > 0)
145
+ .sort((a, b) => b.score - a.score)
146
+ .slice(0, 3);
147
+ if (winner) {
148
+ reasoning.push(`Matched "${winner.capabilityId}" with ${winner.score}% confidence`);
149
+ }
150
+ if (rejected.length) {
151
+ reasoning.push(`Rejected: ${rejected.map(r => `${r.capabilityId} (${r.score}%)`).join(', ')}`);
152
+ }
153
+ reasoning.push(`Resolved via: ${resolvedVia}`);
154
+ if (matchResult.extractedParams && Object.keys(matchResult.extractedParams).length) {
155
+ const params = Object.entries(matchResult.extractedParams)
156
+ .map(([k, v]) => `${k}=${v}`)
157
+ .join(', ');
158
+ reasoning.push(`Extracted params: ${params}`);
159
+ }
160
+ }
161
+ else {
162
+ reasoning.push(matchResult.reasoning);
163
+ }
164
+ // ── Step 7: Record learning ──────────────────────────────────────────────
165
+ await this.recordLearning(query, matchResult, resolvedVia);
166
+ const trace = {
167
+ query,
168
+ candidates: matchResult.candidates ?? [],
169
+ reasoning,
170
+ steps,
171
+ resolvedVia,
172
+ totalMs: Date.now() - start,
173
+ };
174
+ return {
175
+ match: matchResult,
176
+ resolution,
177
+ resolvedVia,
178
+ durationMs: Date.now() - start,
179
+ trace,
180
+ };
181
+ }
182
+ /**
183
+ * Get stats from the learning store.
184
+ * Shows which capabilities are most used, LLM vs keyword ratio, cache hit rate.
185
+ */
186
+ async getStats() {
187
+ if (!this.learning)
188
+ return null;
189
+ return this.learning.getStats();
190
+ }
191
+ /**
192
+ * Get the most frequently matched capabilities.
193
+ */
194
+ async getTopCapabilities(limit = 5) {
195
+ if (!this.learning)
196
+ return [];
197
+ return this.learning.getTopCapabilities(limit);
198
+ }
199
+ /**
200
+ * Clear the cache.
201
+ */
202
+ async clearCache() {
203
+ if (this.cache)
204
+ await this.cache.clear();
205
+ }
206
+ // ── Private helpers ────────────────────────────────────────────────────────
207
+ resolveOptions(overrides = {}) {
208
+ return {
209
+ baseUrl: this.baseUrl,
210
+ auth: this.auth,
211
+ headers: this.headers,
212
+ ...overrides,
213
+ };
214
+ }
215
+ async recordLearning(query, matchResult, resolvedVia) {
216
+ if (!this.learning)
217
+ return;
218
+ await this.learning.record({
219
+ query,
220
+ capabilityId: matchResult.capability?.id ?? null,
221
+ confidence: matchResult.confidence,
222
+ intent: matchResult.intent,
223
+ extractedParams: matchResult.extractedParams,
224
+ resolvedVia,
225
+ timestamp: new Date().toISOString(),
226
+ });
227
+ }
228
+ }
@@ -1,10 +1,11 @@
1
+ import { VERSION } from './version';
1
2
  import * as fs from 'fs';
2
3
  import * as path from 'path';
3
4
  import { validateConfig, validateManifest } from './schema';
4
5
  import { logger } from './logger';
5
6
  export function generate(config) {
6
7
  return {
7
- version: '0.2.0',
8
+ version: VERSION,
8
9
  app: config.app,
9
10
  generatedAt: new Date().toISOString(),
10
11
  capabilities: config.capabilities,
package/dist/esm/index.js CHANGED
@@ -6,6 +6,12 @@ export { resolve } from './resolver';
6
6
  // ─── Convenience: ask() — match + resolve in one call ────────────────────────
7
7
  import { match as _match, matchWithLLM as _matchWithLLM } from './matcher';
8
8
  import { resolve as _resolve } from './resolver';
9
+ // ─── Engine (recommended API) ─────────────────────────────────────────────────
10
+ export { CapmanEngine } from './engine';
11
+ // ─── Cache ────────────────────────────────────────────────────────────────────
12
+ export { MemoryCache, FileCache, ComboCache } from './cache';
13
+ // ─── Learning ─────────────────────────────────────────────────────────────────
14
+ export { FileLearningStore, MemoryLearningStore } from './learning';
9
15
  /**
10
16
  * One-shot convenience: match + resolve in a single call.
11
17
  *
@@ -0,0 +1,116 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { logger } from './logger';
4
+ // ─── Shared computation helpers ───────────────────────────────────────────────
5
+ function computeStats(entries) {
6
+ const index = {};
7
+ let totalQueries = 0;
8
+ let llmQueries = 0;
9
+ let cacheHits = 0;
10
+ let outOfScope = 0;
11
+ for (const entry of entries) {
12
+ totalQueries++;
13
+ if (entry.resolvedVia === 'llm')
14
+ llmQueries++;
15
+ if (entry.resolvedVia === 'cache')
16
+ cacheHits++;
17
+ if (!entry.capabilityId)
18
+ outOfScope++;
19
+ if (entry.capabilityId) {
20
+ const words = entry.query.toLowerCase()
21
+ .split(/\W+/)
22
+ .filter(w => w.length > 2);
23
+ for (const word of words) {
24
+ if (!index[word])
25
+ index[word] = {};
26
+ index[word][entry.capabilityId] =
27
+ (index[word][entry.capabilityId] ?? 0) + 1;
28
+ }
29
+ }
30
+ }
31
+ return { index, totalQueries, llmQueries, cacheHits, outOfScope };
32
+ }
33
+ function computeTopCapabilities(entries, limit) {
34
+ const counts = {};
35
+ for (const entry of entries) {
36
+ if (entry.capabilityId) {
37
+ counts[entry.capabilityId] = (counts[entry.capabilityId] ?? 0) + 1;
38
+ }
39
+ }
40
+ return Object.entries(counts)
41
+ .sort(([, a], [, b]) => b - a)
42
+ .slice(0, limit)
43
+ .map(([id, hits]) => ({ id, hits }));
44
+ }
45
+ // ─── File Learning Store ──────────────────────────────────────────────────────
46
+ export class FileLearningStore {
47
+ constructor(filePath = '.capman/learning.json') {
48
+ this.entries = [];
49
+ this.loaded = false;
50
+ this.filePath = path.resolve(process.cwd(), filePath);
51
+ logger.info(`FileLearningStore initialized — writing to: ${this.filePath}`);
52
+ }
53
+ async load() {
54
+ if (this.loaded)
55
+ return;
56
+ try {
57
+ const raw = await fs.promises.readFile(this.filePath, 'utf-8');
58
+ const parsed = JSON.parse(raw);
59
+ this.entries = parsed.entries ?? [];
60
+ logger.debug(`Learning store loaded: ${this.entries.length} entries`);
61
+ }
62
+ catch {
63
+ // File doesn't exist yet — start fresh
64
+ }
65
+ this.loaded = true;
66
+ }
67
+ async save() {
68
+ try {
69
+ const dir = path.dirname(this.filePath);
70
+ await fs.promises.mkdir(dir, { recursive: true });
71
+ await fs.promises.writeFile(this.filePath, JSON.stringify({
72
+ entries: this.entries,
73
+ updatedAt: new Date().toISOString(),
74
+ }, null, 2));
75
+ }
76
+ catch {
77
+ logger.warn(`Failed to save learning store to ${this.filePath}`);
78
+ }
79
+ }
80
+ async record(entry) {
81
+ await this.load();
82
+ this.entries.push(entry);
83
+ await this.save();
84
+ logger.debug(`Learning recorded: "${entry.query}" → ${entry.capabilityId ?? 'OUT_OF_SCOPE'} via ${entry.resolvedVia}`);
85
+ }
86
+ async getStats() {
87
+ await this.load();
88
+ return computeStats(this.entries);
89
+ }
90
+ async getTopCapabilities(limit = 5) {
91
+ await this.load();
92
+ return computeTopCapabilities(this.entries, limit);
93
+ }
94
+ async clear() {
95
+ this.entries = [];
96
+ await this.save();
97
+ }
98
+ }
99
+ // ─── Memory Learning Store (for testing) ─────────────────────────────────────
100
+ export class MemoryLearningStore {
101
+ constructor() {
102
+ this.entries = [];
103
+ }
104
+ async record(entry) {
105
+ this.entries.push(entry);
106
+ }
107
+ async getStats() {
108
+ return computeStats(this.entries);
109
+ }
110
+ async getTopCapabilities(limit = 5) {
111
+ return computeTopCapabilities(this.entries, limit);
112
+ }
113
+ async clear() {
114
+ this.entries = [];
115
+ }
116
+ }
@@ -76,19 +76,41 @@ function extractParams(query, cap) {
76
76
  // e.g. "profile for johndoe" → johndoe
77
77
  // "articles by jane" → jane
78
78
  // "tag javascript" → javascript
79
+ // Use param name and description as hints for what to look for
80
+ const paramHints = [param.name, ...param.description.toLowerCase().split(/\s+/)]
81
+ .filter(w => w.length > 2);
82
+ // Try keyword-based extraction first
79
83
  const keywords = [
80
84
  `for `, `by `, `about `, `named `, `called `,
81
85
  `tag `, `user `, `author `, `slug `, `id `,
82
- `to `, `from `, `with `,
86
+ `from `, `with `,
83
87
  ];
88
+ // For nav params — look for destination after navigation verbs
89
+ const navKeywords = [`to `, `open `, `show `];
90
+ const isNavParam = param.name === 'destination' ||
91
+ param.description.toLowerCase().includes('screen') ||
92
+ param.description.toLowerCase().includes('page');
93
+ const activeKeywords = isNavParam
94
+ ? [...navKeywords, ...keywords]
95
+ : keywords;
84
96
  let extracted = null;
85
- for (const kw of keywords) {
97
+ for (const kw of activeKeywords) {
86
98
  const idx = q.indexOf(kw);
87
99
  if (idx !== -1) {
88
100
  const after = query.slice(idx + kw.length).trim();
89
- const token = after.split(/\s+/)[0];
90
- if (token && token.length > 1) {
91
- extracted = token.replace(/[^a-zA-Z0-9-_@.]/g, '');
101
+ // Get remaining words, filter stopwords, take first meaningful one
102
+ const tokens = after.split(/\s+/)
103
+ .map(t => t.replace(/[^a-zA-Z0-9-_@.]/g, ''))
104
+ .filter(t => t.length > 1 && !STOPWORDS.has(t.toLowerCase()));
105
+ if (tokens.length > 0) {
106
+ // For IDs and numbers — single token is correct
107
+ const isIdParam = param.name === 'id' ||
108
+ param.name.endsWith('_id') ||
109
+ param.name.endsWith('Id') ||
110
+ /^\s*\w+\s+id\b/i.test(param.description) ||
111
+ /^id\b/i.test(param.description);
112
+ // For names, products, destinations — grab multi-word phrase
113
+ extracted = (isIdParam || isNavParam) ? tokens[0] : tokens.join('-').toLowerCase();
92
114
  break;
93
115
  }
94
116
  }
@@ -118,33 +140,44 @@ export function match(query, manifest) {
118
140
  logger.debug(`Manifest has ${manifest.capabilities.length} capabilities`);
119
141
  let best = null;
120
142
  let bestScore = 0;
143
+ const allScores = [];
121
144
  for (const cap of manifest.capabilities) {
122
145
  const score = scoreCapability(query, cap);
123
146
  logger.debug(` scored "${cap.id}": ${score}%`);
147
+ allScores.push({ cap, score });
124
148
  if (score > bestScore) {
125
149
  bestScore = score;
126
150
  best = cap;
127
151
  }
128
152
  }
153
+ const candidates = allScores.map(({ cap, score }) => ({
154
+ capabilityId: cap.id,
155
+ score,
156
+ matched: cap.id === best?.id,
157
+ }));
129
158
  if (!best || bestScore < 50) {
130
159
  logger.info(`No match above threshold (best: ${bestScore}% for "${best?.id ?? 'none'}")`);
160
+ // Out of scope return:
131
161
  return {
132
162
  capability: null,
133
163
  confidence: bestScore,
134
164
  intent: 'out_of_scope',
135
165
  extractedParams: {},
136
166
  reasoning: `No capability matched with sufficient confidence (best score: ${bestScore})`,
167
+ candidates,
137
168
  };
138
169
  }
139
170
  const params = extractParams(query, best);
140
171
  logger.info(`Matched "${best.id}" at ${bestScore}% confidence`);
141
172
  logger.debug(`Extracted params: ${JSON.stringify(params)}`);
173
+ // Matched return:
142
174
  return {
143
175
  capability: best,
144
176
  confidence: bestScore,
145
177
  intent: resolverToIntent(best),
146
178
  extractedParams: params,
147
179
  reasoning: `Matched "${best.id}" via keyword scoring (score: ${bestScore})`,
180
+ candidates,
148
181
  };
149
182
  }
150
183
  export async function matchWithLLM(query, manifest, options) {