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.
Files changed (54) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/CODEBASE.md +115 -65
  3. package/README.md +45 -4
  4. package/bin/lib/cmd-explain.js +2 -2
  5. package/bin/lib/cmd-generate.js +44 -28
  6. package/bin/lib/cmd-run.js +2 -2
  7. package/bin/lib/shared.js +8 -2
  8. package/dist/cjs/cache.d.ts.map +1 -1
  9. package/dist/cjs/cache.js +22 -5
  10. package/dist/cjs/cache.js.map +1 -1
  11. package/dist/cjs/engine.d.ts +30 -0
  12. package/dist/cjs/engine.d.ts.map +1 -1
  13. package/dist/cjs/engine.js +87 -36
  14. package/dist/cjs/engine.js.map +1 -1
  15. package/dist/cjs/generator.d.ts.map +1 -1
  16. package/dist/cjs/generator.js +7 -1
  17. package/dist/cjs/generator.js.map +1 -1
  18. package/dist/cjs/learning.d.ts.map +1 -1
  19. package/dist/cjs/learning.js +39 -12
  20. package/dist/cjs/learning.js.map +1 -1
  21. package/dist/cjs/matcher.d.ts +18 -10
  22. package/dist/cjs/matcher.d.ts.map +1 -1
  23. package/dist/cjs/matcher.js +140 -29
  24. package/dist/cjs/matcher.js.map +1 -1
  25. package/dist/cjs/parser.d.ts.map +1 -1
  26. package/dist/cjs/parser.js +15 -8
  27. package/dist/cjs/parser.js.map +1 -1
  28. package/dist/cjs/resolver.d.ts +1 -0
  29. package/dist/cjs/resolver.d.ts.map +1 -1
  30. package/dist/cjs/resolver.js +16 -5
  31. package/dist/cjs/resolver.js.map +1 -1
  32. package/dist/cjs/schema.d.ts +18 -18
  33. package/dist/cjs/schema.js +1 -1
  34. package/dist/cjs/schema.js.map +1 -1
  35. package/dist/cjs/types.d.ts +1 -1
  36. package/dist/cjs/types.d.ts.map +1 -1
  37. package/dist/cjs/version.d.ts +1 -1
  38. package/dist/cjs/version.js +1 -1
  39. package/dist/esm/cache.js +22 -5
  40. package/dist/esm/engine.d.ts +30 -0
  41. package/dist/esm/engine.js +89 -38
  42. package/dist/esm/generator.js +7 -1
  43. package/dist/esm/learning.js +39 -12
  44. package/dist/esm/matcher.d.ts +18 -10
  45. package/dist/esm/matcher.js +137 -29
  46. package/dist/esm/parser.js +15 -8
  47. package/dist/esm/resolver.d.ts +1 -0
  48. package/dist/esm/resolver.js +16 -6
  49. package/dist/esm/schema.d.ts +18 -18
  50. package/dist/esm/schema.js +1 -1
  51. package/dist/esm/types.d.ts +1 -1
  52. package/dist/esm/version.d.ts +1 -1
  53. package/dist/esm/version.js +1 -1
  54. package/package.json +11 -10
package/CHANGELOG.md CHANGED
@@ -4,6 +4,67 @@ All notable changes to capman are documented here.
4
4
 
5
5
  ---
6
6
 
