capman 0.5.0 → 0.5.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.
- package/CHANGELOG.md +26 -0
- package/dist/cjs/cache.d.ts +4 -4
- package/dist/cjs/cache.d.ts.map +1 -1
- package/dist/cjs/cache.js +22 -8
- package/dist/cjs/cache.js.map +1 -1
- package/dist/cjs/engine.d.ts +20 -0
- package/dist/cjs/engine.d.ts.map +1 -1
- package/dist/cjs/engine.js +97 -145
- package/dist/cjs/engine.js.map +1 -1
- package/dist/cjs/learning.d.ts +2 -0
- package/dist/cjs/learning.d.ts.map +1 -1
- package/dist/cjs/learning.js +72 -5
- package/dist/cjs/learning.js.map +1 -1
- package/dist/cjs/matcher.d.ts +11 -0
- package/dist/cjs/matcher.d.ts.map +1 -1
- package/dist/cjs/matcher.js +14 -2
- package/dist/cjs/matcher.js.map +1 -1
- package/dist/cjs/parser.js +15 -1
- package/dist/cjs/parser.js.map +1 -1
- package/dist/cjs/resolver.d.ts.map +1 -1
- package/dist/cjs/resolver.js +8 -2
- package/dist/cjs/resolver.js.map +1 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/cache.d.ts +4 -4
- package/dist/esm/cache.js +22 -8
- package/dist/esm/engine.d.ts +20 -0
- package/dist/esm/engine.js +97 -145
- package/dist/esm/learning.d.ts +2 -0
- package/dist/esm/learning.js +72 -5
- package/dist/esm/matcher.d.ts +11 -0
- package/dist/esm/matcher.js +14 -2
- package/dist/esm/parser.js +15 -1
- package/dist/esm/resolver.js +8 -2
- 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
|
@@ -20,6 +20,7 @@ export class CapmanEngine {
|
|
|
20
20
|
this.auth = options.auth;
|
|
21
21
|
this.headers = options.headers;
|
|
22
22
|
this.threshold = options.threshold ?? 50;
|
|
23
|
+
this.cacheTtlMs = options.cacheTtlMs ?? null;
|
|
23
24
|
this.maxLLMCallsPerMinute = options.maxLLMCallsPerMinute ?? 60;
|
|
24
25
|
this.llmCooldownMs = options.llmCooldownMs ?? 0;
|
|
25
26
|
this.llmCircuitBreakerThreshold = options.llmCircuitBreakerThreshold ?? 3;
|
|
@@ -60,12 +61,11 @@ export class CapmanEngine {
|
|
|
60
61
|
async ask(query, overrides = {}) {
|
|
61
62
|
const start = Date.now();
|
|
62
63
|
const steps = [];
|
|
63
|
-
let resolvedVia = 'keyword';
|
|
64
64
|
// ── Step 1: Check cache ──────────────────────────────────────────────────
|
|
65
65
|
const cacheStart = Date.now();
|
|
66
66
|
if (this.cache) {
|
|
67
67
|
const queryKey = normalizeQuery(query);
|
|
68
|
-
const cached = await this.cache.get(queryKey);
|
|
68
|
+
const cached = await this.cache.get(queryKey, this.cacheTtlMs ?? undefined);
|
|
69
69
|
if (cached) {
|
|
70
70
|
steps.push({ type: 'cache_check', status: 'hit', durationMs: Date.now() - cacheStart, detail: 'Served from cache' });
|
|
71
71
|
logger.info(`Cache hit for: "${query}"`);
|
|
@@ -94,88 +94,7 @@ export class CapmanEngine {
|
|
|
94
94
|
steps.push({ type: 'cache_check', status: 'skip', durationMs: 0, detail: 'Cache disabled' });
|
|
95
95
|
}
|
|
96
96
|
// ── Step 2: Match ────────────────────────────────────────────────────────
|
|
97
|
-
let matchResult;
|
|
98
|
-
switch (this.mode) {
|
|
99
|
-
case 'cheap': {
|
|
100
|
-
const t = Date.now();
|
|
101
|
-
matchResult = _match(query, this.manifest);
|
|
102
|
-
steps.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t, detail: `confidence: ${matchResult.confidence}%` });
|
|
103
|
-
break;
|
|
104
|
-
}
|
|
105
|
-
case 'accurate': {
|
|
106
|
-
if (this.llm) {
|
|
107
|
-
const skipReason = this.checkLLMAllowed();
|
|
108
|
-
if (skipReason) {
|
|
109
|
-
logger.warn(`LLM skipped — ${skipReason} — falling back to keyword`);
|
|
110
|
-
const t = Date.now();
|
|
111
|
-
matchResult = _match(query, this.manifest);
|
|
112
|
-
steps.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t, detail: `llm skipped: ${skipReason}` });
|
|
113
|
-
}
|
|
114
|
-
else {
|
|
115
|
-
const t = Date.now();
|
|
116
|
-
try {
|
|
117
|
-
matchResult = await _matchWithLLM(query, this.manifest, { llm: this.llm });
|
|
118
|
-
this.recordLLMSuccess();
|
|
119
|
-
resolvedVia = 'llm';
|
|
120
|
-
steps.push({ type: 'llm_match', status: 'pass', durationMs: Date.now() - t, detail: `confidence: ${matchResult.confidence}%` });
|
|
121
|
-
}
|
|
122
|
-
catch (err) {
|
|
123
|
-
const isParseError = String(err).startsWith('LLM_PARSE_ERROR');
|
|
124
|
-
if (!isParseError)
|
|
125
|
-
this.recordLLMFailure();
|
|
126
|
-
logger.warn(`LLM call failed — falling back to keyword: ${err}`);
|
|
127
|
-
const t2 = Date.now();
|
|
128
|
-
matchResult = _match(query, this.manifest);
|
|
129
|
-
steps.push({ type: 'llm_match', status: 'fail', durationMs: Date.now() - t, detail: String(err) });
|
|
130
|
-
steps.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t2, detail: 'fallback after llm failure' });
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
else {
|
|
135
|
-
logger.warn('accurate mode requires llm — falling back to keyword');
|
|
136
|
-
const t = Date.now();
|
|
137
|
-
matchResult = _match(query, this.manifest);
|
|
138
|
-
steps.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t, detail: 'llm not provided, used keyword' });
|
|
139
|
-
}
|
|
140
|
-
break;
|
|
141
|
-
}
|
|
142
|
-
case 'balanced':
|
|
143
|
-
default: {
|
|
144
|
-
const t1 = Date.now();
|
|
145
|
-
const keywordResult = _match(query, this.manifest);
|
|
146
|
-
steps.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t1, detail: `confidence: ${keywordResult.confidence}%` });
|
|
147
|
-
if (keywordResult.confidence >= this.threshold || !this.llm) {
|
|
148
|
-
matchResult = keywordResult;
|
|
149
|
-
}
|
|
150
|
-
else {
|
|
151
|
-
const skipReason = this.checkLLMAllowed();
|
|
152
|
-
if (skipReason) {
|
|
153
|
-
logger.warn(`LLM skipped — ${skipReason}`);
|
|
154
|
-
steps.push({ type: 'llm_match', status: 'skip', durationMs: 0, detail: skipReason });
|
|
155
|
-
matchResult = keywordResult;
|
|
156
|
-
}
|
|
157
|
-
else {
|
|
158
|
-
logger.info(`Low confidence (${keywordResult.confidence}%) — escalating to LLM`);
|
|
159
|
-
const t2 = Date.now();
|
|
160
|
-
try {
|
|
161
|
-
matchResult = await _matchWithLLM(query, this.manifest, { llm: this.llm });
|
|
162
|
-
this.recordLLMSuccess();
|
|
163
|
-
resolvedVia = 'llm';
|
|
164
|
-
steps.push({ type: 'llm_match', status: 'pass', durationMs: Date.now() - t2, detail: `confidence: ${matchResult.confidence}%` });
|
|
165
|
-
}
|
|
166
|
-
catch (err) {
|
|
167
|
-
const isParseError = String(err).startsWith('LLM_PARSE_ERROR');
|
|
168
|
-
if (!isParseError)
|
|
169
|
-
this.recordLLMFailure();
|
|
170
|
-
logger.warn(`LLM call failed — falling back to keyword: ${err}`);
|
|
171
|
-
steps.push({ type: 'llm_match', status: 'fail', durationMs: Date.now() - t2, detail: String(err) });
|
|
172
|
-
matchResult = keywordResult;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
break;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
97
|
+
let { matchResult, resolvedVia } = await this._runMatch(query, steps);
|
|
179
98
|
const preBoostMatchResult = matchResult; // kept for learning recording only — prevents feedback loop
|
|
180
99
|
// ── Step 2.5: Apply learning boost ───────────────────────────────────────
|
|
181
100
|
matchResult = await this.applyBoostToMatchResult(query, matchResult);
|
|
@@ -295,67 +214,9 @@ export class CapmanEngine {
|
|
|
295
214
|
*/
|
|
296
215
|
async explain(query) {
|
|
297
216
|
const start = Date.now();
|
|
298
|
-
// ── Match —
|
|
299
|
-
let matchResult;
|
|
300
|
-
let resolvedVia =
|
|
301
|
-
if (this.mode === 'accurate') {
|
|
302
|
-
if (this.llm) {
|
|
303
|
-
const skipReason = this.checkLLMAllowed();
|
|
304
|
-
if (skipReason) {
|
|
305
|
-
logger.warn(`explain(): LLM skipped — ${skipReason} — falling back to keyword`);
|
|
306
|
-
matchResult = _match(query, this.manifest);
|
|
307
|
-
}
|
|
308
|
-
else {
|
|
309
|
-
try {
|
|
310
|
-
matchResult = await _matchWithLLM(query, this.manifest, { llm: this.llm });
|
|
311
|
-
this.recordLLMSuccess();
|
|
312
|
-
resolvedVia = 'llm';
|
|
313
|
-
}
|
|
314
|
-
catch (err) {
|
|
315
|
-
const isParseError = String(err).startsWith('LLM_PARSE_ERROR');
|
|
316
|
-
if (!isParseError)
|
|
317
|
-
this.recordLLMFailure();
|
|
318
|
-
logger.warn(`LLM call failed — falling back to keyword: ${err}`);
|
|
319
|
-
matchResult = _match(query, this.manifest);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
else {
|
|
324
|
-
matchResult = _match(query, this.manifest);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
else if (this.mode === 'balanced' && this.llm) {
|
|
328
|
-
// Keyword first — escalate to LLM if low confidence (same as ask())
|
|
329
|
-
const keywordResult = _match(query, this.manifest);
|
|
330
|
-
if (keywordResult.confidence >= this.threshold) {
|
|
331
|
-
matchResult = keywordResult;
|
|
332
|
-
}
|
|
333
|
-
else {
|
|
334
|
-
const skipReason = this.checkLLMAllowed();
|
|
335
|
-
if (skipReason) {
|
|
336
|
-
logger.warn(`explain(): LLM skipped — ${skipReason}`);
|
|
337
|
-
matchResult = keywordResult;
|
|
338
|
-
}
|
|
339
|
-
else {
|
|
340
|
-
try {
|
|
341
|
-
matchResult = await _matchWithLLM(query, this.manifest, { llm: this.llm });
|
|
342
|
-
this.recordLLMSuccess();
|
|
343
|
-
resolvedVia = 'llm';
|
|
344
|
-
}
|
|
345
|
-
catch (err) {
|
|
346
|
-
const isParseError = String(err).startsWith('LLM_PARSE_ERROR');
|
|
347
|
-
if (!isParseError)
|
|
348
|
-
this.recordLLMFailure();
|
|
349
|
-
logger.warn(`LLM call failed — falling back to keyword: ${err}`);
|
|
350
|
-
matchResult = keywordResult;
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
else {
|
|
356
|
-
// cheap mode or no llm — keyword only
|
|
357
|
-
matchResult = _match(query, this.manifest);
|
|
358
|
-
}
|
|
217
|
+
// ── Match — shared with ask() via _runMatch() ─────────────────────────────
|
|
218
|
+
let { matchResult, resolvedVia: _resolvedVia } = await this._runMatch(query);
|
|
219
|
+
let resolvedVia = _resolvedVia;
|
|
359
220
|
// ── Apply learning boost (same as ask()) ─────────────────────────────────
|
|
360
221
|
matchResult = await this.applyBoostToMatchResult(query, matchResult);
|
|
361
222
|
// ── Build candidate explanations ─────────────────────────────────────────
|
|
@@ -541,6 +402,97 @@ export class CapmanEngine {
|
|
|
541
402
|
logger.warn(`LLM circuit breaker opened after ${this.llmConsecutiveFails} consecutive failures — pausing for ${this.llmCircuitBreakerResetMs / 1000}s`);
|
|
542
403
|
}
|
|
543
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* Runs the matching pipeline for a query — shared by ask() and explain().
|
|
407
|
+
* Handles cheap / balanced / accurate mode dispatch and LLM rate limiting.
|
|
408
|
+
* Returns the match result and which resolver was used.
|
|
409
|
+
*/
|
|
410
|
+
async _runMatch(query, steps) {
|
|
411
|
+
let matchResult;
|
|
412
|
+
let resolvedVia = 'keyword';
|
|
413
|
+
switch (this.mode) {
|
|
414
|
+
case 'cheap': {
|
|
415
|
+
const t = Date.now();
|
|
416
|
+
matchResult = _match(query, this.manifest);
|
|
417
|
+
steps?.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t, detail: `confidence: ${matchResult.confidence}%` });
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
case 'accurate': {
|
|
421
|
+
if (this.llm) {
|
|
422
|
+
const skipReason = this.checkLLMAllowed();
|
|
423
|
+
if (skipReason) {
|
|
424
|
+
logger.warn(`LLM skipped — ${skipReason} — falling back to keyword`);
|
|
425
|
+
const t = Date.now();
|
|
426
|
+
matchResult = _match(query, this.manifest);
|
|
427
|
+
steps?.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t, detail: `llm skipped: ${skipReason}` });
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
const t = Date.now();
|
|
431
|
+
try {
|
|
432
|
+
matchResult = await _matchWithLLM(query, this.manifest, { llm: this.llm });
|
|
433
|
+
this.recordLLMSuccess();
|
|
434
|
+
resolvedVia = 'llm';
|
|
435
|
+
steps?.push({ type: 'llm_match', status: 'pass', durationMs: Date.now() - t, detail: `confidence: ${matchResult.confidence}%` });
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
const isParseError = String(err).startsWith('LLM_PARSE_ERROR');
|
|
439
|
+
if (!isParseError)
|
|
440
|
+
this.recordLLMFailure();
|
|
441
|
+
logger.warn(`LLM call failed — falling back to keyword: ${err instanceof Error ? err.message : String(err)}`);
|
|
442
|
+
const t2 = Date.now();
|
|
443
|
+
matchResult = _match(query, this.manifest);
|
|
444
|
+
steps?.push({ type: 'llm_match', status: 'fail', durationMs: Date.now() - t, detail: String(err) });
|
|
445
|
+
steps?.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t2, detail: 'fallback after llm failure' });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
logger.warn('accurate mode requires llm — falling back to keyword');
|
|
451
|
+
const t = Date.now();
|
|
452
|
+
matchResult = _match(query, this.manifest);
|
|
453
|
+
steps?.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t, detail: 'llm not provided, used keyword' });
|
|
454
|
+
}
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
case 'balanced':
|
|
458
|
+
default: {
|
|
459
|
+
const t1 = Date.now();
|
|
460
|
+
const keywordResult = _match(query, this.manifest);
|
|
461
|
+
steps?.push({ type: 'keyword_match', status: 'pass', durationMs: Date.now() - t1, detail: `confidence: ${keywordResult.confidence}%` });
|
|
462
|
+
if (keywordResult.confidence >= this.threshold || !this.llm) {
|
|
463
|
+
matchResult = keywordResult;
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
const skipReason = this.checkLLMAllowed();
|
|
467
|
+
if (skipReason) {
|
|
468
|
+
logger.warn(`LLM skipped — ${skipReason}`);
|
|
469
|
+
steps?.push({ type: 'llm_match', status: 'skip', durationMs: 0, detail: skipReason });
|
|
470
|
+
matchResult = keywordResult;
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
logger.info(`Low confidence (${keywordResult.confidence}%) — escalating to LLM`);
|
|
474
|
+
const t2 = Date.now();
|
|
475
|
+
try {
|
|
476
|
+
matchResult = await _matchWithLLM(query, this.manifest, { llm: this.llm });
|
|
477
|
+
this.recordLLMSuccess();
|
|
478
|
+
resolvedVia = 'llm';
|
|
479
|
+
steps?.push({ type: 'llm_match', status: 'pass', durationMs: Date.now() - t2, detail: `confidence: ${matchResult.confidence}%` });
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
const isParseError = String(err).startsWith('LLM_PARSE_ERROR');
|
|
483
|
+
if (!isParseError)
|
|
484
|
+
this.recordLLMFailure();
|
|
485
|
+
logger.warn(`LLM call failed — falling back to keyword: ${err instanceof Error ? err.message : String(err)}`);
|
|
486
|
+
steps?.push({ type: 'llm_match', status: 'fail', durationMs: Date.now() - t2, detail: String(err) });
|
|
487
|
+
matchResult = keywordResult;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return { matchResult: matchResult, resolvedVia };
|
|
495
|
+
}
|
|
544
496
|
/**
|
|
545
497
|
* Applies learning boost to a MatchResult and returns the updated result.
|
|
546
498
|
* Shared by ask() and explain() to avoid logic divergence.
|
package/dist/esm/learning.d.ts
CHANGED
|
@@ -38,6 +38,7 @@ export declare class FileLearningStore implements LearningStore {
|
|
|
38
38
|
private statsCounter;
|
|
39
39
|
constructor(filePath?: string);
|
|
40
40
|
private updateIndex;
|
|
41
|
+
private subtractFromIndex;
|
|
41
42
|
private rebuildIndex;
|
|
42
43
|
private load;
|
|
43
44
|
private save;
|
|
@@ -59,6 +60,7 @@ export declare class MemoryLearningStore implements LearningStore {
|
|
|
59
60
|
getStats(): Promise<KeywordStats>;
|
|
60
61
|
getIndex(): Promise<Record<string, Record<string, number>>>;
|
|
61
62
|
private updateIndex;
|
|
63
|
+
private subtractFromIndex;
|
|
62
64
|
private rebuildIndex;
|
|
63
65
|
getTopCapabilities(limit?: number): Promise<Array<{
|
|
64
66
|
id: string;
|
package/dist/esm/learning.js
CHANGED
|
@@ -78,6 +78,37 @@ export class FileLearningStore {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
+
subtractFromIndex(entry) {
|
|
82
|
+
if (!entry.capabilityId) {
|
|
83
|
+
this.statsCounter.outOfScope = Math.max(0, this.statsCounter.outOfScope - 1);
|
|
84
|
+
this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
|
|
85
|
+
if (entry.resolvedVia === 'llm')
|
|
86
|
+
this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
|
|
87
|
+
if (entry.resolvedVia === 'cache')
|
|
88
|
+
this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
|
|
92
|
+
if (entry.resolvedVia === 'llm')
|
|
93
|
+
this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
|
|
94
|
+
if (entry.resolvedVia === 'cache')
|
|
95
|
+
this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
|
|
96
|
+
const words = entry.query.toLowerCase()
|
|
97
|
+
.split(/\W+/)
|
|
98
|
+
.filter(w => w.length > 2 && !STOPWORDS.has(w));
|
|
99
|
+
for (const word of words) {
|
|
100
|
+
if (!this.index[word])
|
|
101
|
+
continue;
|
|
102
|
+
this.index[word][entry.capabilityId] =
|
|
103
|
+
(this.index[word][entry.capabilityId] ?? 1) - 1;
|
|
104
|
+
if (this.index[word][entry.capabilityId] <= 0) {
|
|
105
|
+
delete this.index[word][entry.capabilityId];
|
|
106
|
+
}
|
|
107
|
+
if (Object.keys(this.index[word]).length === 0) {
|
|
108
|
+
delete this.index[word];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
81
112
|
rebuildIndex() {
|
|
82
113
|
this.index = {};
|
|
83
114
|
this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
|
|
@@ -128,9 +159,11 @@ export class FileLearningStore {
|
|
|
128
159
|
this.updateIndex(entry);
|
|
129
160
|
if (this.entries.length > MAX_LEARNING_ENTRIES) {
|
|
130
161
|
const excess = this.entries.length - MAX_LEARNING_ENTRIES;
|
|
131
|
-
this.entries.splice(0, excess);
|
|
132
|
-
//
|
|
133
|
-
|
|
162
|
+
const pruned = this.entries.splice(0, excess);
|
|
163
|
+
// Subtract pruned entries from index — O(pruned × w) instead of O(n × w) full rebuild
|
|
164
|
+
for (const entry of pruned) {
|
|
165
|
+
this.subtractFromIndex(entry);
|
|
166
|
+
}
|
|
134
167
|
logger.debug(`Learning store pruned ${excess} oldest entries (cap: ${MAX_LEARNING_ENTRIES})`);
|
|
135
168
|
}
|
|
136
169
|
await this.save();
|
|
@@ -167,8 +200,11 @@ export class MemoryLearningStore {
|
|
|
167
200
|
this.entries.push(entry);
|
|
168
201
|
this.updateIndex(entry);
|
|
169
202
|
if (this.entries.length > MAX_LEARNING_ENTRIES) {
|
|
170
|
-
|
|
171
|
-
this.
|
|
203
|
+
const excess = this.entries.length - MAX_LEARNING_ENTRIES;
|
|
204
|
+
const pruned = this.entries.splice(0, excess);
|
|
205
|
+
for (const entry of pruned) {
|
|
206
|
+
this.subtractFromIndex(entry);
|
|
207
|
+
}
|
|
172
208
|
}
|
|
173
209
|
}
|
|
174
210
|
async getStats() {
|
|
@@ -197,6 +233,37 @@ export class MemoryLearningStore {
|
|
|
197
233
|
}
|
|
198
234
|
}
|
|
199
235
|
}
|
|
236
|
+
subtractFromIndex(entry) {
|
|
237
|
+
if (!entry.capabilityId) {
|
|
238
|
+
this.statsCounter.outOfScope = Math.max(0, this.statsCounter.outOfScope - 1);
|
|
239
|
+
this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
|
|
240
|
+
if (entry.resolvedVia === 'llm')
|
|
241
|
+
this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
|
|
242
|
+
if (entry.resolvedVia === 'cache')
|
|
243
|
+
this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
|
|
247
|
+
if (entry.resolvedVia === 'llm')
|
|
248
|
+
this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
|
|
249
|
+
if (entry.resolvedVia === 'cache')
|
|
250
|
+
this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
|
|
251
|
+
const words = entry.query.toLowerCase()
|
|
252
|
+
.split(/\W+/)
|
|
253
|
+
.filter(w => w.length > 2 && !STOPWORDS.has(w));
|
|
254
|
+
for (const word of words) {
|
|
255
|
+
if (!this.index[word])
|
|
256
|
+
continue;
|
|
257
|
+
this.index[word][entry.capabilityId] =
|
|
258
|
+
(this.index[word][entry.capabilityId] ?? 1) - 1;
|
|
259
|
+
if (this.index[word][entry.capabilityId] <= 0) {
|
|
260
|
+
delete this.index[word][entry.capabilityId];
|
|
261
|
+
}
|
|
262
|
+
if (Object.keys(this.index[word]).length === 0) {
|
|
263
|
+
delete this.index[word];
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
200
267
|
rebuildIndex() {
|
|
201
268
|
this.index = {};
|
|
202
269
|
this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
|
package/dist/esm/matcher.d.ts
CHANGED
|
@@ -16,4 +16,15 @@ export declare function match(query: string, manifest: Manifest): MatchResult;
|
|
|
16
16
|
export interface LLMMatcherOptions {
|
|
17
17
|
llm: (prompt: string) => Promise<string>;
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Matches a query to a capability using an LLM.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ SECURITY NOTE: Capability `description` and `examples` fields from the
|
|
23
|
+
* manifest are injected verbatim into the LLM prompt (system portion).
|
|
24
|
+
* In a solo deployment with a developer-controlled manifest this is safe.
|
|
25
|
+
* If your manifest is generated from third-party OpenAPI specs, user-controlled
|
|
26
|
+
* sources, or any external input, sanitize `description` and `examples` fields
|
|
27
|
+
* before passing the manifest to this function — adversarial content in those
|
|
28
|
+
* fields can influence LLM routing decisions.
|
|
29
|
+
*/
|
|
19
30
|
export declare function matchWithLLM(query: string, manifest: Manifest, options: LLMMatcherOptions): Promise<MatchResult>;
|
package/dist/esm/matcher.js
CHANGED
|
@@ -117,10 +117,11 @@ export function extractParams(query, cap) {
|
|
|
117
117
|
const words = query.trim().split(/\s+/);
|
|
118
118
|
const meaningful = words.filter(w => !STOPWORDS.has(w.toLowerCase()));
|
|
119
119
|
const candidate = meaningful[meaningful.length - 1] ?? null;
|
|
120
|
-
// Only use fallback if candidate looks like an identifier — not a generic noun
|
|
120
|
+
// Only use fallback if candidate looks like an identifier — not a generic noun, verb,
|
|
121
|
+
// or category word that would produce junk URLs like /orders/orders or /users/data
|
|
121
122
|
if (candidate &&
|
|
122
123
|
/^[a-zA-Z0-9_-]{2,}$/.test(candidate) &&
|
|
123
|
-
!/^(all|new|latest|recent|current|list|get|show|find|fetch|give|open|my|their|your)$/i.test(candidate)) {
|
|
124
|
+
!/^(all|new|latest|recent|current|list|get|show|find|fetch|give|open|my|their|your|orders|order|items|item|data|results|result|records|record|entries|entry|users|user|products|product|details|info|summary|history|status|feed|content|files|file|documents|document)$/i.test(candidate)) {
|
|
124
125
|
extracted = candidate;
|
|
125
126
|
}
|
|
126
127
|
}
|
|
@@ -184,6 +185,17 @@ export function match(query, manifest) {
|
|
|
184
185
|
candidates,
|
|
185
186
|
};
|
|
186
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Matches a query to a capability using an LLM.
|
|
190
|
+
*
|
|
191
|
+
* ⚠️ SECURITY NOTE: Capability `description` and `examples` fields from the
|
|
192
|
+
* manifest are injected verbatim into the LLM prompt (system portion).
|
|
193
|
+
* In a solo deployment with a developer-controlled manifest this is safe.
|
|
194
|
+
* If your manifest is generated from third-party OpenAPI specs, user-controlled
|
|
195
|
+
* sources, or any external input, sanitize `description` and `examples` fields
|
|
196
|
+
* before passing the manifest to this function — adversarial content in those
|
|
197
|
+
* fields can influence LLM routing decisions.
|
|
198
|
+
*/
|
|
187
199
|
export async function matchWithLLM(query, manifest, options) {
|
|
188
200
|
const manifestSummary = manifest.capabilities.map(c => `- ${c.id} (${c.resolver.type}): ${c.description}${c.examples?.length ? `\n examples: ${c.examples.slice(0, 2).join(', ')}` : ''}`).join('\n');
|
|
189
201
|
const prompt = `You are an intent matcher for an AI agent system.
|
package/dist/esm/parser.js
CHANGED
|
@@ -10,7 +10,21 @@ async function loadSpec(source) {
|
|
|
10
10
|
// URL
|
|
11
11
|
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
12
12
|
logger.info(`Fetching OpenAPI spec from: ${source}`);
|
|
13
|
-
const
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const timer = setTimeout(() => controller.abort(), 10000);
|
|
15
|
+
// eslint-disable-next-line prefer-const
|
|
16
|
+
let res;
|
|
17
|
+
try {
|
|
18
|
+
res = await fetch(source, { signal: controller.signal });
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
24
|
+
throw new Error(`Timed out fetching spec from ${source} (10s limit)`);
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
14
28
|
if (!res.ok)
|
|
15
29
|
throw new Error(`Failed to fetch spec: ${res.status} ${res.statusText}`);
|
|
16
30
|
const text = await res.text();
|
package/dist/esm/resolver.js
CHANGED
|
@@ -44,11 +44,17 @@ export async function resolve(matchResult, params = {}, options = {}) {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
// ── Session param injection ───────────────────────────────────────────────
|
|
47
|
-
// Inject auth.userId into
|
|
47
|
+
// Inject auth.userId into params marked as source: 'session'
|
|
48
|
+
// Session params are only injected if they appear as {template} in the path —
|
|
49
|
+
// they must never leak into the query string as ?user_id=xyz
|
|
48
50
|
const enrichedParams = { ...params };
|
|
49
51
|
if (options.auth?.userId !== undefined && options.auth.userId !== '') {
|
|
52
|
+
const resolver = capability.resolver;
|
|
53
|
+
const pathTemplate = resolver.type === 'api' ? resolver.endpoints.map(e => e.path).join('') :
|
|
54
|
+
resolver.type === 'hybrid' ? resolver.api.endpoints.map(e => e.path).join('') :
|
|
55
|
+
resolver.type === 'nav' ? resolver.destination : '';
|
|
50
56
|
for (const param of capability.params) {
|
|
51
|
-
if (param.source === 'session') {
|
|
57
|
+
if (param.source === 'session' && pathTemplate.includes(`{${param.name}}`)) {
|
|
52
58
|
enrichedParams[param.name] = options.auth.userId;
|
|
53
59
|
logger.debug(`Injected session param "${param.name}" (value redacted)`);
|
|
54
60
|
}
|
package/dist/esm/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.5.
|
|
1
|
+
export declare const VERSION = "0.5.1";
|
package/dist/esm/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated by scripts/version.js — do not edit manually
|
|
2
|
-
export const VERSION = '0.5.
|
|
2
|
+
export const VERSION = '0.5.1';
|
package/package.json
CHANGED