capman 0.6.0 → 0.6.1

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 (57) hide show
  1. package/dist/cjs/cache.d.ts +9 -0
  2. package/dist/cjs/cache.d.ts.map +1 -1
  3. package/dist/cjs/cache.js +37 -7
  4. package/dist/cjs/cache.js.map +1 -1
  5. package/dist/cjs/engine.d.ts +15 -0
  6. package/dist/cjs/engine.d.ts.map +1 -1
  7. package/dist/cjs/engine.js +111 -21
  8. package/dist/cjs/engine.js.map +1 -1
  9. package/dist/cjs/generator.d.ts.map +1 -1
  10. package/dist/cjs/generator.js +28 -6
  11. package/dist/cjs/generator.js.map +1 -1
  12. package/dist/cjs/index.d.ts +2 -1
  13. package/dist/cjs/index.d.ts.map +1 -1
  14. package/dist/cjs/index.js +3 -1
  15. package/dist/cjs/index.js.map +1 -1
  16. package/dist/cjs/learning.d.ts +7 -0
  17. package/dist/cjs/learning.d.ts.map +1 -1
  18. package/dist/cjs/learning.js +35 -8
  19. package/dist/cjs/learning.js.map +1 -1
  20. package/dist/cjs/matcher.d.ts +38 -1
  21. package/dist/cjs/matcher.d.ts.map +1 -1
  22. package/dist/cjs/matcher.js +106 -23
  23. package/dist/cjs/matcher.js.map +1 -1
  24. package/dist/cjs/parser.js +27 -9
  25. package/dist/cjs/parser.js.map +1 -1
  26. package/dist/cjs/resolver.d.ts +2 -2
  27. package/dist/cjs/resolver.d.ts.map +1 -1
  28. package/dist/cjs/resolver.js +66 -26
  29. package/dist/cjs/resolver.js.map +1 -1
  30. package/dist/cjs/schema.d.ts +821 -68
  31. package/dist/cjs/schema.d.ts.map +1 -1
  32. package/dist/cjs/schema.js +61 -12
  33. package/dist/cjs/schema.js.map +1 -1
  34. package/dist/cjs/types.d.ts +147 -9
  35. package/dist/cjs/types.d.ts.map +1 -1
  36. package/dist/cjs/version.d.ts +1 -1
  37. package/dist/cjs/version.js +1 -1
  38. package/dist/esm/cache.d.ts +9 -0
  39. package/dist/esm/cache.js +37 -7
  40. package/dist/esm/engine.d.ts +15 -0
  41. package/dist/esm/engine.js +112 -22
  42. package/dist/esm/generator.js +28 -6
  43. package/dist/esm/index.d.ts +2 -1
  44. package/dist/esm/index.js +1 -0
  45. package/dist/esm/learning.d.ts +7 -0
  46. package/dist/esm/learning.js +35 -8
  47. package/dist/esm/matcher.d.ts +38 -1
  48. package/dist/esm/matcher.js +104 -23
  49. package/dist/esm/parser.js +27 -9
  50. package/dist/esm/resolver.d.ts +2 -2
  51. package/dist/esm/resolver.js +66 -26
  52. package/dist/esm/schema.d.ts +821 -68
  53. package/dist/esm/schema.js +61 -12
  54. package/dist/esm/types.d.ts +147 -9
  55. package/dist/esm/version.d.ts +1 -1
  56. package/dist/esm/version.js +1 -1
  57. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { match as _match, matchWithLLM as _matchWithLLM, resolverToIntent, extractParams, LLMParseError, tokenize, buildBM25Index, scoreCapability as _scoreCapability, sanitizeForPrompt } from './matcher';
1
+ import { match as _match, matchWithLLM as _matchWithLLM, resolverToIntent, extractParams, LLMParseError, tokenize, buildBM25Index, sanitizeForPrompt, calibrateCeiling as _calibrateCeiling } from './matcher';
2
2
  import { resolve as _resolve, checkPrivacy } from './resolver';
3
3
  import { MemoryLearningStore } from './learning';
4
4
  import { logger } from './logger';