7
+ ## [0.5.5] — 2026-05-01
8
+ ### Fixed
9
+
10
+ **Critical:**
11
+ - LLM rate-limit slot now refunded on failure — `recordLLMFailure()` decrements `llmCallsThisMinute`. Previously burned slot was never returned, causing premature rate exhaustion under sustained errors and silent degradation to keyword-only matching
12
+ - `maxLLMCallsPerMinute: 0` now returns a clear `'LLM disabled'` message instead of arithmetic confusion
13
+ - Anthropic/OpenAI/OpenRouter error responses now read via `res.text()` before `res.ok` check — `res.json()` on a non-200 with malformed body was masking the real API error with a parse exception
14
+ - Empty string API keys (`""`) now correctly rejected — `??` operator passed empty strings through as truthy; replaced with `.trim() ||` across all three provider env vars
15
+
16
+ **High:**
17
+ - Privacy trace step now correctly shows `'fail'` when auth would block — previously always pushed `status: 'pass'` regardless of auth state, making the trace misleading for unauthenticated requests
18
+ - `preBoostMatchResult` is now an explicit shallow copy (`{ ...matchResult, candidates: matchResult.candidates.slice() }`) instead of a reference alias — prevents accidental mutation corrupting the pre-boost snapshot
19
+ - `'manage'` keyword no longer causes false admin classification in OpenAPI parser — replaced substring check with word-boundary regex `\b(admin|administrator|backoffice|back-office|internal|superuser)\b`. Operations like `manageWishlist`, `fileManager` are no longer misclassified as admin
20
+ - `'context'` and `'static'` param sources removed from schema and types — were schema-valid but silently dropped at runtime, injecting `null` into URLs with no error. Schema now only accepts `'user_query'` and `'session'`. Parser updated to map Swagger 2.x body/formData params to `'user_query'`
21
+ - Swagger 2.0 `scheme` no longer hardcoded to `https` — respects declared `schemes` array, prefers `https` over `http` when both present
22
+ - `process.exit()` removed from library signal handlers — `FileLearningStore` SIGTERM/SIGINT handlers were calling `process.exit(0)`, hijacking application shutdown sequence. Handlers now flush only. Handler references stored as module-level variables and cleaned up via `unregisterExitHandlers()` when `activeStores` is empty after `destroy()`
23
+ - LLM `extracted_params` validated against declared capability params — previously cast directly to `Record<string, string | null>`. Nested objects now produce `null` instead of `"[object Object]"` in URLs. Unknown keys dropped. Numbers and booleans coerced to string
24
+
25
+ **Medium:**
26
+ - `ENOENT` vs corruption now distinguished in `FileCache` and `FileLearningStore` load — bare `catch {}` replaced with code check; only non-ENOENT errors emit a warning
27
+ - `loadPromise` now resets on rejection — previously a failed load cached the rejected promise permanently, causing all subsequent calls to fail forever
28
+ - `validateNavParam` allowlist aligned with `validateApiPathParam` — dots, colons, `@` now permitted. Allows deep links (`myapp://path`), domain-qualified values (`auth.tokens`), versioned routes (`v1:resource`)
29
+ - `manifest.app` sanitized before LLM prompt injection — strips newlines, tabs, and control characters that could break prompt structure
30
+ - `buildCacheKey` now used on cache write — engine stores results under both `normalizeQuery` key (exact phrasing) and `buildCacheKey` (capability + params semantic key). Differently-phrased queries resolving to the same capability share cache entries
31
+ - Description scoring normalized against `Math.min(descWords.length, 10)` — previously `overlap / totalWords` penalized rich documentation. Long descriptions no longer score lower than short ones for the same keyword overlap
32
+ - Silent `baseUrl` placeholder in parser now emits a `logger.warn` — generated configs with no server URL were silently broken at runtime
33
+ - `writeManifest` now validates output path stays within working directory — public API had no guard, only the CLI wrapper did
34
+ - `scoreCapability` converted from O(n²) to O(n) — `qWords.includes(w)` inside loops replaced with a `Set` built once per `match()` call. At 500 capabilities × 30 query words: ~300,000 ops → ~15,000 ops
35
+
36
+ **Low / Cosmetic:**
37
+ - `export class CapmanEngine` indentation fixed — extra leading 2-space indent removed
38
+ - Trailing whitespace artifact removed from `matcher.ts` line 89
39
+ - Rate-limiter call sites in `_runMatch()` now have comment noting shared quota between `ask()` and `explain()`
40
+ - Learning boost skips high-confidence LLM matches — `applyBoostToMatchResult()` now accepts `resolvedVia` and returns early when `resolvedVia === 'llm' && confidence > 80`. Avoids learning signal incorrectly overriding strong LLM decisions
41
+ - `matchWithLLM()` sanitizes capability `description` and `examples` fields via `sanitizeForPrompt()` before LLM prompt injection — strips newlines, delimiters, leading braces. Defence-in-depth on top of caller-side sanitization
42
+ - `resolveApi` JSDoc updated — documents that partial failure scenario does not surface which endpoints succeeded, and notes planned `partialSuccess` field in future version
43
+ - `extractParams` JSDoc updated — documents fallback word extraction limitation and future `pattern` field plan
44
+ - `matchWithLLM` security JSDoc updated — notes current single-string prompt limitation for system/user message separation
45
+
46
+ ### Tests
47
+ - 99 tests passing (up from 97)
48
+ - Added: `'manage'` false admin classification test
49
+ - Added: nav params with dots/colons allowed test
50
+
51
+ ---
52
+
53
+ ## [0.5.4] — 2026-04-29
54
+ ### Added
55
+ - `engine.loadManifest(manifest)` — hot-reloads the manifest without creating a new engine instance. Preserves cache, learning history, and rate limiter state. Clears cache automatically since cached results from the old manifest are no longer valid
56
+ - `fuzzyMatch` and `fuzzyThreshold` options in `EngineOptions` — opt-in Fuse.js fuzzy matching catches typos, slight paraphrases, and morphological variants that exact keyword matching misses. Disabled by default, never runs in `cheap` mode
57
+ - POSIX `--` sentinel support in CLI — `capman run -- "query"` and `capman explain -- "query"` now correctly handle queries that start with `--` or contain flag-like strings
58
+
59
+ ### Fixed
60
+ - Example scoring now uses `Math.max` across examples instead of accumulating — a capability with 10 weak examples no longer beats one with a single precise example
61
+ - Fuse.js index built once per `match()` call using a flat corpus — each example/description/name is its own searchable entry, grouped by capability after search. Avoids the dead-weight property bug and multi-key aggregation issues from the previous implementation
62
+
63
+ ### Tests
64
+ - 97 tests passing (up from 91)
65
+
66
+ ---
67
+
7
68
  ## [0.5.3] — 2026-04-25
8
69
  ### Fixed
9
70
 
package/CODEBASE.md CHANGED
@@ -28,6 +28,7 @@ All TypeScript types and interfaces. No logic — pure declarations.
28
28
 
29
29
  Key exports:
30
30
  - `Capability`, `CapabilityParam`, `Manifest`, `CapmanConfig` — core data shapes
31
+ - `CapabilityParam.source` — `'user_query' | 'session'` only. `context` and `static` removed in v0.5.5 — were schema-valid but silently dropped at runtime
31
32
  - `MatchResult` — what `match()` returns, including `candidates: MatchCandidate[]`
32
33
  - `MatchCandidate` — `{ capabilityId, score, matched }` — all scored candidates
33
34
  - `ResolveResult`, `ApiCallResult` — what `resolve()` returns, with `status` and `data`
@@ -48,8 +49,12 @@ Key exports:
48
49
 
49
50
  Notable rules:
50
51
  - `id` must match `/^[a-z0-9_]+$/` — snake_case only
51
- - `description` minimum 10 characters
52
+ - `description` minimum 10 characters, maximum 500 characters
53
+ - `examples` each entry maximum 200 characters
54
+ - `source` only accepts `'user_query'` or `'session'`
52
55
  - Capability IDs must be unique within a manifest
56
+ - `baseUrl` required when any capability uses `api` or `hybrid` resolver
57
+ - `CapmanConfig` refined — `baseUrl` must be present when any API/hybrid capability exists
53
58
 
54
59
  ---
55
60
 
