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.
- package/CODEBASE.md +6 -5
- package/dist/cjs/concurrent.d.ts +53 -0
- package/dist/cjs/concurrent.d.ts.map +1 -0
- package/dist/cjs/concurrent.js +71 -0
- package/dist/cjs/concurrent.js.map +1 -0
- package/dist/cjs/engine.d.ts +82 -12
- package/dist/cjs/engine.d.ts.map +1 -1
- package/dist/cjs/engine.js +159 -37
- package/dist/cjs/engine.js.map +1 -1
- package/dist/cjs/index.d.ts +2 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/learning.d.ts +14 -6
- package/dist/cjs/learning.d.ts.map +1 -1
- package/dist/cjs/learning.js +64 -10
- package/dist/cjs/learning.js.map +1 -1
- package/dist/cjs/matcher.d.ts +13 -1
- package/dist/cjs/matcher.d.ts.map +1 -1
- package/dist/cjs/matcher.js +67 -10
- package/dist/cjs/matcher.js.map +1 -1
- package/dist/cjs/schema.js +1 -1
- package/dist/cjs/schema.js.map +1 -1
- package/dist/cjs/types.d.ts +9 -0
- package/dist/cjs/types.d.ts.map +1 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/concurrent.d.ts +52 -0
- package/dist/esm/concurrent.js +66 -0
- package/dist/esm/engine.d.ts +82 -12
- package/dist/esm/engine.js +159 -37
- package/dist/esm/index.d.ts +2 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/learning.d.ts +14 -6
- package/dist/esm/learning.js +64 -10
- package/dist/esm/matcher.d.ts +13 -1
- package/dist/esm/matcher.js +66 -10
- package/dist/esm/schema.js +1 -1
- package/dist/esm/types.d.ts +9 -0
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/dist/esm/engine.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
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)
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -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 ─────────────────────────────────────────────────────────────────
|
package/dist/esm/learning.d.ts
CHANGED
|
@@ -7,12 +7,19 @@ export interface LearningEntry {
|
|
|
7
7
|
resolvedVia: 'keyword' | 'llm' | 'cache';
|
|
8
8
|
timestamp: string;
|
|
9
9
|
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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>>>;
|
package/dist/esm/learning.js
CHANGED
|
@@ -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
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
package/dist/esm/matcher.d.ts
CHANGED
|
@@ -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,
|
|
145
|
+
export declare function matchWithLLM(query: string, topCandidates: Capability[], options: LLMMatcherOptions): Promise<MatchResult>;
|