capman 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CODEBASE.md +6 -5
  2. package/dist/cjs/concurrent.d.ts +53 -0
  3. package/dist/cjs/concurrent.d.ts.map +1 -0
  4. package/dist/cjs/concurrent.js +71 -0
  5. package/dist/cjs/concurrent.js.map +1 -0
  6. package/dist/cjs/engine.d.ts +82 -12
  7. package/dist/cjs/engine.d.ts.map +1 -1
  8. package/dist/cjs/engine.js +159 -37
  9. package/dist/cjs/engine.js.map +1 -1
  10. package/dist/cjs/index.d.ts +2 -1
  11. package/dist/cjs/index.d.ts.map +1 -1
  12. package/dist/cjs/index.js +3 -1
  13. package/dist/cjs/index.js.map +1 -1
  14. package/dist/cjs/learning.d.ts +14 -6
  15. package/dist/cjs/learning.d.ts.map +1 -1
  16. package/dist/cjs/learning.js +64 -10
  17. package/dist/cjs/learning.js.map +1 -1
  18. package/dist/cjs/matcher.d.ts +13 -1
  19. package/dist/cjs/matcher.d.ts.map +1 -1
  20. package/dist/cjs/matcher.js +67 -10
  21. package/dist/cjs/matcher.js.map +1 -1
  22. package/dist/cjs/schema.js +1 -1
  23. package/dist/cjs/schema.js.map +1 -1
  24. package/dist/cjs/types.d.ts +9 -0
  25. package/dist/cjs/types.d.ts.map +1 -1
  26. package/dist/cjs/version.d.ts +1 -1
  27. package/dist/cjs/version.js +1 -1
  28. package/dist/esm/concurrent.d.ts +52 -0
  29. package/dist/esm/concurrent.js +66 -0
  30. package/dist/esm/engine.d.ts +82 -12
  31. package/dist/esm/engine.js +159 -37
  32. package/dist/esm/index.d.ts +2 -1
  33. package/dist/esm/index.js +1 -0
  34. package/dist/esm/learning.d.ts +14 -6
  35. package/dist/esm/learning.js +64 -10
  36. package/dist/esm/matcher.d.ts +13 -1
  37. package/dist/esm/matcher.js +66 -10
  38. package/dist/esm/schema.js +1 -1
  39. package/dist/esm/types.d.ts +9 -0
  40. package/dist/esm/version.d.ts +1 -1
  41. package/dist/esm/version.js +1 -1
  42. package/package.json +1 -1
@@ -7,6 +7,9 @@ import { VERSION } from './version';
7
7
  // ─── CapmanEngine ─────────────────────────────────────────────────────────────