@@ -59,13 +64,13 @@ Manifest lifecycle — create, read, write, validate.
59
64
  Key exports:
60
65
  - `generate(config)` → `Manifest` — converts config to manifest, deep-copies capabilities
61
66
  - `loadConfig(path?)` → `CapmanConfig` — loads `capman.config.js` via `require()`
62
- - `writeManifest(manifest, path?)` — writes `manifest.json`
67
+ - `writeManifest(manifest, path?)` — writes `manifest.json`. Output path validated against working directory — throws on path traversal
63
68
  - `readManifest(path?)` → `Manifest` — reads and Zod-validates `manifest.json`
64
69
  - `validate(manifest)` → `ValidationResult`
65
70
  - `generateStarterConfig()` → `string`
66
71
  - `VERSION` — current version string, auto-generated by `scripts/version.js`
67
72
 
68
- Note: `loadConfig()` uses `require()` internally — CJS config files only. ESM config files (`.mjs` or `"type": "module"`) are not supported. Full ESM config support is planned for v0.6.
73
+ Note: `loadConfig()` uses `require()` internally — CJS config files only. ESM config files (`.mjs` or `"type": "module"`) produce a clear error with migration instructions. Full ESM config support is planned for v0.6.
69
74
 
70
75
  ---
71
76
 
@@ -73,27 +78,38 @@ Note: `loadConfig()` uses `require()` internally — CJS config files only. ESM
73
78
  Intent matching — keyword scoring and LLM-based matching.
74
79
 
75
80
  Key exports:
76
- - `match(query, manifest)` → `MatchResult` — scores all capabilities, returns winner + all candidates
81
+ - `match(query, manifest, options?)` → `MatchResult` — scores all capabilities, returns winner + all candidates
82
+ - `options.fuzzyMatch` — enable Fuse.js fuzzy matching (default: false)
83
+ - `options.fuzzyThreshold` — Fuse.js threshold 0.0–1.0 (default: 0.4)
77
84
  - `matchWithLLM(query, manifest, { llm })` → `MatchResult` — LLM-based matching
78
85
  - Query passed as `JSON.stringify({ user_query })` with system instructions before user data
79
86
  - `USER_QUERY_START/END` delimiters separate instructions from user-controlled content
80
- - Parse failures throw `LLM_PARSE_ERROR:` prefixed errors not counted as network failures
87
+ - Capability `description` and `examples` sanitized via `sanitizeForPrompt()` before injection
88
+ - `manifest.app` sanitized — strips newlines/control chars, capped at 100 chars
89
+ - LLM `extracted_params` validated against declared capability params — nested objects → null, numbers/booleans → string, unknown keys dropped
90
+ - Parse failures throw `LLMParseError` — not counted toward circuit breaker
81
91
  - Errors propagate to caller — no internal try/catch
82
92
  - `extractParams(query, capability)` → `Record<string, string | null>` — direct param extraction
83
93
  - `resolverToIntent(capability)` → intent string — converts resolver type to intent
84
94
  - `STOPWORDS` — set of words filtered from scoring and learning index
95
+ - `LLMParseError` — typed error class for LLM parse failures
96
+ - `MatchOptions` — `{ fuzzyMatch?, fuzzyThreshold? }`
85
97
 
86
- Scoring algorithm (weights):
87
- - Examples: best single-example overlap score — up to 60 points (not accumulated across examples)
88
- - Description match: up to 30 points
89
- - Name match: up to 10 points
98
+ Scoring algorithm:
99
+ - Examples: `Math.max` across all examplesbest single match up to 60 points. Quality beats quantity — a capability with 10 weak examples no longer beats one with a single precise example
100
+ - Description: `Math.min(overlap / Math.min(descWords.length, 10), 1) * 30` — normalized against a cap of 10 words to avoid penalizing rich documentation
101
+ - Name: up to 10 points
102
+ - Fuzzy (optional): Fuse.js flat corpus — one entry per example/description/name, one index per `match()` call, results grouped by capability, best hit merged via `Math.max` with keyword score
103
+
104
+ Performance: `qWordSet` built once as `Set<string>` per `match()` call — O(1) `.has()` lookups replace O(n) `Array.includes` in all scoring loops.
90
105
 
91
106
  Param extraction:
92
107
  - `isIdParam` — single token (e.g. `order_id=1234`)
93
108
  - `isNavParam` — single token after nav keywords (`to`, `open`, `show`)
94
109
  - Multi-word — joined with `-` (e.g. `product=blue-jacket`)
95
- - Required param fallback — only accepts identifier-shaped last word (rejects generic nouns)
110
+ - Required param fallback — only accepts identifier-shaped last word (rejects generic nouns and category words)
96
111
  - Optional params stay `null` if no keyword match found
112
+ - Session params return `null` — injected by resolver from auth context, not extracted from query
97
113
 
98
114
  ---
99
115
 
@@ -103,16 +119,19 @@ Capability execution — API calls, navigation, hybrid.
103
119
  Key exports:
104
120
  - `resolve(matchResult, params, options)` → `ResolveResult`
105
121
  - Enforces privacy before executing
106
- - Injects `auth.userId` into session params (skipped if empty string or undefined)
122
+ - Injects `auth.userId` into session params per-endpoint only where `{param}` placeholder exists in that specific endpoint's path
107
123
  - Supports `dryRun: true` — returns call plan without executing
108
- - Retries with `AbortController` timeout on failure
124
+ - Retries with `AbortController` timeout on failure — safe methods (GET/HEAD/OPTIONS) only by default
125
+ - `retryAllMethods: true` opt-in for retrying write operations
109
126
  - Returns `status` and parsed `data` from API response
110
127
  - `null` and `undefined` params never written into URLs