@@ -17,6 +17,7 @@ export class CapmanEngine {
17
17
  this.mode = options.mode ?? 'balanced';
18
18
  this.llm = options.llm;
19
19
  this.baseUrl = options.baseUrl;
20
+ this.environment = options.environment;
20
21
  this.auth = options.auth;
21
22
  this.headers = options.headers;
22
23
  this.threshold = options.threshold ?? 50;
@@ -124,6 +125,7 @@ export class CapmanEngine {
124
125
  // ── Step 2.5: Apply learning boost ───────────────────────────────────────
125
126
  matchResult = await this.applyBoostToMatchResult(query, matchResult, resolvedVia);
126
127
  // ── Step 3: Privacy check ────────────────────────────────────────────────
128
+ let privacyFailed = false;
127
129
  if (matchResult.capability) {
128
130
  const privacyError = checkPrivacy(matchResult.capability, this.auth);
129
131
  steps.push({
@@ -132,13 +134,23 @@ export class CapmanEngine {
132
134
  durationMs: 0,
133
135
  detail: privacyError ?? `level: ${matchResult.capability.privacy.level}`,
134
136
  });
137
+ // Warn on deprecated or sunset capabilities — never silently fail
138
+ this.checkCapabilityLifecycle(matchResult.capability);
139
+ // Log when engine mode differs from capability's preferred mode
140
+ this.checkMatchHint(matchResult.capability);
141
+ // Short-circuit: if privacy fails, skip disambiguation to avoid burning an LLM
142
+ // call on a request that _resolve() will block anyway. privacyFailed propagates
143
+ // to Step 4a so the mode guard check is clean and explicit.
144
+ if (privacyError)
145
+ privacyFailed = true;
135
146
  }
136
147
  // ── Step 4a: Compute verdict + optional margin-aware LLM disambiguation ──
137
148
  let { verdict, margin } = this.computeVerdict(matchResult);
138
149
  if (verdict === 'marginal' &&
139
150
  this.marginAwareLLM &&
140
151
  this.llm &&
141
- this.mode === 'balanced') {
152
+ !privacyFailed &&
153
+ (this.mode === 'balanced' || this.mode === 'accurate')) {
142
154
  matchResult = await this.disambiguateLLM(query, matchResult, steps);
143
155
  // Recompute verdict after disambiguation
144
156
  const recomputed = this.computeVerdict(matchResult);
@@ -205,8 +217,19 @@ export class CapmanEngine {
205
217
  }
206
218
  }
207
219
  }
208
- catch {
209
- // LLM param extraction failed — fall through to missingParams below
220
+ catch (err) {
221
+ const isParseError = err instanceof SyntaxError;
222
+ if (isParseError) {
223
+ // JSON parse failure: refund the rate-limit slot but don't open circuit breaker
224
+ // The llm is reachable - the response format was just bad
225
+ this.llmCallsThisMinute = Math.max(0, this.llmCallsThisMinute - 1);
226
+ }
227
+ else {
228
+ // Hard failure (timeout, network): refund slot and increment fail counter
229
+ this.recordLLMFailure();
230
+ }
231
+ logger.warn(`LLM param extraction failed: ${err instanceof Error ? err.message : String(err)}`);
232
+ // fall through to missingParams below
210
233
  }
211
234
  }
212
235
  }
@@ -292,6 +315,20 @@ export class CapmanEngine {
292
315
  await this.cache.clear();
293
316
  }
294
317
  checkManifestVersion(manifest) {
318
+ // ── Schema version check ─────────────────────────────────────────────────
319
+ // schemaVersion tracks manifest format — "1" for v0.6+.
320
+ // Manifests without schemaVersion are pre-v0.6 — warn but allow.
321
+ const CURRENT_SCHEMA_VERSION = '1';
322
+ if (!manifest.schemaVersion) {
323
+ console.warn(`[capman] Manifest is missing schemaVersion — it was generated with capman < 0.6. ` +
324
+ `Regenerate with: npx capman generate`);
325
+ }
326
+ else if (manifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
327
+ console.warn(`[capman] Manifest schemaVersion "${manifest.schemaVersion}" differs from ` +
328
+ `engine's expected "${CURRENT_SCHEMA_VERSION}". ` +
329
+ `Regenerate with: npx capman generate`);
330
+ }
331
+ // ── Package version check ────────────────────────────────────────────────
295
332
  if (!manifest.version)
296
333
  return;
297
334
  const SEMVER_RE = /^\d+\.\d+\.\d+$/;
@@ -299,8 +336,8 @@ export class CapmanEngine {
299
336
  const [mMaj, mMin] = manifest.version.split('.').map(Number);
300
337
  const [eMaj, eMin] = VERSION.split('.').map(Number);
301
338
  if (mMaj !== eMaj || mMin !== eMin) {
302
- console.warn(`[capman] Manifest version "${manifest.version}" was generated with a ` +
303
- `different engine version than "${VERSION}". This is usually fine across patch versions. ` +
339
+ console.warn(`[capman] Manifest was generated with capman "${manifest.version}" ` +
340
+ `but engine is "${VERSION}". This is usually fine across patch versions. ` +
304
341
  `If you experience unexpected matching issues, regenerate with: npx capman generate`);
305
342
  }
306
343
  }
@@ -309,6 +346,42 @@ export class CapmanEngine {
309
346
  `to engine version "${VERSION}" — version strings are not valid semver.`);
310
347
  }
311
348
  }
349
+ checkCapabilityLifecycle(capability) {
350
+ const lc = capability.lifecycle;
351
+ if (!lc || lc.status === 'stable' || lc.status === 'beta' || lc.status === 'experimental') {
352
+ if (lc?.status === 'beta') {
353
+ logger.warn(`Capability "${capability.id}" is in beta — behavior may change`);
354
+ }
355
+ if (lc?.status === 'experimental') {
356
+ logger.warn(`Capability "${capability.id}" is experimental — use with caution`);
357
+ }
358
+ return;
359
+ }
360
+ if (lc.status === 'deprecated') {
361
+ const sunsetPassed = lc.sunsetAt && new Date(lc.sunsetAt) < new Date();
362
+ if (sunsetPassed) {
363
+ // Sunset date has passed — strongest warning
364
+ console.warn(`[capman] ⚠️ Capability "${capability.id}" passed its sunset date (${lc.sunsetAt}). ` +
365
+ `It may be removed in a future version.` +
366
+ (lc.successor ? ` Use "${lc.successor}" instead.` : '') +
367
+ (lc.note ? ` Note: ${lc.note}` : ''));
368
+ }
369
+ else {
370
+ logger.warn(`Capability "${capability.id}" is deprecated.` +
371
+ (lc.sunsetAt ? ` Sunset: ${lc.sunsetAt}.` : '') +
372
+ (lc.successor ? ` Use "${lc.successor}" instead.` : '') +
373
+ (lc.note ? ` Note: ${lc.note}` : ''));
374
+ }
375
+ }
376
+ }
377
+ checkMatchHint(capability) {
378
+ const hint = capability.matchHint?.preferredMode;
379
+ if (!hint || hint === this.mode)
380
+ return;
381
+ // Advisory only — log but never enforce
382
+ logger.warn(`Capability "${capability.id}" prefers mode "${hint}" but engine is in "${this.mode}" mode. ` +
383
+ `Set mode: '${hint}' in EngineOptions to honor this hint.`);
384
+ }
312
385
  /**
313
386
  * Replaces the active manifest without creating a new engine instance.
314
387
  * Useful for hot-reloading manifests in long-running servers without
@@ -327,6 +400,8 @@ export class CapmanEngine {
327
400
  this.bm25Index = buildBM25Index(manifest.capabilities);
328
401
  this.bm25Ceiling = this.calibrateBM25Ceiling();
329
402
  this.adaptiveMargin = this.calibrateAdaptiveMargin();
403
+ // resolveBaseUrl() reads from this.manifest.servers on each call —
404
+ // server selection updates automatically after loadManifest()
330
405
  await this.clearCache();
331
406
  }
332
407
  /**
@@ -684,7 +759,11 @@ export class CapmanEngine {
684
759
  break;
685
760
  }
686
761
  }
687
- return { matchResult: matchResult, resolvedVia };
762
+ if (matchResult === undefined) {
763
+ const exhaustive = this.mode;
764
+ throw new Error(`_runMatch: unhandled MatchMode "${exhaustive}"`);
765
+ }
766
+ return { matchResult, resolvedVia };
688
767
  }
689
768
  /**
690
769
  * Applies learning boost to a MatchResult and returns the updated result.
@@ -769,10 +848,26 @@ export class CapmanEngine {
769
848
  };
770
849
  });
771
850
  }
851
+ /**
852
+ * Resolves the effective baseUrl from manifest.servers[] or EngineOptions.baseUrl.
853
+ * Priority: environment-matched server > first server > explicit baseUrl > undefined
854
+ */
855
+ resolveBaseUrl() {
856
+ const servers = this.manifest.servers;
857
+ if (!servers?.length)
858
+ return this.baseUrl;
859
+ if (this.environment) {
860
+ const match = servers.find(s => s.environment === this.environment);
861
+ if (match)
862
+ return match.url.replace(/\/$/, '');
863
+ }
864
+ // Fallback to first server
865
+ return servers[0].url.replace(/\/$/, '');
866
+ }
772
867
  // ── Private helpers ────────────────────────────────────────────────────────
773
868
  resolveOptions(overrides = {}) {
774
869
  return {
775
- baseUrl: this.baseUrl,
870
+ baseUrl: this.resolveBaseUrl(),
776
871
  auth: this.auth,
777
872
  headers: this.headers,
778
873
  ...overrides,
@@ -792,16 +887,7 @@ export class CapmanEngine {
792
887
  });
793
888
  }
794
889
  calibrateBM25Ceiling() {
795
- let max = 0;
796
- for (const cap of this.manifest.capabilities) {
797
- if (!cap.examples?.length)
798
- continue;
799
- const selfWords = new Set(tokenize(cap.examples[0]));
800
- const raw = _scoreCapability(selfWords, cap, this.bm25Index, this.bm25K1, this.bm25B);
801
- if (raw > max)
802
- max = raw;
803
- }
804
- return max > 0 ? max : 100;
890
+ return _calibrateCeiling(this.manifest.capabilities, this.bm25Index, this.bm25K1, this.bm25B);
805
891
  }
806
892
  /**
807
893
  * Calibrates the adaptive margin threshold from the manifest's own score
@@ -829,10 +915,14 @@ export class CapmanEngine {
829
915
  for (const cap of this.manifest.capabilities) {
830
916
  if (!cap.examples?.length)
831
917
  continue;
832
- const result = _match(cap.examples[0], this.manifest, fuzzyOpts);
833
- const sorted = [...result.candidates].sort((a, b) => b.score - a.score);
834
- if (sorted.length >= 2) {
835
- margins.push(sorted[0].score - sorted[1].score);
918
+ // Use all examples and take the maximum margin — same rationale as
919
+ // calibrateBM25Ceiling(): a weak first example skews the calibration.
920
+ for (const example of cap.examples) {
921
+ const result = _match(example, this.manifest, fuzzyOpts);
922
+ const sorted = [...result.candidates].sort((a, b) => b.score - a.score);
923
+ if (sorted.length >= 2) {
924
+ margins.push(sorted[0].score - sorted[1].score);
925
+ }
836
926
  }
837
927
  }
838
928
  if (margins.length === 0)
@@ -5,10 +5,14 @@ import { validateConfig, validateManifest } from './schema';
5
5
  import { logger } from './logger';
6
6
  export function generate(config) {
7
7
  return {
8
+ schemaVersion: '1',
8
9
  version: VERSION,
9
10
  app: config.app,
10
11
  generatedAt: new Date().toISOString(),
11
- capabilities: config.capabilities.map(cap => ({ ...cap, params: [...cap.params] })),
12
+ capabilities: config.capabilities.map(cap => ({ ...cap })),
13
+ ...(config.info ? { info: config.info } : {}),
14
+ ...(config.tagRegistry ? { tagRegistry: config.tagRegistry } : {}),
15
+ ...(config.servers ? { servers: config.servers } : {}),
12
16
  };
13
17
  }
14
18
  export function loadConfig(configPath) {
@@ -33,6 +37,10 @@ export function loadConfig(configPath) {
33
37
  // Use a CJS config file or convert with: module.exports = { ... }
34
38
  // Full ESM config support is planned for v0.5.
35
39
  try {
40
+ // Bust the module cache before loading — require() caches by resolved path,
41
+ // so a second call without this returns the stale version from the first call.
42
+ // This matters in watch mode and test suites that change config between calls.
43
+ delete require.cache[require.resolve(resolved)];
36
44
  const mod = require(resolved);
37
45
  raw = mod.default ?? mod;
38
46
  }
@@ -80,7 +88,12 @@ export function writeManifest(manifest, outputPath = 'manifest.json') {
80
88
  throw new Error(`writeManifest: output path "${outputPath}" resolves outside the working directory.\n` +
81
89
  `Resolved: ${resolved}\nAllowed: ${cwd}`);
82
90
  }
83
- fs.writeFileSync(resolved, JSON.stringify(manifest, null, 2));
91
+ // Write atomically via tmp → rename — same pattern used by FileCache and
92
+ // FileLearningStore. A crash or SIGKILL mid-write leaves the .tmp file, not
93
+ // a truncated manifest.json, so the next readManifest() can still parse it.
94
+ const tmp = `${resolved}.tmp`;
95
+ fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2));
96
+ fs.renameSync(tmp, resolved);
84
97
  return resolved;
85
98
  }
86
99
  export function readManifest(manifestPath = 'manifest.json') {
@@ -122,14 +135,23 @@ export function validate(manifest) {
122
135
  return { valid: errors.length === 0, errors, warnings };
123
136
  }
124
137
  export function generateStarterConfig() {
125
- return `// capman.config.js
126
- // Define what your app can do for AI agents.
127
- // Replace the examples below with your own app's capabilities.
138
+ return `// capman.config.js
139
+ // Auto-generated starter config edit before use
128
140
 
129
141
  module.exports = {
130
- app: 'your-app-name',
142
+ app: 'my-app',
131
143
  baseUrl: 'https://api.your-app.com',
132
144
 
145
+ // Optional metadata block — used for documentation and provenance
146
+ info: {
147
+ title: 'My App',
148
+ description: 'Brief description of what this app does',
149
+ version: '1.0.0',
150
+ homepage: 'https://your-app.com',
151
+ contact: { name: 'Your Name', email: 'you@your-app.com' },
152
+ license: { name: 'MIT' },
153
+ },
154
+
133
155
  capabilities: [
134
156
  {
135
157
  id: 'get_resource',
@@ -1,11 +1,12 @@
1
1
  export { setLogLevel } from './logger';
2
2
  export type { LogLevel } from './logger';
3
- export type { Capability, CapabilityParam, CapmanConfig, Manifest, MatchResult, ExecutionTrace, TraceStep, MatchCandidate, ResolveResult, ApiCallResult, ValidationResult, Resolver, ApiResolver, NavResolver, HybridResolver, PrivacyScope, ResolverType, HttpMethod, ExplainResult, ExplainCandidate, } from './types';
3
+ export type { Capability, CapabilityParam, CapmanConfig, Manifest, MatchResult, ExecutionTrace, TraceStep, MatchCandidate, ResolveResult, ApiCallResult, ValidationResult, Resolver, ApiResolver, NavResolver, HybridResolver, PrivacyScope, ResolverType, HttpMethod, ExplainResult, ExplainCandidate, ManifestInfo, Server, LifecycleInfo, LifecycleStatus, CapabilityError, Endpoint, ParamType, MatchHint, } from './types';
4
4
  export { generate, loadConfig, writeManifest, readManifest, validate, generateStarterConfig, } from './generator';
5
5
  export { match, matchWithLLM, extractParams, } from './matcher';
6
6
  export { LLMParseError } from './matcher';
7
7
  export type { LLMMatcherOptions } from './matcher';
8
8
  export { TYPE_PATTERNS } from './matcher';
9
+ export { filterByTags } from './matcher';
9
10
  export { resolve } from './resolver';
10
11
  export type { ResolveOptions, AuthContext } from './resolver';
11
12
  export { CapmanEngine } from './engine';
package/dist/esm/index.js CHANGED
@@ -3,6 +3,7 @@ export { generate, loadConfig, writeManifest, readManifest, validate, generateSt
3
3
  export { match, matchWithLLM, extractParams, } from './matcher';
4
4
  export { LLMParseError } from './matcher';
5
5
  export { TYPE_PATTERNS } from './matcher';
6
+ export { filterByTags } from './matcher';
6
7
  export { resolve } from './resolver';
7
8
  // ─── Engine (recommended API) ─────────────────────────────────────────────────
8
9
  export { CapmanEngine } from './engine';
@@ -6,6 +6,13 @@ export interface LearningEntry {
6
6
  extractedParams: Record<string, string | null>;
7
7
  resolvedVia: 'keyword' | 'llm' | 'cache';
8
8
  timestamp: string;
9
+ /**
10
+ * Confidence-derived weight stored at record time (confidence / 100, floor 0.1).
11
+ * Used by subtract() to reverse the exact contribution made by update(),
12
+ * preventing index drift when high-confidence entries are pruned.
13
+ * Optional for backwards-compatibility with persisted entries written before v0.5.5.
14
+ */
15
+ weight?: number;
9
16
  }