8
8
  export class CapmanEngine {
9
9
  constructor(options) {
10
+ this.manifestVersion = 0;
11
+ /** Resolves when the post-loadManifest re-encode completes. Awaited by buildEmbeddingScores(). */
12
+ this.pendingEmbedding = null;
10
13
  // ── LLM rate limiting state ────────────────────────────────────────────────
11
14
  this.llmCallsThisMinute = 0;
12
15
  this.llmWindowStart = Date.now();
@@ -43,8 +46,20 @@ export class CapmanEngine {
43
46
  // Use FileLearningStore explicitly for persistence across restarts
44
47
  this.learning = options.learning === false
45
48
  ? null
46
- : (options.learning ?? new MemoryLearningStore());
47
- logger.info(`CapmanEngine initialized — mode: ${this.mode}, cache: ${this.cache ? 'enabled' : 'disabled'}, learning: ${this.learning ? 'enabled' : 'disabled'}`);
49
+ : (options.learning ?? new MemoryLearningStore(options.learningHalfLifeDays ?? 30));
50
+ this.embedding = options.embedding;
51
+ if (this.embedding) {
52
+ // Pre-encode all capability texts at construction time — one batch call.
53
+ // Concatenate name + description for richer semantic surface.
54
+ const texts = this.manifest.capabilities.map(c => `${c.name}: ${c.description}`);
55
+ this.embedding.encode(texts).then(vecs => {
56
+ this.capEmbeddings = vecs;
57
+ logger.info('Capability embeddings pre-encoded');
58
+ }).catch(err => {
59
+ logger.warn(`EmbeddingProvider pre-encode failed — embedding signal disabled: ${err instanceof Error ? err.message : String(err)}`);
60
+ });
61
+ }
62
+ logger.info(`CapmanEngine initialized — mode: ${this.mode}, cache: ${this.cache ? 'enabled' : 'disabled'}, learning: ${this.learning ? 'enabled' : 'disabled'}, embedding: ${this.embedding ? 'enabled' : 'disabled'}`);
48
63
  // ── Manifest version compatibility check ─────────────────────────────────
49
64
  this.checkManifestVersion(options.manifest);
50
65
  }
@@ -68,6 +83,9 @@ export class CapmanEngine {
68
83
  }
69
84
  const start = Date.now();
70
85
  const steps = [];
86
+ // Capture manifest version at entry — used to guard the cache write.
87
+ // If loadManifest() is called mid-flight, we skip writing stale results.
88
+ const manifestVersion = this.manifestVersion;
71
89
  // ── Step 1: Check cache ──────────────────────────────────────────────────
72
90
  const cacheStart = Date.now();
73
91
  if (this.cache) {
@@ -173,11 +191,19 @@ export class CapmanEngine {
173
191
  // queries that resolve to the same capability share a cache entry
174
192
  if (this.cache && resolution.success && matchResult.capability
175
193
  && matchResult.capability.privacy.level === 'public') {
176
- const queryKey = normalizeQuery(query);
177
- const capKey = buildCacheKey(query, matchResult.capability.id, matchResult.extractedParams);
178
- await this.cache.set(queryKey, matchResult);
179
- await this.cache.set(capKey, matchResult);
180
- // capKey always starts with 'cap:' — structurally distinct from queryKey
194
+ // Optimistic concurrency guard — skip cache write if manifest was swapped
195
+ // mid-flight. The result was computed against a now-stale manifest and
196
+ // must not pollute the cache for the new one.
197
+ if (this.manifestVersion === manifestVersion) {
198
+ const queryKey = normalizeQuery(query);
199
+ const capKey = buildCacheKey(query, matchResult.capability.id, matchResult.extractedParams);
200
+ await this.cache.set(queryKey, matchResult);
201
+ await this.cache.set(capKey, matchResult);
202
+ // capKey always starts with 'cap:' — structurally distinct from queryKey
203
+ }
204
+ else {
205
+ logger.warn('loadManifest() called mid-flight — skipping cache write for stale result');
206
+ }
181
207
  }
182
208
  // ── Step 5b: Compute missingParams ───────────────────────────────────────
183
209
  // Spec: LLM attempts extraction first when available. missingParams is last resort.
@@ -374,6 +400,44 @@ export class CapmanEngine {
374
400
  }
375
401
  }
376
402
  }
403
+ /** Cosine similarity between two equal-length vectors */
404
+ cosineSim(a, b) {
405
+ if (a.length !== b.length || a.length === 0) {
406
+ logger.warn(`cosineSim: dimension mismatch (${a.length} vs ${b.length}) — returning 0`);
407
+ return 0;
408
+ }
409
+ let dot = 0, normA = 0, normB = 0;
410
+ for (let i = 0; i < a.length; i++) {
411
+ dot += a[i] * b[i];
412
+ normA += a[i] * a[i];
413
+ normB += b[i] * b[i];
414
+ }
415
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
416
+ return denom === 0 ? 0 : dot / denom;
417
+ }
418
+ /** Encode query and return cosine similarity scores (0–100) keyed by capability ID */
419
+ async buildEmbeddingScores(query) {
420
+ if (!this.embedding || !this.capEmbeddings)
421
+ return undefined;
422
+ // Wait for any in-flight re-encode from loadManifest() to finish.
423
+ // Without this, the first ask() after loadManifest returns uses stale embeddings.
424
+ if (this.pendingEmbedding)
425
+ await this.pendingEmbedding;
426
+ try {
427
+ const [queryVec] = await this.embedding.encode([query]);
428
+ const scores = new Map();
429
+ this.manifest.capabilities.forEach((cap, i) => {
430
+ const sim = this.cosineSim(queryVec, this.capEmbeddings[i]);
431
+ // Cosine sim is -1..1; map to 0–100, negatives floored to 0
432
+ scores.set(cap.id, Math.max(0, Math.round(sim * 100)));
433
+ });
434
+ return scores;
435
+ }
436
+ catch (err) {
437
+ logger.warn(`Embedding encode failed — skipping embedding signal: ${err instanceof Error ? err.message : String(err)}`);
438
+ return undefined;
439
+ }
440
+ }
377
441
  checkMatchHint(capability) {
378
442
  const hint = capability.matchHint?.preferredMode;
379
443
  if (!hint || hint === this.mode)
@@ -396,13 +460,31 @@ export class CapmanEngine {
396
460
  */
397
461
  async loadManifest(manifest) {
398
462
  this.checkManifestVersion(manifest);
463
+ // Assign all derived state atomically before any await — an in-flight ask()
464
+ // must never see a new manifest paired with a stale bm25Index or ceiling.
399
465
  this.manifest = manifest;
400
466
  this.bm25Index = buildBM25Index(manifest.capabilities);
401
467
  this.bm25Ceiling = this.calibrateBM25Ceiling();
402
468
  this.adaptiveMargin = this.calibrateAdaptiveMargin();
403
- // resolveBaseUrl() reads from this.manifest.servers on each call —
469
+ this.manifestVersion++;
404
470
  // server selection updates automatically after loadManifest()
405
471
  await this.clearCache();
472
+ // Re-encode capabilities after manifest swap — stale embeddings misalign with new capabilities
473
+ if (this.embedding) {
474
+ const texts = manifest.capabilities.map(c => `${c.name}: ${c.description}`);
475
+ this.pendingEmbedding = this.embedding.encode(texts).then(vecs => {
476
+ this.capEmbeddings = vecs;
477
+ this.pendingEmbedding = null;
478
+ logger.info('Capability embeddings re-encoded after manifest reload');
479
+ }).catch(err => {
480
+ this.capEmbeddings = undefined;
481
+ this.pendingEmbedding = null;
482
+ logger.warn(`EmbeddingProvider re-encode failed after loadManifest: ${err instanceof Error ? err.message : String(err)}`);
483
+ });
484
+ }
485
+ else {
486
+ this.pendingEmbedding = null;
487
+ }
406
488
  }
407
489
  /**
408
490
  * Explain what would happen for a query — without executing it.
@@ -644,13 +726,15 @@ export class CapmanEngine {
644
726
  let matchResult;
645
727
  let resolvedVia = 'keyword';
646
728
  // Fuzzy options — never applied in cheap mode
729
+ const embeddingScores = await this.buildEmbeddingScores(query);
647
730
  const fuzzyOpts = {
648
731
  fuzzyMatch: this.fuzzyMatch,
649
732
  fuzzyThreshold: this.fuzzyThreshold,
650
733
  bm25Index: this.bm25Index,
651
- bm25Ceiling: this.bm25Ceiling,
652
734
  bm25K1: this.bm25K1,
653
735
  bm25B: this.bm25B,
736
+ bm25Ceiling: this.bm25Ceiling,
737
+ embeddingScores,
654
738
  };
655
739
  switch (this.mode) {
656
740
  case 'cheap': {
@@ -673,20 +757,33 @@ export class CapmanEngine {
673
757
  else {
674
758
  const t = Date.now();
675
759
  try {
676
- matchResult = await _matchWithLLM(query, this.manifest, { llm: this.llm });
677
- this.recordLLMSuccess();
678
- resolvedVia = 'llm';
679
- // Merge keyword scores into LLM candidates so boost has real signal for alternatives
680
- const kwResult = _match(query, this.manifest, fuzzyOpts);
681
- matchResult = {
682
- ...matchResult,
683
- candidates: matchResult.candidates.map(c => ({
684
- ...c,
685
- score: c.matched
686
- ? c.score // keep LLM confidence for winner
687
- : (kwResult.candidates.find(kc => kc.capabilityId === c.capabilityId)?.score ?? 0),
688
- })),
689
- };
760
+ const kwResultAccurate = _match(query, this.manifest, fuzzyOpts);
761
+ const top3Accurate = kwResultAccurate.candidates
762
+ .sort((a, b) => b.score - a.score)
763
+ .filter(c => c.score > 0)
764
+ .slice(0, 3)
765
+ .map(c => this.manifest.capabilities.find(cap => cap.id === c.capabilityId))
766
+ .filter(Boolean);
767
+ // Skip LLM if no candidates scored above zero — no meaningful top-3 to discriminate
768
+ if (top3Accurate.length === 0) {
769
+ matchResult = kwResultAccurate;
770
+ }
771
+ else {
772
+ const llmResult = await _matchWithLLM(query, top3Accurate, { llm: this.llm, app: this.manifest.app });
773
+ this.recordLLMSuccess();
774
+ resolvedVia = 'llm';
775
+ // If LLM says OOS but keyword had a match, the correct capability may have
776
+ // been rank 4+. Fall back to keyword result rather than returning OOS.
777
+ matchResult = llmResult.capability === null ? kwResultAccurate : {
778
+ ...llmResult,
779
+ candidates: llmResult.candidates.map(c => ({
780
+ ...c,
781
+ score: c.matched
782
+ ? c.score
783
+ : (kwResultAccurate.candidates.find(kc => kc.capabilityId === c.capabilityId)?.score ?? 0),
784
+ })),
785
+ };
786
+ }
690
787
  steps?.push({ type: 'llm_match', status: 'pass', durationMs: Date.now() - t, detail: `confidence: ${matchResult.confidence}%` });
691
788
  }
692
789
  catch (err) {
@@ -731,19 +828,32 @@ export class CapmanEngine {
731
828
  logger.debug(`Query escalated to LLM: "${query}"`);
732
829
  const t2 = Date.now();
733
830
  try {
734
- matchResult = await _matchWithLLM(query, this.manifest, { llm: this.llm });
735
- this.recordLLMSuccess();
736
- resolvedVia = 'llm';
737
- // keywordResult already computed above in balanced mode — merge scores
738
- matchResult = {
739
- ...matchResult,
740
- candidates: matchResult.candidates.map(c => ({
741
- ...c,
742
- score: c.matched
743
- ? c.score
744
- : (keywordResult.candidates.find(kc => kc.capabilityId === c.capabilityId)?.score ?? 0),
745
- })),
746
- };
831
+ const top3Balanced = keywordResult.candidates
832
+ .sort((a, b) => b.score - a.score)
833
+ .filter(c => c.score > 0)
834
+ .slice(0, 3)
835
+ .map(c => this.manifest.capabilities.find(cap => cap.id === c.capabilityId))
836
+ .filter(Boolean);
837
+ // Balanced mode only escalates when keyword confidence is low but > 0 —
838
+ // top3 should always be non-empty here, but guard anyway
839
+ if (top3Balanced.length === 0) {
840
+ matchResult = keywordResult;
841
+ }
842
+ else {
843
+ const llmResult = await _matchWithLLM(query, top3Balanced, { llm: this.llm, app: this.manifest.app });
844
+ this.recordLLMSuccess();
845
+ resolvedVia = 'llm';
846
+ // If LLM returns OOS but keyword had a scored candidate, fall back to keyword
847
+ matchResult = llmResult.capability === null ? keywordResult : {
848
+ ...llmResult,
849
+ candidates: llmResult.candidates.map(c => ({
850
+ ...c,
851
+ score: c.matched
852
+ ? c.score
853
+ : (keywordResult.candidates.find(kc => kc.capabilityId === c.capabilityId)?.score ?? 0),
854
+ })),
855
+ };
856
+ }
747
857
  steps?.push({ type: 'llm_match', status: 'pass', durationMs: Date.now() - t2, detail: `confidence: ${matchResult.confidence}%` });
748
858
  }
749
859
  catch (err) {
@@ -834,7 +944,15 @@ export class CapmanEngine {
834
944
  const hits = wordIndex[candidate.capabilityId] ?? 0;
835
945
  if (hits > 0) {
836
946
  // Logarithmic boost — diminishing returns after first few hits
837
- boost += Math.min(5, Math.log2(hits + 1) * 2);
947
+ const rawBoost = Math.min(5, Math.log2(hits + 1) * 2);
948
+ // IDF weighting — common words ("get", "show", "user") appear in many
949
+ // capabilities and accumulate learning hits that carry little signal.
950
+ // Reuses BM25 df/N so no separate computation is needed.
951
+ const df = this.bm25Index.df[word] ?? 0;
952
+ const idf = df > 0
953
+ ? Math.log((this.bm25Index.N - df + 0.5) / (df + 0.5) + 1)
954
+ : 0;
955
+ boost += rawBoost * Math.min(1, idf);
838
956
  }
839
957
  }
840
958
  const cappedBoost = Math.min(15, Math.round(boost));
@@ -900,6 +1018,10 @@ export class CapmanEngine {
900
1018
  * For manifests with ≤100 capabilities this is negligible (<10ms).
901
1019
  * For very large manifests (500+ capabilities), consider passing
902
1020
  * `adaptiveMarginOverride` to skip calibration.
1021
+ *
1022
+ * Note: constructor total cost also includes BM25 index build O(capabilities × tokens)
1023
+ * and embedding pre-encoding O(capabilities) if an EmbeddingProvider is configured.
1024
+ * For 100 capabilities with embeddings, expect ~100–500ms depending on provider latency.
903
1025
  */
904
1026
  calibrateAdaptiveMargin() {
905
1027
  if (this.manifest.capabilities.length < 2)
@@ -1,6 +1,6 @@
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, ManifestInfo, Server, LifecycleInfo, LifecycleStatus, CapabilityError, Endpoint, ParamType, MatchHint, } 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, EmbeddingProvider, } 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';
@@ -10,6 +10,7 @@ export { filterByTags } from './matcher';
10
10
  export { resolve } from './resolver';
11
11
  export type { ResolveOptions, AuthContext } from './resolver';
12
12
  export { CapmanEngine } from './engine';
13
+ export { ConcurrentCapmanEngine } from './concurrent';
13
14
  export type { EngineOptions, EngineResult } from './engine';
14
15
  export { MemoryCache, FileCache, ComboCache, buildCacheKey, normalizeQuery } from './cache';
15
16
  export type { CacheStore, CacheEntry } from './cache';
package/dist/esm/index.js CHANGED
@@ -7,6 +7,7 @@ export { filterByTags } from './matcher';
7
7
  export { resolve } from './resolver';
8
8
  // ─── Engine (recommended API) ─────────────────────────────────────────────────
9
9
  export { CapmanEngine } from './engine';
10
+ export { ConcurrentCapmanEngine } from './concurrent';
10
11
  // ─── Cache ────────────────────────────────────────────────────────────────────
11
12
  export { MemoryCache, FileCache, ComboCache, buildCacheKey, normalizeQuery } from './cache';
12
13
  // ─── Learning ─────────────────────────────────────────────────────────────────
@@ -7,12 +7,19 @@ export interface LearningEntry {
7
7
  resolvedVia: 'keyword' | 'llm' | 'cache';
8
8
  timestamp: string;
9
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
- */
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
15
  weight?: number;
16
+ /**
17
+ * Unix timestamp (ms) when this entry was last updated.
18
+ * Used for time-decay — older entries contribute less learning signal.
19
+ * Optional for backwards-compatibility with persisted entries written before v0.7.0.
20
+ * Migration: FileLearningStore falls back to file mtime for entries missing this field.
21
+ */
22
+ lastUpdated?: number;
16
23
  }
17
24
  export interface KeywordStats {
18
25
  /** keyword → Map of capabilityId → hit count */
@@ -50,7 +57,7 @@ export declare class FileLearningStore implements LearningStore {
50
57
  private learningIndex;
51
58
  private dirty;
52
59
  private saveTimer;
53
- constructor(filePath?: string);
60
+ constructor(filePath?: string, halfLifeDays?: number);
54
61
  flushSync(): void;
55
62
  /**
56
63
  * Removes this store from the exit flush registry and cancels any pending save timer.
@@ -74,6 +81,7 @@ export declare class FileLearningStore implements LearningStore {
74
81
  export declare class MemoryLearningStore implements LearningStore {
75
82
  private entries;
76
83
  private learningIndex;
84
+ constructor(halfLifeDays?: number);
77
85
  record(entry: LearningEntry): Promise<void>;
78
86
  getStats(): Promise<KeywordStats>;
79
87
  getIndex(): Promise<Record<string, Record<string, number>>>;
@@ -3,6 +3,15 @@ import * as path from 'path';
3
3
  import { logger } from './logger';
4
4
  const MAX_LEARNING_ENTRIES = 10_000;
5
5
  import { tokenize } from './matcher';
6
+ /**
7
+ * Exponential decay — older entries contribute less signal.
8
+ * At exactly halfLifeDays old, a weight of 1.0 decays to 0.5.
9
+ * At 2× halfLifeDays, it decays to 0.25. And so on.
10
+ */
11
+ function decayedWeight(weight, lastUpdated, halfLifeDays) {
12
+ const ageDays = (Date.now() - lastUpdated) / (1000 * 60 * 60 * 24);
13
+ return weight * Math.pow(0.5, ageDays / halfLifeDays);
14
+ }
6
15
  // Module-level registry — tracks all active FileLearningStore instances
7
16
  // for process exit flushing. Handlers registered once to avoid accumulation.
8
17
  const activeStores = new Set();
@@ -56,11 +65,18 @@ function computeTopCapabilities(entries, limit) {
56
65
  // Both FileLearningStore and MemoryLearningStore compose this instead of
57
66
  // duplicating the same ~80 lines of index management logic.
58
67
  class LearningIndex {
59
- constructor() {
68
+ constructor(halfLifeDays = 30) {
60
69
  this.index = {};
70
+ /** Tracks when each (word, capabilityId) cell was last reinforced — used for decay */
71
+ this.lastUpdatedIndex = {};
61
72
  this.statsCounter = {
62
73
  totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0,
63
74
  };
75
+ if (halfLifeDays <= 0) {
76
+ throw new RangeError(`halfLifeDays must be a positive number — got ${halfLifeDays}. ` +
77
+ `Use a value in days e.g. 30 (1 month), 7 (1 week).`);
78
+ }
79
+ this.halfLifeDays = halfLifeDays;
64
80
  }
65
81
  update(entry) {
66
82
  this.statsCounter.totalQueries++;
@@ -75,15 +91,21 @@ class LearningIndex {
75
91
  // more signal than a 51% borderline match. Floor of 0.1 ensures
76
92
  // borderline matches still contribute, just proportionally less.
77
93
  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.
94
+ // Respect a caller-supplied timestamp (historical replay, rebuild()).
95
+ // For brand-new real-time entries lastUpdated is undefined default to now.
96
+ const now = entry.lastUpdated ?? Date.now();
97
+ // Store weight and timestamp on the entry so subtract() can reverse the
98
+ // exact amount and migration has an accurate record time.
81
99
  entry.weight = weight;
100
+ entry.lastUpdated = now;
82
101
  const words = tokenize(entry.query);
83
102
  for (const word of words) {
84
103
  this.index[word] ??= {};
85
104
  this.index[word][entry.capabilityId] =
86
105
  (this.index[word][entry.capabilityId] ?? 0) + weight;
106
+ // Track when this (word, cap) cell was last reinforced for decay
107
+ this.lastUpdatedIndex[word] ??= {};
108
+ this.lastUpdatedIndex[word][entry.capabilityId] = now;
87
109
  }
88
110
  }
89
111
  }
@@ -111,9 +133,11 @@ class LearningIndex {
111
133
  (this.index[word][entry.capabilityId] ?? weight) - weight;
112
134
  if (this.index[word][entry.capabilityId] <= 0) {
113
135
  delete this.index[word][entry.capabilityId];
136
+ delete this.lastUpdatedIndex[word]?.[entry.capabilityId];
114
137
  }
115
138
  if (Object.keys(this.index[word]).length === 0) {
116
139
  delete this.index[word];
140
+ delete this.lastUpdatedIndex[word];
117
141
  }
118
142
  }
119
143
  }
@@ -126,10 +150,25 @@ class LearningIndex {
126
150
  }
127
151
  reset() {
128
152
  this.index = {};
153
+ this.lastUpdatedIndex = {};
129
154
  this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
130
155
  }
131
156
  getStats() {
132
- return { ...this.statsCounter, index: structuredClone(this.index) };
157
+ // Apply time-decay lazily on read. The index stores accumulated weights;
158
+ // each (word, capId) cell is decayed by how long ago it was last reinforced.
159
+ // This means recently-used capabilities retain full signal while stale ones fade.
160
+ const decayed = {};
161
+ for (const [word, capMap] of Object.entries(this.index)) {
162
+ for (const [capId, weight] of Object.entries(capMap)) {
163
+ const lastUpdated = this.lastUpdatedIndex[word]?.[capId] ?? Date.now();
164
+ const dw = decayedWeight(weight, lastUpdated, this.halfLifeDays);
165
+ if (dw > 0.001) { // drop negligible signal — avoids ghost entries
166
+ decayed[word] ??= {};
167
+ decayed[word][capId] = dw;
168
+ }
169
+ }
170
+ }
171
+ return { ...this.statsCounter, index: decayed };
133
172
  }
134
173
  getIndex() {
135
174
  return structuredClone(this.index);
@@ -137,13 +176,13 @@ class LearningIndex {
137
176
  }
138
177
  // ─── File Learning Store ──────────────────────────────────────────────────────
139
178
  export class FileLearningStore {
140
- constructor(filePath = '.capman/learning.json') {
179
+ constructor(filePath = '.capman/learning.json', halfLifeDays = 30) {
141
180
  this.entries = [];
142
181
  this.loadPromise = null;
143
182
  this.saveQueue = Promise.resolve();
144
- this.learningIndex = new LearningIndex();
145
183
  this.dirty = false;
146
184
  this.saveTimer = null;
185
+ this.learningIndex = new LearningIndex(halfLifeDays);
147
186
  const cwd = process.cwd();
148
187
  const resolved = path.resolve(cwd, filePath);
149
188
  const allowedPrefix = cwd === '/' ? '/' : cwd + path.sep;
@@ -207,6 +246,17 @@ export class FileLearningStore {
207
246
  }
208
247
  async _doLoad() {
209
248
  try {
249
+ // Fetch mtime once — used as lastUpdated fallback for pre-v0.7.0 entries.
250
+ // Conservative: treats all old entries as "last updated when file was written"
251
+ // rather than "infinitely old", preventing a cliff-edge decay on first upgrade.
252
+ let fileMtimeMs = Date.now();
253
+ try {
254
+ const stat = await fs.promises.stat(this.filePath);
255
+ fileMtimeMs = stat.mtimeMs;
256
+ }
257
+ catch {
258
+ // File doesn't exist yet or stat failed — Date.now() fallback is safe
259
+ }
210
260
  const raw = await fs.promises.readFile(this.filePath, 'utf-8');
211
261
  const parsed = JSON.parse(raw);
212
262
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Array.isArray(parsed.entries)) {
@@ -220,7 +270,11 @@ export class FileLearningStore {
220
270
  (entry.capabilityId === null || typeof entry.capabilityId === 'string') &&
221
271
  typeof entry.confidence === 'number' &&
222
272
  typeof entry.resolvedVia === 'string') {
223
- validEntries.push(entry);
273
+ // Migration guard: backfill lastUpdated for pre-v0.7.0 entries
274
+ validEntries.push({
275
+ ...entry,
276
+ lastUpdated: entry.lastUpdated ?? fileMtimeMs,
277
+ });
224
278
  }
225
279
  else {
226
280
  skipped++;
@@ -326,9 +380,9 @@ export class FileLearningStore {
326
380
  }
327
381
  // ─── Memory Learning Store (for testing) ─────────────────────────────────────
328
382
  export class MemoryLearningStore {
329
- constructor() {
383
+ constructor(halfLifeDays = 30) {
330
384
  this.entries = [];
331
- this.learningIndex = new LearningIndex();
385
+ this.learningIndex = new LearningIndex(halfLifeDays);
332
386
  }
333
387
  async record(entry) {
334
388
  const sanitized = {
@@ -59,6 +59,14 @@ export declare function scoreCapability(qWordSet: Set<string>, cap: Capability,
59
59
  * Input must already be post-stopword and post-stem (use tokenize() first).
60
60
  */
61
61
  export declare function extractBigrams(tokens: string[]): Set<string>;
62
+ /**
63
+ * Reciprocal Rank Fusion — fuses multiple ranked lists into a single score map.
64
+ * k=60 is the standard literature default.
65
+ */
66
+ export declare function rrf(rankings: Array<Array<{
67
+ id: string;
68
+ score: number;
69
+ }>>, k?: number): Map<string, number>;
62
70
  /**
63
71
  * Returns a sub-manifest containing only capabilities that match ALL provided tags.
64
72
  * Capabilities without tags are excluded when tags filter is active.
@@ -108,6 +116,8 @@ export interface MatchOptions {
108
116
  bm25K1?: number;
109
117
  bm25B?: number;
110
118
  bm25Ceiling?: number;
119
+ /** Pre-computed cosine similarity scores keyed by capability ID (0–100). Engine passes these when an EmbeddingProvider is configured. */
120
+ embeddingScores?: Map<string, number>;
111
121
  }
112
122
  /**
113
123
  * Calibrates a BM25 normalization ceiling from the manifest.
@@ -117,6 +127,8 @@ export interface MatchOptions {
117
127
  export declare function calibrateCeiling(capabilities: Capability[], bm25Index: BM25Index, k1: number, b: number): number;
118
128
  export declare function match(query: string, manifest: Manifest, options?: MatchOptions): MatchResult;
119
129
  export interface LLMMatcherOptions {
130
+ /** App name for prompt context — passed from engine, optional for direct callers */
131
+ app?: string;
120
132
  llm: (prompt: string) => Promise<string>;
121
133
  }
122
134
  /**
@@ -130,4 +142,4 @@ export interface LLMMatcherOptions {
130
142
  * wrapper that maps the prompt to a proper system message, keeping user query
131
143
  * data in the user turn only.
132
144
  */
133
- export declare function matchWithLLM(query: string, manifest: Manifest, options: LLMMatcherOptions): Promise<MatchResult>;
145
+ export declare function matchWithLLM(query: string, topCandidates: Capability[], options: LLMMatcherOptions): Promise<MatchResult>;