111
- - Nav param values URL-encoded via `encodeURIComponent`
112
- - Nav params validated against `[a-zA-Z0-9_-]` allowlist — rejects path separators
128
+ - Both API and nav param values URL-encoded via `encodeURIComponent`
129
+ - API path params validated against `[a-zA-Z0-9_\-.:@]+` allowlist — prevents path traversal
130
+ - Nav params validated against same allowlist — rejects `/` and shell metacharacters
131
+ - `checkPrivacy(capability, auth)` → `string | null` — exported. Used by engine to populate privacy trace step accurately
113
132
 
114
133
  `ResolveOptions`:
115
- - `baseUrl`, `auth`, `dryRun`, `retries`, `timeoutMs`, `headers`, `fetch`
134
+ - `baseUrl`, `auth`, `dryRun`, `retries`, `timeoutMs`, `retryAllMethods`, `headers`, `fetch`
116
135
 
117
136
  Privacy enforcement:
118
137
  - `public` — always allowed
@@ -121,51 +140,61 @@ Privacy enforcement:
121
140
 
122
141
  Debug logging: param values and `auth.userId` are redacted as `[REDACTED]` — never logged in plaintext.
123
142
 
143
+ ⚠️ Parallel execution: multi-endpoint capabilities fire all endpoints simultaneously via `Promise.all()`. If one fails, side effects from successful endpoints cannot be rolled back. Use single-endpoint capabilities for operations requiring ordering or rollback.
144
+
124
145
  ---
125
146
 
126
147
  ### `src/cache.ts`
127
148
  Pluggable cache backends.
128
149
 
129
150
  Key exports:
130
- - `CacheStore` interface — `get(key)`, `set(key, result)`, `clear()`, `size()`
131
- - `MemoryCache` — in-memory Map, 512-entry cap with oldest-first eviction
132
- - `FileCache` — async `fs.promises` read/write, 2048-entry cap with oldest-first eviction
151
+ - `CacheStore` interface — `get(key, ttlMs?)`, `set(key, result)`, `clear()`, `size()`
152
+ - `MemoryCache` — in-memory Map, 512-entry LRU cap
153
+ - `FileCache` — async `fs.promises` read/write, 2048-entry LRU cap. Atomic writes via `.tmp` + rename. Concurrent load serialized via `loadPromise`
133
154
  - `ComboCache` — memory-first with file fallback
134
- - `normalizeQuery(query)` — lowercase + trim + collapse whitespace → cache key
135
- - `buildCacheKey(query, capabilityId, params)` — exported for future post-match cache layer (not currently used by engine)
155
+ - `normalizeQuery(query)` — lowercase + trim + strip punctuation + collapse whitespace → cache key. Punctuation stripped so `"show orders!"` and `"show orders"` share the same key
156
+ - `buildCacheKey(query, capabilityId, params)` — semantic key using capability + params. Engine writes under both `normalizeQuery` and `buildCacheKey` — differently-phrased queries resolving to the same capability share cache entries
136
157
 
137
- Security: Only `public` capabilities are cached. Non-public (`user_owned`, `admin`) are never cached — prevents auth bypass where one user's cached match is served to another.
158
+ Security: Only `public` capabilities are cached. Non-public (`user_owned`, `admin`) are never cached — prevents auth bypass where one user's cached match is served to another. Cache written only after successful resolution — failed resolutions do not poison the cache.
138
159
 
139
160
  Notes:
140
161
  - `FileCache` and `ComboCache` are single-instance only — concurrent writers will corrupt
162
+ - `loadPromise` resets on rejection — failed loads allow retry rather than caching the rejection permanently
141
163
  - For multi-instance deployments, use a Redis adapter (planned v0.6)
142
164
 
143
165
  ---
144
166
 
145
167
  ### `src/learning.ts`
146
- Usage analytics and keyword index — now incremental.
168
+ Usage analytics and keyword index — incremental, PII-safe.
147
169
 
148
170
  Key exports:
149
- - `LearningStore` interface — `record(entry)`, `getStats()`, `getTopCapabilities(limit)`, `getIndex()`
150
- - `FileLearningStore` — persists to `.capman/learning.json`, caps at 10,000 entries. Saves are debounced (5s) with synchronous flush on process exit
171
+ - `LearningStore` interface — `record(entry)`, `getStats()`, `getTopCapabilities(limit)`, `getIndex()`, `destroy()`
172
+ - `FileLearningStore` — persists to `.capman/learning.json`, caps at 10,000 entries. Saves debounced (5s) with synchronous flush on process exit via `flushSync()`
151
173
  - `MemoryLearningStore` — in-memory only, used in tests
152
174
  - `LearningIndex` — internal class shared by both stores. Maintains keyword index and stats counters incrementally. Eliminates ~80 lines of duplication
153
175
 
154
176
  `LearningEntry`:
155
- - `query`, `capabilityId`, `confidence`, `intent`, `extractedParams`
177
+ - `query` stored as tokenized keywords only, never raw text. PII (emails, names, IDs) stripped before persistence
178
+ - `capabilityId`, `confidence`, `intent`, `extractedParams`
156
179
  - `resolvedVia: 'keyword' | 'llm' | 'cache'`
157
180
  - `timestamp`
158
181
 
159
182
  `KeywordStats` (from `getStats()`):
160
- - `index` — `{ word → { capabilityId → hitCount } }` — used by engine for learning boost
183
+ - `index` — `{ word → { capabilityId → hitCount } }` — used by engine for learning boost. Returns `structuredClone` — callers cannot corrupt internal state
161
184
  - `totalQueries`, `llmQueries`, `cacheHits`, `outOfScope`
162
185
 
163
186
  Performance:
164
- - Index is maintained incrementally in `record()` — O(w) per entry where w = meaningful words
187
+ - Index maintained incrementally in `record()` — O(w) per entry
165
188
  - `getStats()` returns cached counters — O(1), no rebuild