10
17
  export interface KeywordStats {
11
18
  /** keyword → Map of capabilityId → hit count */
@@ -75,6 +75,10 @@ class LearningIndex {
75
75
  // more signal than a 51% borderline match. Floor of 0.1 ensures
76
76
  // borderline matches still contribute, just proportionally less.
77
77
  const weight = Math.max(0.1, entry.confidence / 100);
78
+ // Store weight on the entry so subtract() can reverse the exact amount.
79
+ // Without this, subtract() would have to use a hardcoded estimate (0.5)
80
+ // that causes index drift after pruning high-confidence entries.
81
+ entry.weight = weight;
78
82
  const words = tokenize(entry.query);
79
83
  for (const word of words) {
80
84
  this.index[word] ??= {};
@@ -99,10 +103,12 @@ class LearningIndex {
99
103
  for (const word of words) {
100
104
  if (!this.index[word])
101
105
  continue;
102
- // Subtract estimated weight (0.5 average) exact weight not stored.
103
- // Minor drift on prune is acceptable; index is rebuilt when drift matters.
106
+ // Use the weight stored at record time for exact symmetric subtraction.
107
+ // Fallback recalculates from confidence for entries persisted before the
108
+ // weight field was added (backwards-compatible with older learning.json files).
109
+ const weight = entry.weight ?? Math.max(0.1, entry.confidence / 100);
104
110
  this.index[word][entry.capabilityId] =
105
- (this.index[word][entry.capabilityId] ?? 0.5) - 0.5;
111
+ (this.index[word][entry.capabilityId] ?? weight) - weight;
106
112
  if (this.index[word][entry.capabilityId] <= 0) {
107
113
  delete this.index[word][entry.capabilityId];
108
114
  }
@@ -168,8 +174,10 @@ export class FileLearningStore {
168
174
  fs.writeFileSync(tmp, payload);
169
175
  fs.renameSync(tmp, this.filePath);
170
176
  }
171
- catch {
172
- // Best-effort in exit handler
177
+ catch (err) {
178
+ // Use process.stderr.write — never console.error in an exit handler,
179
+ // as stdout may already be flushed or closed at this point.
180
+ process.stderr.write(`[capman] Failed to flush learning store to ${this.filePath}: ${err}\n`);
173
181
  }
174
182
  }
175
183
  /**
@@ -202,7 +210,26 @@ export class FileLearningStore {
202
210
  const raw = await fs.promises.readFile(this.filePath, 'utf-8');
203
211
  const parsed = JSON.parse(raw);
204
212
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Array.isArray(parsed.entries)) {
205
- this.entries = parsed.entries;
213
+ // Validate each entry — corrupted entries (null capability, wrong types) must
214
+ // not propagate into the engine where they cause runtime errors deep in matching.
215
+ const validEntries = [];
216
+ let skipped = 0;
217
+ for (const entry of parsed.entries) {
218
+ if (entry !== null && typeof entry === 'object' &&
219
+ typeof entry.query === 'string' &&
220
+ (entry.capabilityId === null || typeof entry.capabilityId === 'string') &&
221
+ typeof entry.confidence === 'number' &&
222
+ typeof entry.resolvedVia === 'string') {
223
+ validEntries.push(entry);
224
+ }
225
+ else {
226
+ skipped++;
227
+ }
228
+ }
229
+ if (skipped > 0) {
230
+ logger.warn(`Learning store: skipped ${skipped} invalid entries during load`);
231
+ }
232
+ this.entries = validEntries;
206
233
  this.learningIndex.rebuild(this.entries);
207
234
  logger.debug(`Learning store loaded: ${this.entries.length} entries`);
208
235
  }
@@ -313,8 +340,8 @@ export class MemoryLearningStore {
313
340
  if (this.entries.length > MAX_LEARNING_ENTRIES) {
314
341
  const excess = this.entries.length - MAX_LEARNING_ENTRIES;
315
342
  const pruned = this.entries.splice(0, excess);
316
- for (const entry of pruned) {
317
- this.learningIndex.subtract(entry);
343
+ for (const staleEntry of pruned) {
344
+ this.learningIndex.subtract(staleEntry);
318
345
  }
319
346
  }
320
347
  }
@@ -34,6 +34,17 @@ export interface BM25Index {
34
34
  N: number;
35
35
  /** Bigram sets per capability — post-stopword, post-stem, examples only */
36
36
  bigrams: Record<string, Set<string>>;
37
+ /**
38
+ * Pre-computed token arrays per capability, per field.
39
+ * Avoids re-tokenizing capability text on every scoreCapability() call.
40
+ * At 50 capabilities × 100 req/s, that is 5,000 redundant tokenization
41
+ * calls per second — each involving stem() and split/filter chains.
42
+ */
43
+ capTokens: Record<string, {
44
+ examples: string[];
45
+ description: string[];
46
+ name: string[];
47
+ }>;
37
48
  }
38
49
  /** Build a BM25 index over all capabilities. Call once at manifest load. */
39
50
  export declare function buildBM25Index(capabilities: Capability[]): BM25Index;
@@ -48,11 +59,31 @@ export declare function scoreCapability(qWordSet: Set<string>, cap: Capability,
48
59
  * Input must already be post-stopword and post-stem (use tokenize() first).
49
60
  */
50
61
  export declare function extractBigrams(tokens: string[]): Set<string>;
62
+ /**
63
+ * Returns a sub-manifest containing only capabilities that match ALL provided tags.
64
+ * Capabilities without tags are excluded when tags filter is active.
65
+ * Enables token-efficient LLM prompts for large manifests:
66
+ *
67
+ * @example
68
+ * // Only send order-related capabilities to LLM
69
+ * const orderManifest = filterByTags(manifest, ['orders'])
70
+ * const result = await matchWithLLM(query, orderManifest, { llm })
71
+ *
72
+ * @example
73
+ * // Match by any of multiple tags (union) — call filterByTags per tag and merge
74
+ * const ordersOrPayments = [
75
+ * ...filterByTags(manifest, ['orders']).capabilities,
76
+ * ...filterByTags(manifest, ['payments']).capabilities,
77
+ * ]
78
+ */
79
+ export declare function filterByTags(manifest: Manifest, tags: string[]): Manifest;
51
80
  export declare function resolverToIntent(cap: Capability): MatchResult['intent'];
52
81
  /**
53
82
  * Strips characters that could break LLM prompt structure from
54
83
  * capability field values before injection into the system prompt.
55
- * Removes control characters, newlines, and delimiter-like sequences.
84
+ * Removes control characters, newlines, delimiter sequences, and braces
85
+ * anywhere in the string (not just at line starts) to resist prompt injection
86
+ * from third-party OpenAPI spec content ingested via parseOpenAPI().
56
87
  */
57
88
  export declare function sanitizeForPrompt(value: string, maxLen: number): string;
58
89
  /**
@@ -78,6 +109,12 @@ export interface MatchOptions {
78
109
  bm25B?: number;
79
110
  bm25Ceiling?: number;
80
111
  }
112
+ /**
113
+ * Calibrates a BM25 normalization ceiling from the manifest.
114
+ * Scores each capability against all of its own examples and returns the maximum.
115
+ * Call once at manifest load time — O(capabilities × examples).
116
+ */
117
+ export declare function calibrateCeiling(capabilities: Capability[], bm25Index: BM25Index, k1: number, b: number): number;
81
118
  export declare function match(query: string, manifest: Manifest, options?: MatchOptions): MatchResult;
82
119
  export interface LLMMatcherOptions {
83
120
  llm: (prompt: string) => Promise<string>;