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.
- package/CHANGELOG.md +61 -0
- package/CODEBASE.md +115 -65
- package/README.md +45 -4
- package/bin/lib/cmd-explain.js +2 -2
- package/bin/lib/cmd-generate.js +44 -28
- package/bin/lib/cmd-run.js +2 -2
- package/bin/lib/shared.js +8 -2
- package/dist/cjs/cache.d.ts.map +1 -1
- package/dist/cjs/cache.js +22 -5
- package/dist/cjs/cache.js.map +1 -1
- package/dist/cjs/engine.d.ts +30 -0
- package/dist/cjs/engine.d.ts.map +1 -1
- package/dist/cjs/engine.js +87 -36
- package/dist/cjs/engine.js.map +1 -1
- package/dist/cjs/generator.d.ts.map +1 -1
- package/dist/cjs/generator.js +7 -1
- package/dist/cjs/generator.js.map +1 -1
- package/dist/cjs/learning.d.ts.map +1 -1
- package/dist/cjs/learning.js +39 -12
- package/dist/cjs/learning.js.map +1 -1
- package/dist/cjs/matcher.d.ts +18 -10
- package/dist/cjs/matcher.d.ts.map +1 -1
- package/dist/cjs/matcher.js +140 -29
- package/dist/cjs/matcher.js.map +1 -1
- package/dist/cjs/parser.d.ts.map +1 -1
- package/dist/cjs/parser.js +15 -8
- package/dist/cjs/parser.js.map +1 -1
- package/dist/cjs/resolver.d.ts +1 -0
- package/dist/cjs/resolver.d.ts.map +1 -1
- package/dist/cjs/resolver.js +16 -5
- package/dist/cjs/resolver.js.map +1 -1
- package/dist/cjs/schema.d.ts +18 -18
- package/dist/cjs/schema.js +1 -1
- package/dist/cjs/schema.js.map +1 -1
- package/dist/cjs/types.d.ts +1 -1
- 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/cache.js +22 -5
- package/dist/esm/engine.d.ts +30 -0
- package/dist/esm/engine.js +89 -38
- package/dist/esm/generator.js +7 -1
- package/dist/esm/learning.js +39 -12
- package/dist/esm/matcher.d.ts +18 -10
- package/dist/esm/matcher.js +137 -29
- package/dist/esm/parser.js +15 -8
- package/dist/esm/resolver.d.ts +1 -0
- package/dist/esm/resolver.js +16 -6
- package/dist/esm/schema.d.ts +18 -18
- package/dist/esm/schema.js +1 -1
- package/dist/esm/types.d.ts +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +11 -10
package/dist/esm/engine.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { match as _match, matchWithLLM as _matchWithLLM, resolverToIntent, extractParams, STOPWORDS, LLMParseError } from './matcher';
|
|
2
|
-
import { resolve as _resolve } from './resolver';
|
|
2
|
+
import { resolve as _resolve, checkPrivacy } from './resolver';
|
|
3
3
|
import { MemoryLearningStore } from './learning';
|
|
4
4
|
import { logger } from './logger';
|
|
5
|
-
import { MemoryCache, normalizeQuery } from './cache';
|
|
5
|
+
import { MemoryCache, normalizeQuery, buildCacheKey } from './cache';
|
|
6
6
|
import { VERSION } from './version';
|
|
7
7
|
// ─── CapmanEngine ─────────────────────────────────────────────────────────────
|
|
8
8
|
export class CapmanEngine {
|
|
@@ -25,6 +25,8 @@ export class CapmanEngine {
|
|
|
25
25
|
this.llmCooldownMs = options.llmCooldownMs ?? 0;
|
|
26
26
|
this.llmCircuitBreakerThreshold = options.llmCircuitBreakerThreshold ?? 3;
|
|
27
27
|
this.llmCircuitBreakerResetMs = options.llmCircuitBreakerResetMs ?? 60_000;
|
|
28
|
+
this.fuzzyMatch = options.fuzzyMatch ?? false;
|
|
29
|
+
this.fuzzyThreshold = options.fuzzyThreshold ?? 0.4;
|
|
28
30
|
// Cache — default MemoryCache (no filesystem writes), or disabled with false
|
|
29
31
|
// Use FileCache or ComboCache explicitly for persistence across restarts
|
|
30
32
|
this.cache = options.cache === false
|
|
@@ -37,23 +39,7 @@ export class CapmanEngine {
|
|
|
37
39
|
: (options.learning ?? new MemoryLearningStore());
|
|
38
40
|
logger.info(`CapmanEngine initialized — mode: ${this.mode}, cache: ${this.cache ? 'enabled' : 'disabled'}, learning: ${this.learning ? 'enabled' : 'disabled'}`);
|
|
39
41
|
// ── Manifest version compatibility check ─────────────────────────────────
|
|
40
|
-
|
|
41
|
-
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
42
|
-
if (SEMVER_RE.test(options.manifest.version) && SEMVER_RE.test(VERSION)) {
|
|
43
|
-
const [mMaj, mMin] = options.manifest.version.split('.').map(Number);
|
|
44
|
-
const [eMaj, eMin] = VERSION.split('.').map(Number);
|
|
45
|
-
if (mMaj !== eMaj || mMin !== eMin) {
|
|
46
|
-
console.warn(`[capman] Manifest version "${options.manifest.version}" was generated with a ` +
|
|
47
|
-
`different engine version than "${VERSION}". This is usually fine across patch versions. ` +
|
|
48
|
-
`If you experience unexpected matching issues, regenerate with: npx capman generate`);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
else if (options.manifest.version !== VERSION) {
|
|
52
|
-
//console.warn is used instead of logger.warn to avoid the warning being logged to the console
|
|
53
|
-
console.warn(`[capman] Manifest version "${options.manifest.version}" could not be compared ` +
|
|
54
|
-
`to engine version "${VERSION}" — version strings are not valid semver.`);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
42
|
+
this.checkManifestVersion(options.manifest);
|
|
57
43
|
}
|
|
58
44
|
/**
|
|
59
45
|
* Ask the engine a natural language query.
|
|
@@ -121,17 +107,20 @@ export class CapmanEngine {
|
|
|
121
107
|
}
|
|
122
108
|
// ── Step 2: Match ────────────────────────────────────────────────────────
|
|
123
109
|
let { matchResult, resolvedVia } = await this._runMatch(query, steps);
|
|
124
|
-
|
|
110
|
+
// Shallow copy with candidates slice — not a reference alias.
|
|
111
|
+
// applyBoostToMatchResult() returns a new object today, but an explicit
|
|
112
|
+
// copy makes the invariant clear and safe against future in-place mutation.
|
|
113
|
+
const preBoostMatchResult = { ...matchResult, candidates: matchResult.candidates.slice() };
|
|
125
114
|
// ── Step 2.5: Apply learning boost ───────────────────────────────────────
|
|
126
|
-
matchResult = await this.applyBoostToMatchResult(query, matchResult);
|
|
115
|
+
matchResult = await this.applyBoostToMatchResult(query, matchResult, resolvedVia);
|
|
127
116
|
// ── Step 3: Privacy check ────────────────────────────────────────────────
|
|
128
117
|
if (matchResult.capability) {
|
|
129
|
-
const
|
|
118
|
+
const privacyError = checkPrivacy(matchResult.capability, this.auth);
|
|
130
119
|
steps.push({
|
|
131
120
|
type: 'privacy_check',
|
|
132
|
-
status: 'pass',
|
|
121
|
+
status: privacyError ? 'fail' : 'pass',
|
|
133
122
|
durationMs: 0,
|
|
134
|
-
detail: `level: ${
|
|
123
|
+
detail: privacyError ?? `level: ${matchResult.capability.privacy.level}`,
|
|
135
124
|
});
|
|
136
125
|
}
|
|
137
126
|
// ── Step 4: Resolve ──────────────────────────────────────────────────────
|
|
@@ -144,14 +133,17 @@ export class CapmanEngine {
|
|
|
144
133
|
detail: resolution.error ?? `via ${resolution.resolverType}`,
|
|
145
134
|
});
|
|
146
135
|
// ── Step 5: Cache after successful resolution ────────────────────────────
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
136
|
+
// Write under two keys:
|
|
137
|
+
// 1. normalizeQuery — exact phrasing lookup for this query
|
|
138
|
+
// 2. buildCacheKey — semantic key (capability + params) so differently-phrased
|
|
139
|
+
// queries that resolve to the same capability share a cache entry
|
|
151
140
|
if (this.cache && resolution.success && matchResult.capability
|
|
152
141
|
&& matchResult.capability.privacy.level === 'public') {
|
|
153
142
|
const queryKey = normalizeQuery(query);
|
|
143
|
+
const capKey = buildCacheKey(query, matchResult.capability.id, matchResult.extractedParams);
|
|
154
144
|
await this.cache.set(queryKey, matchResult);
|
|
145
|
+
await this.cache.set(capKey, matchResult);
|
|
146
|
+
// capKey always starts with 'cap:' — structurally distinct from queryKey
|
|
155
147
|
}
|
|
156
148
|
// ── Step 6: Build reasoning array ────────────────────────────────────────
|
|
157
149
|
const reasoning = [];
|
|
@@ -223,6 +215,45 @@ export class CapmanEngine {
|
|
|
223
215
|
if (this.cache)
|
|
224
216
|
await this.cache.clear();
|
|
225
217
|
}
|
|
218
|
+
checkManifestVersion(manifest) {
|
|
219
|
+
if (!manifest.version)
|
|
220
|
+
return;
|
|
221
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
222
|
+
if (SEMVER_RE.test(manifest.version) && SEMVER_RE.test(VERSION)) {
|
|
223
|
+
const [mMaj, mMin] = manifest.version.split('.').map(Number);
|
|
224
|
+
const [eMaj, eMin] = VERSION.split('.').map(Number);
|
|
225
|
+
if (mMaj !== eMaj || mMin !== eMin) {
|
|
226
|
+
console.warn(`[capman] Manifest version "${manifest.version}" was generated with a ` +
|
|
227
|
+
`different engine version than "${VERSION}". This is usually fine across patch versions. ` +
|
|
228
|
+
`If you experience unexpected matching issues, regenerate with: npx capman generate`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
else if (manifest.version !== VERSION) {
|
|
232
|
+
console.warn(`[capman] Manifest version "${manifest.version}" could not be compared ` +
|
|
233
|
+
`to engine version "${VERSION}" — version strings are not valid semver.`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Replaces the active manifest without creating a new engine instance.
|
|
238
|
+
* Useful for hot-reloading manifests in long-running servers without
|
|
239
|
+
* losing cache, learning history, or rate limiter state.
|
|
240
|
+
*
|
|
241
|
+
* Note: clears the cache automatically — cached results from the old
|
|
242
|
+
* manifest are no longer valid after the manifest changes.
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* const newManifest = generate(updatedConfig)
|
|
246
|
+
* await engine.loadManifest(newManifest)
|
|
247
|
+
*/
|
|
248
|
+
async loadManifest(manifest) {
|
|
249
|
+
this.checkManifestVersion(manifest);
|
|
250
|
+
this.manifest = manifest;
|
|
251
|
+
await this.clearCache();
|
|
252
|
+
// Note: LLM rate limiter state (llmCallsThisMinute, llmConsecutiveFails,
|
|
253
|
+
// llmCircuitOpenAt) is intentionally preserved across manifest reloads.
|
|
254
|
+
// The LLM provider has not changed, so circuit breaker state remains valid.
|
|
255
|
+
// If you need a clean rate limiter state, create a new CapmanEngine instance.
|
|
256
|
+
}
|
|
226
257
|
/**
|
|
227
258
|
* Explain what would happen for a query — without executing it.
|
|
228
259
|
* Shows matched capability, all candidate scores with reasoning,
|
|
@@ -258,8 +289,9 @@ export class CapmanEngine {
|
|
|
258
289
|
}
|
|
259
290
|
let resolvedVia = _resolvedVia;
|
|
260
291
|
// ── Apply learning boost (same as ask()) ─────────────────────────────────
|
|
261
|
-
matchResult = await this.applyBoostToMatchResult(query, matchResult);
|
|
292
|
+
matchResult = await this.applyBoostToMatchResult(query, matchResult, resolvedVia);
|
|
262
293
|
// ── Build candidate explanations ─────────────────────────────────────────
|
|
294
|
+
const qWordSet = new Set(query.toLowerCase().split(/\W+/).filter(Boolean));
|
|
263
295
|
const candidates = matchResult.candidates
|
|
264
296
|
.sort((a, b) => b.score - a.score)
|
|
265
297
|
.map(c => {
|
|
@@ -272,10 +304,9 @@ export class CapmanEngine {
|
|
|
272
304
|
explanation = `Strong match (${c.score}%) — query closely matches examples`;
|
|
273
305
|
}
|
|
274
306
|
else if (c.score >= 50) {
|
|
275
|
-
const qWords = query.toLowerCase().split(/\W+/).filter(Boolean);
|
|
276
307
|
const matchedWords = (cap?.examples ?? [])
|
|
277
308
|
.flatMap(e => e.toLowerCase().split(/\s+/))
|
|
278
|
-
.filter(w =>
|
|
309
|
+
.filter(w => qWordSet.has(w) && w.length > 2);
|
|
279
310
|
const unique = [...new Set(matchedWords)].slice(0, 3);
|
|
280
311
|
explanation = unique.length
|
|
281
312
|
? `Matched keywords: ${unique.join(', ')} (${c.score}%)`
|
|
@@ -421,8 +452,10 @@ export class CapmanEngine {
|
|
|
421
452
|
this.llmCallsThisMinute = 0;
|
|
422
453
|
this.llmWindowStart = now;
|
|
423
454
|
}
|
|
455
|
+
if (this.maxLLMCallsPerMinute === 0) {
|
|
456
|
+
return 'LLM disabled — maxLLMCallsPerMinute is 0';
|
|
457
|
+
}
|
|
424
458
|
if (this.llmCallsThisMinute >= this.maxLLMCallsPerMinute) {
|
|
425
|
-
// Recalculate elapsed after possible window reset above
|
|
426
459
|
const resetIn = Math.ceil((60_000 - (now - this.llmWindowStart)) / 1000);
|
|
427
460
|
return `rate limit reached (${this.maxLLMCallsPerMinute}/min) — resets in ${Math.max(0, resetIn)}s`;
|
|
428
461
|
}
|
|
@@ -441,6 +474,10 @@ export class CapmanEngine {
|
|
|
441
474
|
* Records a failed LLM call — may open the circuit breaker.
|
|
442
475
|
*/
|
|
443
476
|
recordLLMFailure() {
|
|
477
|
+
// Refund the rate-limit slot — the call failed so it shouldn't count
|
|
478
|
+
// against the per-minute quota. Without this, sustained failures
|
|
479
|
+
// exhaust the limit prematurely and silently degrade to keyword-only.
|
|
480
|
+
this.llmCallsThisMinute = Math.max(0, this.llmCallsThisMinute - 1);
|
|
444
481
|
this.llmConsecutiveFails++;
|
|
445
482
|
if (this.llmConsecutiveFails >= this.llmCircuitBreakerThreshold) {
|
|
446
483
|
this.llmCircuitOpenAt = Date.now();
|
|
@@ -455,6 +492,11 @@ export class CapmanEngine {
|
|
|
455
492
|
async _runMatch(query, steps) {
|
|
456
493
|
let matchResult;
|
|
457
494
|
let resolvedVia = 'keyword';
|
|
495
|
+
// Fuzzy options — never applied in cheap mode
|
|
496
|
+
const fuzzyOpts = {
|
|
497
|
+
fuzzyMatch: this.fuzzyMatch,
|
|
498
|
+
fuzzyThreshold: this.fuzzyThreshold,
|
|
499
|
+
};
|
|
458
500
|
switch (this.mode) {
|
|
459
501
|
case 'cheap': {
|
|
460
502
|
const t = Date.now();
|
|
@@ -464,11 +506,13 @@ export class CapmanEngine {
|
|
|
464
506
|
}
|
|
465
507
|
case 'accurate': {
|
|
466
508
|
if (this.llm) {
|
|
509
|
+
// Rate limiter shared between ask() and explain() — explain() counts
|
|
510
|
+
// against the same quota since it makes real LLM calls.
|
|
467
511
|
const skipReason = this.checkLLMAllowed();
|
|
468
512
|
if (skipReason) {
|
|
469
513
|
logger.warn(`LLM skipped — ${skipReason} — falling back to keyword`);
|
|
470
514
|
const t = Date.now();
|
|
471
|
-
matchResult = _match(query, this.manifest);
|
|
515
|
+
matchResult = _match(query, this.manifest, fuzzyOpts);
|
|
472
516
|
steps?.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t, detail: `llm skipped: ${skipReason}` });
|
|
473
517
|
}
|
|
474
518
|
else {
|
|
@@ -478,7 +522,7 @@ export class CapmanEngine {
|
|
|
478
522
|
this.recordLLMSuccess();
|
|
479
523
|
resolvedVia = 'llm';
|
|
480
524
|
// Merge keyword scores into LLM candidates so boost has real signal for alternatives
|
|
481
|
-
const kwResult = _match(query, this.manifest);
|
|
525
|
+
const kwResult = _match(query, this.manifest, fuzzyOpts);
|
|
482
526
|
matchResult = {
|
|
483
527
|
...matchResult,
|
|
484
528
|
candidates: matchResult.candidates.map(c => ({
|
|
@@ -496,7 +540,7 @@ export class CapmanEngine {
|
|
|
496
540
|
this.recordLLMFailure();
|
|
497
541
|
logger.warn(`LLM call failed — falling back to keyword: ${err instanceof Error ? err.message : String(err)}`);
|
|
498
542
|
const t2 = Date.now();
|
|
499
|
-
matchResult = _match(query, this.manifest);
|
|
543
|
+
matchResult = _match(query, this.manifest, fuzzyOpts);
|
|
500
544
|
steps?.push({ type: 'llm_match', status: 'fail', durationMs: Date.now() - t, detail: String(err) });
|
|
501
545
|
steps?.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t2, detail: 'fallback after llm failure' });
|
|
502
546
|
}
|
|
@@ -505,7 +549,7 @@ export class CapmanEngine {
|
|
|
505
549
|
else {
|
|
506
550
|
logger.warn('accurate mode requires llm — falling back to keyword');
|
|
507
551
|
const t = Date.now();
|
|
508
|
-
matchResult = _match(query, this.manifest);
|
|
552
|
+
matchResult = _match(query, this.manifest, fuzzyOpts);
|
|
509
553
|
steps?.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t, detail: 'llm not provided, used keyword' });
|
|
510
554
|
}
|
|
511
555
|
break;
|
|
@@ -513,12 +557,14 @@ export class CapmanEngine {
|
|
|
513
557
|
case 'balanced':
|
|
514
558
|
default: {
|
|
515
559
|
const t1 = Date.now();
|
|
516
|
-
const keywordResult = _match(query, this.manifest);
|
|
560
|
+
const keywordResult = _match(query, this.manifest, fuzzyOpts);
|
|
517
561
|
steps?.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t1, detail: `confidence: ${keywordResult.confidence}%` });
|
|
518
562
|
if (keywordResult.confidence >= this.threshold || !this.llm) {
|
|
519
563
|
matchResult = keywordResult;
|
|
520
564
|
}
|
|
521
565
|
else {
|
|
566
|
+
// Rate limiter shared between ask() and explain() — explain() counts
|
|
567
|
+
// against the same quota since it makes real LLM calls.
|
|
522
568
|
const skipReason = this.checkLLMAllowed();
|
|
523
569
|
if (skipReason) {
|
|
524
570
|
logger.warn(`LLM skipped — ${skipReason}`);
|
|
@@ -564,7 +610,12 @@ export class CapmanEngine {
|
|
|
564
610
|
* Applies learning boost to a MatchResult and returns the updated result.
|
|
565
611
|
* Shared by ask() and explain() to avoid logic divergence.
|
|
566
612
|
*/
|
|
567
|
-
async applyBoostToMatchResult(query, matchResult) {
|
|
613
|
+
async applyBoostToMatchResult(query, matchResult, resolvedVia = 'keyword') {
|
|
614
|
+
// Skip boost when LLM matched with high confidence — learning signal is
|
|
615
|
+
// less reliable than a strong LLM result and could incorrectly override it.
|
|
616
|
+
// Threshold 80% leaves room for boost to help on borderline LLM matches.
|
|
617
|
+
if (resolvedVia === 'llm' && matchResult.confidence > 80)
|
|
618
|
+
return matchResult;
|
|
568
619
|
const hasKeywordSignal = matchResult.candidates.some(c => c.score > 0);
|
|
569
620
|
if (!hasKeywordSignal || matchResult.candidates.length === 0 || !this.learning || this.mode === 'cheap') {
|
|
570
621
|
return matchResult;
|
package/dist/esm/generator.js
CHANGED
|
@@ -73,7 +73,13 @@ export function loadConfig(configPath) {
|
|
|
73
73
|
`Run: node bin/capman.js init to create one.`);
|
|
74
74
|
}
|
|
75
75
|
export function writeManifest(manifest, outputPath = 'manifest.json') {
|
|
76
|
-
const
|
|
76
|
+
const cwd = process.cwd();
|
|
77
|
+
const resolved = path.resolve(cwd, outputPath);
|
|
78
|
+
const allowedPrefix = cwd === '/' ? '/' : cwd + path.sep;
|
|
79
|
+
if (!resolved.startsWith(allowedPrefix)) {
|
|
80
|
+
throw new Error(`writeManifest: output path "${outputPath}" resolves outside the working directory.\n` +
|
|
81
|
+
`Resolved: ${resolved}\nAllowed: ${cwd}`);
|
|
82
|
+
}
|
|
77
83
|
fs.writeFileSync(resolved, JSON.stringify(manifest, null, 2));
|
|
78
84
|
return resolved;
|
|
79
85
|
}
|
package/dist/esm/learning.js
CHANGED
|
@@ -6,12 +6,38 @@ import { STOPWORDS } from './matcher';
|
|
|
6
6
|
// Module-level registry — tracks all active FileLearningStore instances
|
|
7
7
|
// for process exit flushing. Handlers registered once to avoid accumulation.
|
|
8
8
|
const activeStores = new Set();
|
|
9
|
-
|
|
9
|
+
// Module-level handler references — stored so they can be removed
|
|
10
|
+
// when all stores are destroyed. Never call process.exit() in a library.
|
|
11
|
+
let exitHandler = null;
|
|
12
|
+
let sigTermHandler = null;
|
|
13
|
+
let sigIntHandler = null;
|
|
10
14
|
function flushAllStores() {
|
|
11
15
|
for (const store of activeStores) {
|
|
12
16
|
store.flushSync();
|
|
13
17
|
}
|
|
14
18
|
}
|
|
19
|
+
function registerExitHandlers() {
|
|
20
|
+
if (exitHandler)
|
|
21
|
+
return; // already registered
|
|
22
|
+
exitHandler = flushAllStores;
|
|
23
|
+
sigTermHandler = flushAllStores;
|
|
24
|
+
sigIntHandler = flushAllStores;
|
|
25
|
+
process.on('exit', exitHandler);
|
|
26
|
+
process.on('SIGTERM', sigTermHandler);
|
|
27
|
+
process.on('SIGINT', sigIntHandler);
|
|
28
|
+
}
|
|
29
|
+
function unregisterExitHandlers() {
|
|
30
|
+
if (!exitHandler)
|
|
31
|
+
return; // nothing registered
|
|
32
|
+
if (activeStores.size > 0)
|
|
33
|
+
return; // other stores still active
|
|
34
|
+
process.off('exit', exitHandler);
|
|
35
|
+
process.off('SIGTERM', sigTermHandler);
|
|
36
|
+
process.off('SIGINT', sigIntHandler);
|
|
37
|
+
exitHandler = null;
|
|
38
|
+
sigTermHandler = null;
|
|
39
|
+
sigIntHandler = null;
|
|
40
|
+
}
|
|
15
41
|
// ─── Shared computation helpers ───────────────────────────────────────────────
|
|
16
42
|
function computeTopCapabilities(entries, limit) {
|
|
17
43
|
const counts = {};
|
|
@@ -120,12 +146,7 @@ export class FileLearningStore {
|
|
|
120
146
|
this.filePath = resolved;
|
|
121
147
|
logger.info(`FileLearningStore initialized — writing to: ${this.filePath}`);
|
|
122
148
|
activeStores.add(this);
|
|
123
|
-
|
|
124
|
-
exitHandlersRegistered = true;
|
|
125
|
-
process.on('exit', flushAllStores);
|
|
126
|
-
process.on('SIGTERM', () => { flushAllStores(); process.exit(0); });
|
|
127
|
-
process.on('SIGINT', () => { flushAllStores(); process.exit(0); });
|
|
128
|
-
}
|
|
149
|
+
registerExitHandlers();
|
|
129
150
|
}
|
|
130
151
|
flushSync() {
|
|
131
152
|
// Cancel pending timer — prevents scheduleSave firing after sync write
|
|
@@ -160,15 +181,17 @@ export class FileLearningStore {
|
|
|
160
181
|
}
|
|
161
182
|
if (this.dirty) {
|
|
162
183
|
this.dirty = false;
|
|
163
|
-
// Await final flush before removing from registry —
|
|
164
|
-
// ensures data is written before the store becomes unreachable
|
|
165
184
|
await this.save();
|
|
166
185
|
}
|
|
167
186
|
activeStores.delete(this);
|
|
187
|
+
unregisterExitHandlers(); // remove handlers if no stores remain
|
|
168
188
|
}
|
|
169
189
|
load() {
|
|
170
190
|
if (!this.loadPromise) {
|
|
171
|
-
this.loadPromise = this._doLoad()
|
|
191
|
+
this.loadPromise = this._doLoad().catch(err => {
|
|
192
|
+
this.loadPromise = null; // allow retry on next call
|
|
193
|
+
throw err;
|
|
194
|
+
});
|
|
172
195
|
}
|
|
173
196
|
return this.loadPromise;
|
|
174
197
|
}
|
|
@@ -185,8 +208,12 @@ export class FileLearningStore {
|
|
|
185
208
|
logger.warn(`Learning store at ${this.filePath} contained unexpected format — starting fresh`);
|
|
186
209
|
}
|
|
187
210
|
}
|
|
188
|
-
catch {
|
|
189
|
-
|
|
211
|
+
catch (err) {
|
|
212
|
+
const code = err.code;
|
|
213
|
+
if (code !== 'ENOENT') {
|
|
214
|
+
logger.warn(`Failed to load learning store from ${this.filePath} (${code ?? 'unknown error'}) — starting fresh`);
|
|
215
|
+
}
|
|
216
|
+
// ENOENT = file doesn't exist yet — expected on first run, no warning needed
|
|
190
217
|
}
|
|
191
218
|
}
|
|
192
219
|
scheduleSave(urgencyMs = 5_000) {
|
package/dist/esm/matcher.d.ts
CHANGED
|
@@ -11,23 +11,31 @@ export declare function resolverToIntent(cap: Capability): MatchResult['intent']
|
|
|
11
11
|
* - Extracts single tokens only — "jane smith" would extract "jane"
|
|
12
12
|
* - Keyword matching is positional — "articles from authors I follow"
|
|
13
13
|
* may extract "authors" instead of nothing, since "from" is a keyword
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
14
|
+
* - Required param fallback grabs the last meaningful word — "list all
|
|
15
|
+
* recent orders" may extract "orders" even with the denylist extended.
|
|
16
|
+
* For precise extraction of complex queries, use matchWithLLM() which
|
|
17
|
+
* handles param extraction via structured LLM prompt.
|
|
18
|
+
* - To support richer extraction patterns, add a `pattern` field to
|
|
19
|
+
* CapabilityParam in a future version.
|
|
16
20
|
*/
|
|
17
21
|
export declare function extractParams(query: string, cap: Capability): Record<string, string | null>;
|
|
18
|
-
export
|
|
22
|
+
export interface MatchOptions {
|
|
23
|
+
fuzzyMatch?: boolean;
|
|
24
|
+
fuzzyThreshold?: number;
|
|
25
|
+
}
|
|
26
|
+
export declare function match(query: string, manifest: Manifest, options?: MatchOptions): MatchResult;
|
|
19
27
|
export interface LLMMatcherOptions {
|
|
20
28
|
llm: (prompt: string) => Promise<string>;
|
|
21
29
|
}
|
|
22
30
|
/**
|
|
23
31
|
* Matches a query to a capability using an LLM.
|
|
24
32
|
*
|
|
25
|
-
* ⚠️ SECURITY NOTE: Capability
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
33
|
+
* ⚠️ SECURITY NOTE: Capability fields are sanitized before injection into
|
|
34
|
+
* the LLM prompt (newlines stripped, delimiters neutralized, length capped).
|
|
35
|
+
* However, the current interface passes a single prompt string — it cannot
|
|
36
|
+
* provide true system/user message separation that some LLM APIs support.
|
|
37
|
+
* For maximum injection resistance in high-security deployments, use an LLM
|
|
38
|
+
* wrapper that maps the prompt to a proper system message, keeping user query
|
|
39
|
+
* data in the user turn only.
|
|
32
40
|
*/
|
|
33
41
|
export declare function matchWithLLM(query: string, manifest: Manifest, options: LLMMatcherOptions): Promise<MatchResult>;
|