166
- - `getIndex()` returns live index — O(1)
167
- - Full rebuild only on pruning (when entries exceed 10,000 cap)
168
- - Stopwords filtered from index — same `STOPWORDS` set as `matcher.ts`
189
+ - Prune uses `subtractFromIndex()` — O(pruned × w), not full rebuild
190
+
191
+ Exit handling:
192
+ - Process exit handlers registered once via module-level `registerExitHandlers()`
193
+ - Handler references stored as `exitHandler`, `sigTermHandler`, `sigIntHandler`
194
+ - Removed via `unregisterExitHandlers()` when `activeStores` is empty after `destroy()`
195
+ - Does NOT call `process.exit()` — library must not hijack application shutdown
196
+
197
+ `destroy()` — async, awaits final flush before removing from registry. On the `LearningStore` interface — callable through the interface type.
169
198
 
170
199
  ---
171
200
 
@@ -181,27 +210,34 @@ Key exports:
181
210
 
182
211
  `CapmanEngine` methods:
183
212
  - `ask(query, overrides?)` → `EngineResult` — full pipeline: cache → match → boost → resolve → learn
184
- - `explain(query)` → `ExplainResult` — match + boost only, no execution, no cache/learning write
213
+ - `explain(query)` → `ExplainResult` — match + boost only, no execution, no cache/learning write. Shares LLM quota with `ask()` — explain() counts against rate limit
214
+ - `loadManifest(manifest)` — hot-reloads manifest without losing learning history, rate limiter state, or cache. Cache is cleared automatically. Rate limiter state intentionally preserved — LLM provider unchanged
185
215
  - `getStats()` → `KeywordStats | null`
186
216
  - `getTopCapabilities(limit?)` → `Array<{ id, hits }>`
187
217
  - `clearCache()`
188
218
 
219
+ `EngineOptions` highlights:
220
+ - `fuzzyMatch` — enable Fuse.js fuzzy matching (default: false)
221
+ - `fuzzyThreshold` — Fuse.js threshold 0.0–1.0 (default: 0.4)
222
+ - `cacheTtlMs` — optional TTL for cache entries in ms (default: no expiry)
223
+ - `maxLLMCallsPerMinute` — rate limit (default: 60). Set to 0 to disable LLM entirely
224
+ - `llmCooldownMs`, `llmCircuitBreakerThreshold`, `llmCircuitBreakerResetMs`
225
+
189
226
  Matching pipeline in `ask()`:
190
- 1. Cache check — return immediately on hit (public capabilities only)
191
- 2. Match — `cheap` / `balanced` / `accurate` mode
192
- 3. Privacy check — recorded in trace
193
- 4. Learning boost — up to +15 points for historically matched capabilities (skipped in `cheap` mode, skipped if all candidates score 0)
194
- 5. Cache set — stores post-boost result under normalized query key (public only)
227
+ 1. Cache check — return immediately on hit (public capabilities only). Re-extracts params fresh from current query
228
+ 2. Match — `cheap` / `balanced` / `accurate` mode dispatch via `_runMatch()`
229
+ 3. Privacy check — uses `checkPrivacy()` from `resolver.ts`. Correctly shows `'fail'` for blocked requests
230
+ 4. Learning boost — up to +15 points. Skipped in `cheap` mode, skipped if all candidates score 0 (no keyword signal), skipped if `resolvedVia === 'llm'` and `confidence > 80`
231
+ 5. Cache set — writes under both `normalizeQuery` and `buildCacheKey` (public only, after successful resolution only)
195
232
  6. Resolve — actual API call or nav
196
233
  7. Reasoning build — human-readable array
197
234
  8. Learning record — pre-boost result recorded to prevent feedback loop
198
235
 
199
- LLM rate limiting (all modes respect these):
200
- - `maxLLMCallsPerMinute` — sliding window (default: 60)
201
- - `llmCooldownMs` minimum gap between calls (default: 0)
202
- - `llmCircuitBreakerThreshold` failures before circuit opens (default: 3)
203
- - `llmCircuitBreakerResetMs` circuit reset time (default: 60,000ms)
204
- - Parse failures (`LLM_PARSE_ERROR`) do NOT count toward circuit breaker — only network failures do
236
+ LLM rate limiting:
237
+ - `maxLLMCallsPerMinute` — slot reserved in `checkLLMAllowed()`, refunded in `recordLLMFailure()`
238
+ - `maxLLMCallsPerMinute: 0` returns `'LLM disabled'` message immediately
239
+ - Parse failures (`LLMParseError`) do NOT count toward circuit breaker only network failures do
240
+ - Rate-limiter state shared between `ask()` and `explain()`
205
241
 
206
242
  ---
207
243
 
@@ -210,13 +246,18 @@ OpenAPI/Swagger → capman config converter.
210
246
 
211
247
  Key exports:
212
248
  - `parseOpenAPI(specPathOrUrl)` → `ParseResult`
213
- - Accepts local file path or HTTP URL
214
- - Parses JSON natively; YAML requires `js-yaml` installed
249
+ - Accepts local file path or HTTP URL (10s timeout via `AbortController`)
250
+ - Parses JSON natively; YAML requires `js-yaml` (distinguished via `err.code === 'MODULE_NOT_FOUND'`)
215
251
  - Converts every path+method into a `Capability`
216
252
  - Infers privacy from security schemes and tags
217
- - Extracts path/query/body params
253
+ - Extracts path/query/body params — all mapped to `'user_query'` source
218
254
  - Generates examples from operation summaries
219
255
 
256
+ Privacy inference:
257
+ - Admin classification uses word-boundary regex `\b(admin|administrator|backoffice|back-office|internal|superuser)\b` — `'manage'` alone no longer triggers admin. Operations like `manageWishlist`, `fileManager` correctly classified as non-admin
258
+ - Swagger 2.x `schemes` array respected — prefers `https` over `http` when both declared
259
+ - Missing server URL emits `logger.warn` instead of silently using placeholder
260
+
220
261
  `ParseResult`: `{ config, stats: { total, skipped, warnings } }`
221
262
 
222
263
  Supported: OpenAPI 3.x, Swagger 2.x (JSON or YAML)
@@ -230,7 +271,7 @@ Key exports:
230
271
  - `logger` — singleton with `debug()`, `info()`, `warn()`, `error()`
231
272
  - `setLogLevel(level)` — `'silent' | 'error' | 'warn' | 'info' | 'debug'`
232
273
 
233
- Note: The manifest version compatibility warning uses `console.warn` directly (not `logger.warn`) so it is always visible regardless of log level.
274
+ Note: The manifest version compatibility warning uses `console.warn` directly (not `logger.warn`) so it is always visible regardless of log level. Raw query text never appears at info level — only query length logged at info, full query at debug.
234
275
 
235
276
  ---
236
277
 
@@ -241,7 +282,7 @@ Notable:
241
282
  - `ask(query, manifest, options?)` — convenience function, delegates to `CapmanEngine`
242
283
  - Marked `@deprecated` — use `CapmanEngine` directly for full features
243
284
  - `MatchMode` — `'cheap' | 'balanced' | 'accurate'`
244
- - `extractParams`, `resolverToIntent`, `STOPWORDS` — exported for advanced use cases
285
+ - `extractParams`, `resolverToIntent`, `STOPWORDS`, `LLMParseError` — exported for advanced use cases
245
286
 
246
287
  ---
247
288
 
@@ -251,38 +292,41 @@ Notable:
251
292
  Entry point only (~20 lines). Routes `command` to the correct module.
252
293
 
253
294
  ### `bin/lib/shared.js`
254
- Exports: `args`, `command`, `flags`, `getFlag`, `c`, `log`, `header`, `requireSrc`
295
+ Exports: `args`, `command`, `flags`, `posArgs`, `getFlag`, `c`, `log`, `header`, `requireSrc`
255
296
 
256
- `getFlag(name)` — exits with error if flag is present but has no value (e.g. `--from` with no path).
297
+ - `getFlag(name)` — exits with error if flag is present but has no value
298
+ - `posArgs` — positional arguments after POSIX `--` sentinel. Allows queries starting with `--` to be passed without flag interpretation (e.g. `capman run -- "--help me find orders"`)
257
299
 
258
300
  ### `bin/lib/cmd-generate.js`
259
301
  Three generation paths: `--from` (OpenAPI), `--ai` (LLM-assisted), manual.
260
302
  Output paths validated via `safeOutputPath()` — rejects traversal outside working directory.
261
303
  Contains `buildAIPrompt()` and `callLLM()`.
304
+ - All three providers (Anthropic, OpenAI, OpenRouter) read response via `res.text()` before `res.ok` check — prevents JSON parse errors masking real API errors
305
+ - Empty string API keys rejected — `.trim() ||` used instead of `??`
262
306
 
263
307
  ### `bin/lib/cmd-init.js` — creates `capman.config.js`
264
308
  ### `bin/lib/cmd-validate.js` — validates `manifest.json`
265
309
  ### `bin/lib/cmd-inspect.js` — prints all capabilities
266
310
  ### `bin/lib/cmd-demo.js` — live demo with hardcoded e-commerce manifest
267
- ### `bin/lib/cmd-run.js` — runs a query, `--debug` shows all candidates
268
- ### `bin/lib/cmd-explain.js` — runs `engine.explain()` and prints full breakdown
311
+ ### `bin/lib/cmd-run.js` — runs a query, `--debug` shows all candidates. Supports `--` sentinel via `posArgs`
312
+ ### `bin/lib/cmd-explain.js` — runs `engine.explain()` and prints full breakdown. Supports `--` sentinel via `posArgs`
269
313
  ### `bin/lib/cmd-help.js` — usage and command list
270
314
 
271
315
  ---
272
316
 
273
317
  ## tests/
274
318
 
275
- ### `tests/matcher.test.ts` — 16 tests
276
- Keyword scoring, OOS detection, param extraction, LLM edge cases (hallucinated ID, undefined reasoning)
319
+ ### `tests/matcher.test.ts` — 17 tests
320
+ Keyword scoring, OOS detection, param extraction, LLM edge cases (hallucinated ID, undefined reasoning), example quality-over-quantity (Math.max)
277
321
 
278
- ### `tests/resolver.test.ts` — 22 tests
279
- API/nav/hybrid resolvers, privacy enforcement, session injection, null params, nav open redirect
322
+ ### `tests/resolver.test.ts` — 27 tests
323
+ API/nav/hybrid resolvers, privacy enforcement, session injection, null params, nav open redirect, API path traversal rejection, multi-endpoint session param isolation, LRU eviction, nav params with dots/colons allowed
280
324
 
281
- ### `tests/engine.test.ts` — 33 tests
282
- `ask()`, `explain()`, caching, learning, matching modes, trace, rate limiting, manifest version check, learning boost
325
+ ### `tests/engine.test.ts` — 44 tests
326
+ `ask()`, `explain()`, caching, learning, matching modes, trace, rate limiting, manifest version check, learning boost, query validation (TypeError/RangeError guards), LRU eviction, fuzzy matching (typos, cheap mode bypass, default disabled, strict threshold), `loadManifest()` hot-reload (cache cleared, learning preserved)
283
327
 
284
- ### `tests/parser.test.ts` — 9 tests
285
- OpenAPI capability extraction, privacy inference, param extraction, base URL, error handling
328
+ ### `tests/parser.test.ts` — 11 tests
329
+ OpenAPI capability extraction, privacy inference, param extraction, base URL, error handling, manage/admin false-positive regression
286
330
 
287
331
  ---
288
332
 
@@ -301,9 +345,9 @@ Prebuild script. Reads `version` from `package.json` and writes `src/version.ts`
301
345
  | `tsconfig.esm.json` | ESM build → `dist/esm/` with `.d.ts` |
302
346
  | `package.json` | Version, exports map, scripts, dependencies |
303
347
  | `.github/workflows/ci.yml` | Build + test + verify both dist outputs on every push |
348
+ | `.gitignore` | Includes `.capman/` — cache and learning files must never be committed |
304
349
  | `CHANGELOG.md` | All notable changes per version |
305
350
  | `CODEBASE.md` | This file |
306
- | `ROADMAP_v0.5.0.md` | Prioritized fix and feature roadmap |
307
351
 
308
352
  ---
309
353
 
@@ -314,19 +358,25 @@ Developer writes capman.config.js
314
358
 
315
359
  capman generate
316
360
 
317
- generator.ts → manifest.json
361
+ generator.ts → manifest.json (path-guarded write)
318
362
 
319
363
  CapmanEngine.ask("user query")
320
364
 
321
- cache.ts → cache hit? return immediately (public only)
365
+ cache.ts → cache hit? re-extract params fresh → return
366
+
367
+ matcher.ts → score all capabilities (Set-based O(n))
368
+ → optional Fuse.js fuzzy pass (single index)
369
+ → pick winner
370
+
371
+ checkPrivacy() → privacy trace step (pass or fail)
322
372
 
323
- matcher.ts score all capabilities pick winner
373
+ learning.ts apply boost (+0 to +15) — skipped for high-confidence LLM
324
374
 
325
- learning.ts apply boost (+0 to +15) based on keyword history
375
+ cache.ts write under normalizeQuery + buildCacheKey (public, on success only)
326
376
 
327
377
  resolver.ts → enforce privacy → call API or navigate
328
378
 
329
- learning.ts → record pre-boost match result
379
+ learning.ts → record pre-boost match result (tokenized, PII-stripped)
330
380
 
331
381
  EngineResult → { match, resolution, trace, resolvedVia }
332
382
  ```
package/README.md CHANGED
@@ -227,6 +227,25 @@ const engine = new CapmanEngine({
227
227
  })
228
228
  ```
229
229
 
230
+ ### Fuzzy Matching
231
+
232
+ Enable opt-in fuzzy matching to catch typos and slight paraphrases:
233
+
234
+ ```typescript
235
+ const engine = new CapmanEngine({
236
+ manifest,
237
+ mode: 'balanced',
238
+ fuzzyMatch: true, // enable Fuse.js fuzzy matching
239
+ fuzzyThreshold: 0.4, // 0.0 = exact only, 1.0 = match anything (default: 0.4)
240
+ })
241
+
242
+ // Now catches typos: "Shwo me artciles" → matches "Show me articles"
243
+ // Also catches near-matches Fuse.js considers similar
244
+ ```
245
+
246
+ Fuzzy matching never runs in `cheap` mode. It is additive — fuzzy can only
247
+ help a capability reach the confidence threshold, never hurt it.
248
+
230
249
  ---
231
250
 
232
251
  ## Caching + Learning
@@ -249,6 +268,28 @@ const top = await engine.getTopCapabilities(3)
249
268
 
250
269
  ---
251
270
 
271
+ ### Hot-reloading manifests
272
+
273
+ Swap the manifest without creating a new engine instance — preserves cache,
274
+ learning history, and rate limiter state:
275
+
276
+ ```typescript
277
+ const newManifest = generate(updatedConfig)
278
+ await engine.loadManifest(newManifest)
279
+ // Cache is cleared automatically — stale entries from old manifest are gone
280
+ ```
281
+
282
+ ### Cleaning up
283
+
284
+ Call `destroy()` on file-backed stores when done to flush pending data and
285
+ deregister process exit handlers:
286
+
287
+ ```typescript
288
+ await engine.learning?.destroy()
289
+ ```
290
+
291
+ ---
292
+
252
293
  ## Privacy + Auth
253
294
 
254
295
  Privacy scope is enforced **per capability**, before resolution happens:
@@ -320,8 +361,6 @@ const result = await engine.ask('show my orders', {
320
361
  |---|---|
321
362
  | `user_query` | Extracted from the user's query |
322
363
  | `session` | Injected from `auth.userId` automatically |
323
- | `context` | Provided by the caller |
324
- | `static` | Fixed value, never changes |
325
364
 
326
365
  ---
327
366
 
@@ -337,8 +376,10 @@ const result = await engine.ask('show my orders', {
337
376
  **Current limits:**
338
377
  - Real-time infra status (is the server down?)
339
378
  - UI-only state with no API backing
340
- - Very ambiguous queries — use `mode: 'accurate'` with an LLM
341
- - Multi-instance deployments need Redis adapter (planned for v0.5)
379
+ - Very ambiguous queries with no keyword signal — use `mode: 'accurate'` with an LLM, or enable `fuzzyMatch: true`
380
+ - Multi-instance deployments: `FileCache` and `FileLearningStore` are single-instance only — concurrent writers will corrupt the file. Use separate instances per process or a shared Redis adapter
381
+ - `FileLearningStore` saves are debounced — up to 5s of learning data could be lost on `SIGKILL` (not SIGTERM/SIGINT which are handled)
382
+ - Parallel multi-endpoint capabilities: if one endpoint fails, side effects from successful endpoints cannot be rolled back. Use single-endpoint capabilities for operations requiring ordering or rollback
342
383
 
343
384
  ---
344
385
 
@@ -1,10 +1,10 @@
1
1
  'use strict'
2
2
 
3
- const { header, log, c, args, getFlag, requireSrc } = require('./shared')
3
+ const { header, log, c, args, posArgs, getFlag, requireSrc } = require('./shared')
4
4
 
5
5
  module.exports = async function cmdExplain() {
6
6
  header()
7
- const query = args[1]
7
+ const query = posArgs[0] ?? args[1]
8
8
  const manifestPath = getFlag('--manifest') ?? 'manifest.json'
9
9
 
10
10
  if (!query) {
@@ -70,60 +70,72 @@ Rules:
70
70
 
71
71
  async function callLLM(provider, apiKey, prompt) {
72
72
  if (provider === 'anthropic') {
73
- const res = await fetch('https://api.anthropic.com/v1/messages', {
73
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
74
74
  method: 'POST',
75
75
  headers: {
76
- 'Content-Type': 'application/json',
77
- 'x-api-key': apiKey,
76
+ 'Content-Type': 'application/json',
77
+ 'x-api-key': apiKey,
78
78
  'anthropic-version': '2023-06-01',
79
79
  },
80
80
  body: JSON.stringify({
81
- model: 'claude-sonnet-4-20250514',
81
+ model: 'claude-sonnet-4-20250514',
82
82
  max_tokens: 4000,
83
- messages: [{ role: 'user', content: prompt }],
83
+ messages: [{ role: 'user', content: prompt }],
84
84
  }),
85
85
  })
86
- const data = await res.json()
87
- if (!res.ok) throw new Error(data.error?.message ?? res.statusText)
88
- return data.content[0].text
86
+ const text = await res.text()
87
+ if (!res.ok) {
88
+ let msg = res.statusText
89
+ try { msg = JSON.parse(text).error?.message ?? msg } catch {}
90
+ throw new Error(`Anthropic API error: ${msg}`)
91
+ }
92
+ return JSON.parse(text).content[0].text
89
93
  }
90
94
 
91
95
  if (provider === 'openai') {
92
- const res = await fetch('https://api.openai.com/v1/chat/completions', {
96
+ const res = await fetch('https://api.openai.com/v1/chat/completions', {
93
97
  method: 'POST',
94
98
  headers: {
95
- 'Content-Type': 'application/json',
99
+ 'Content-Type': 'application/json',
96
100
  'Authorization': `Bearer ${apiKey}`,
97
101
  },
98
102
  body: JSON.stringify({
99
- model: 'gpt-4o-mini',
103
+ model: 'gpt-4o-mini',
100
104
  max_tokens: 4000,
101
- messages: [{ role: 'user', content: prompt }],
105
+ messages: [{ role: 'user', content: prompt }],
102
106
  }),
103
107
  })
104
- const data = await res.json()
105
- if (!res.ok) throw new Error(data.error?.message ?? res.statusText)
106
- return data.choices[0].message.content
108
+ const text = await res.text()
109
+ if (!res.ok) {
110
+ let msg = res.statusText
111
+ try { msg = JSON.parse(text).error?.message ?? msg } catch {}
112
+ throw new Error(`OpenAI API error: ${msg}`)
113
+ }
114
+ return JSON.parse(text).choices[0].message.content
107
115
  }
108
116
 
109
117
  if (provider === 'openrouter') {
110
- const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
118
+ const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
111
119
  method: 'POST',
112
120
  headers: {
113
- 'Content-Type': 'application/json',
121
+ 'Content-Type': 'application/json',
114
122
  'Authorization': `Bearer ${apiKey}`,
115
- 'HTTP-Referer': 'https://github.com/Hobbydefiningdoctory/capman',
123
+ 'HTTP-Referer': 'https://github.com/Hobbydefiningdoctory/capman',
116
124
  },
117
125
  body: JSON.stringify({
118
- model: 'openai/gpt-oss-120b:free',
126
+ model: 'openai/gpt-oss-120b:free',
119
127
  max_tokens: 4000,
120
- messages: [{ role: 'user', content: prompt }],
121
- provider: { order: ['open-inference'], allow_fallbacks: true },
128
+ messages: [{ role: 'user', content: prompt }],
129
+ provider: { order: ['open-inference'], allow_fallbacks: true },
122
130
  }),
123
131
  })
124
- const data = await res.json()
125
- if (!res.ok) throw new Error(data.error?.message ?? res.statusText)
126
- return data.choices[0].message.content
132
+ const text = await res.text()
133
+ if (!res.ok) {
134
+ let msg = res.statusText
135
+ try { msg = JSON.parse(text).error?.message ?? msg } catch {}
136
+ throw new Error(`OpenRouter API error: ${msg}`)
137
+ }
138
+ return JSON.parse(text).choices[0].message.content
127
139
  }
128
140
 
129
141
  throw new Error(`Unknown provider: ${provider}`)
@@ -206,11 +218,15 @@ const fromFlag = getFlag('--from')
206
218
  process.exit(1)
207
219
  }
208
220
 
209
- const apiKey = process.env.ANTHROPIC_API_KEY ?? process.env.OPENAI_API_KEY ?? process.env.OPENROUTER_API_KEY
221
+ const apiKey =
222
+ process.env.ANTHROPIC_API_KEY?.trim() ||
223
+ process.env.OPENAI_API_KEY?.trim() ||
224
+ process.env.OPENROUTER_API_KEY?.trim() ||
225
+ null
210
226
  const provider =
211
- process.env.ANTHROPIC_API_KEY ? 'anthropic' :
212
- process.env.OPENAI_API_KEY ? 'openai' :
213
- process.env.OPENROUTER_API_KEY ? 'openrouter' : null
227
+ process.env.ANTHROPIC_API_KEY?.trim() ? 'anthropic' :
228
+ process.env.OPENAI_API_KEY?.trim() ? 'openai' :
229
+ process.env.OPENROUTER_API_KEY?.trim() ? 'openrouter' : null
214
230
 
215
231
  if (!apiKey || !provider) {
216
232
  log.error('No LLM API key found.')