capman 0.5.1 → 0.5.3
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 +57 -0
- package/CODEBASE.md +2 -1
- package/bin/lib/cmd-demo.js +2 -2
- package/dist/cjs/cache.d.ts +4 -1
- package/dist/cjs/cache.d.ts.map +1 -1
- package/dist/cjs/cache.js +42 -20
- package/dist/cjs/cache.js.map +1 -1
- package/dist/cjs/engine.d.ts +3 -1
- package/dist/cjs/engine.d.ts.map +1 -1
- package/dist/cjs/engine.js +103 -34
- package/dist/cjs/engine.js.map +1 -1
- package/dist/cjs/generator.d.ts.map +1 -1
- package/dist/cjs/generator.js +16 -1
- package/dist/cjs/generator.js.map +1 -1
- package/dist/cjs/index.d.ts +3 -2
- 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 +20 -11
- package/dist/cjs/learning.d.ts.map +1 -1
- package/dist/cjs/learning.js +169 -135
- package/dist/cjs/learning.js.map +1 -1
- package/dist/cjs/matcher.d.ts +4 -1
- package/dist/cjs/matcher.d.ts.map +1 -1
- package/dist/cjs/matcher.js +33 -13
- package/dist/cjs/matcher.js.map +1 -1
- package/dist/cjs/parser.js +10 -4
- package/dist/cjs/parser.js.map +1 -1
- package/dist/cjs/resolver.d.ts +7 -0
- package/dist/cjs/resolver.d.ts.map +1 -1
- package/dist/cjs/resolver.js +63 -19
- package/dist/cjs/resolver.js.map +1 -1
- package/dist/cjs/schema.d.ts +106 -14
- package/dist/cjs/schema.d.ts.map +1 -1
- package/dist/cjs/schema.js +9 -4
- package/dist/cjs/schema.js.map +1 -1
- package/dist/cjs/types.d.ts +1 -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/cache.d.ts +4 -1
- package/dist/esm/cache.js +42 -20
- package/dist/esm/engine.d.ts +3 -1
- package/dist/esm/engine.js +104 -35
- package/dist/esm/generator.js +16 -1
- package/dist/esm/index.d.ts +3 -2
- package/dist/esm/index.js +1 -0
- package/dist/esm/learning.d.ts +20 -11
- package/dist/esm/learning.js +169 -135
- package/dist/esm/matcher.d.ts +4 -1
- package/dist/esm/matcher.js +31 -12
- package/dist/esm/parser.js +10 -4
- package/dist/esm/resolver.d.ts +7 -0
- package/dist/esm/resolver.js +63 -19
- package/dist/esm/schema.d.ts +106 -14
- package/dist/esm/schema.js +9 -4
- package/dist/esm/types.d.ts +1 -0
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/dist/esm/learning.js
CHANGED
|
@@ -1,37 +1,18 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { logger } from './logger';
|
|
4
|
-
const MAX_LEARNING_ENTRIES =
|
|
4
|
+
const MAX_LEARNING_ENTRIES = 10_000;
|
|
5
5
|
import { STOPWORDS } from './matcher';
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
for (const entry of entries) {
|
|
14
|
-
totalQueries++;
|
|
15
|
-
if (entry.resolvedVia === 'llm')
|
|
16
|
-
llmQueries++;
|
|
17
|
-
if (entry.resolvedVia === 'cache')
|
|
18
|
-
cacheHits++;
|
|
19
|
-
if (!entry.capabilityId)
|
|
20
|
-
outOfScope++;
|
|
21
|
-
if (entry.capabilityId) {
|
|
22
|
-
const words = entry.query.toLowerCase()
|
|
23
|
-
.split(/\W+/)
|
|
24
|
-
.filter(w => w.length > 2 && !STOPWORDS.has(w));
|
|
25
|
-
for (const word of words) {
|
|
26
|
-
if (!index[word])
|
|
27
|
-
index[word] = {};
|
|
28
|
-
index[word][entry.capabilityId] =
|
|
29
|
-
(index[word][entry.capabilityId] ?? 0) + 1;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
6
|
+
// Module-level registry — tracks all active FileLearningStore instances
|
|
7
|
+
// for process exit flushing. Handlers registered once to avoid accumulation.
|
|
8
|
+
const activeStores = new Set();
|
|
9
|
+
let exitHandlersRegistered = false;
|
|
10
|
+
function flushAllStores() {
|
|
11
|
+
for (const store of activeStores) {
|
|
12
|
+
store.flushSync();
|
|
32
13
|
}
|
|
33
|
-
return { index, totalQueries, llmQueries, cacheHits, outOfScope };
|
|
34
14
|
}
|
|
15
|
+
// ─── Shared computation helpers ───────────────────────────────────────────────
|
|
35
16
|
function computeTopCapabilities(entries, limit) {
|
|
36
17
|
const counts = {};
|
|
37
18
|
for (const entry of entries) {
|
|
@@ -44,22 +25,18 @@ function computeTopCapabilities(entries, limit) {
|
|
|
44
25
|
.slice(0, limit)
|
|
45
26
|
.map(([id, hits]) => ({ id, hits }));
|
|
46
27
|
}
|
|
47
|
-
// ───
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// ── Incremental index — updated in record(), not rebuilt in getStats() ────
|
|
28
|
+
// ─── Shared Learning Index ────────────────────────────────────────────────────
|
|
29
|
+
// Encapsulates keyword index and stats counters.
|
|
30
|
+
// Both FileLearningStore and MemoryLearningStore compose this instead of
|
|
31
|
+
// duplicating the same ~80 lines of index management logic.
|
|
32
|
+
class LearningIndex {
|
|
33
|
+
constructor() {
|
|
54
34
|
this.index = {};
|
|
55
35
|
this.statsCounter = {
|
|
56
36
|
totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0,
|
|
57
37
|
};
|
|
58
|
-
this.filePath = path.resolve(process.cwd(), filePath);
|
|
59
|
-
logger.info(`FileLearningStore initialized — writing to: ${this.filePath}`);
|
|
60
38
|
}
|
|
61
|
-
|
|
62
|
-
var _a;
|
|
39
|
+
update(entry) {
|
|
63
40
|
this.statsCounter.totalQueries++;
|
|
64
41
|
if (entry.resolvedVia === 'llm')
|
|
65
42
|
this.statsCounter.llmQueries++;
|
|
@@ -72,27 +49,24 @@ export class FileLearningStore {
|
|
|
72
49
|
.split(/\W+/)
|
|
73
50
|
.filter(w => w.length > 2 && !STOPWORDS.has(w));
|
|
74
51
|
for (const word of words) {
|
|
75
|
-
|
|
52
|
+
this.index[word] ??= {};
|
|
76
53
|
this.index[word][entry.capabilityId] =
|
|
77
54
|
(this.index[word][entry.capabilityId] ?? 0) + 1;
|
|
78
55
|
}
|
|
79
56
|
}
|
|
80
57
|
}
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
}
|
|
58
|
+
subtract(entry) {
|
|
59
|
+
// Shared counter decrements regardless of capabilityId
|
|
91
60
|
this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
|
|
92
61
|
if (entry.resolvedVia === 'llm')
|
|
93
62
|
this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
|
|
94
63
|
if (entry.resolvedVia === 'cache')
|
|
95
64
|
this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
|
|
65
|
+
if (!entry.capabilityId) {
|
|
66
|
+
this.statsCounter.outOfScope = Math.max(0, this.statsCounter.outOfScope - 1);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// Keyword index cleanup
|
|
96
70
|
const words = entry.query.toLowerCase()
|
|
97
71
|
.split(/\W+/)
|
|
98
72
|
.filter(w => w.length > 2 && !STOPWORDS.has(w));
|
|
@@ -109,22 +83,102 @@ export class FileLearningStore {
|
|
|
109
83
|
}
|
|
110
84
|
}
|
|
111
85
|
}
|
|
112
|
-
|
|
86
|
+
rebuild(entries) {
|
|
87
|
+
this.index = {};
|
|
88
|
+
this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
this.update(entry);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
reset() {
|
|
113
94
|
this.index = {};
|
|
114
95
|
this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
|
|
115
|
-
|
|
116
|
-
|
|
96
|
+
}
|
|
97
|
+
getStats() {
|
|
98
|
+
return { ...this.statsCounter, index: structuredClone(this.index) };
|
|
99
|
+
}
|
|
100
|
+
getIndex() {
|
|
101
|
+
return structuredClone(this.index);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// ─── File Learning Store ──────────────────────────────────────────────────────
|
|
105
|
+
export class FileLearningStore {
|
|
106
|
+
constructor(filePath = '.capman/learning.json') {
|
|
107
|
+
this.entries = [];
|
|
108
|
+
this.loadPromise = null;
|
|
109
|
+
this.saveQueue = Promise.resolve();
|
|
110
|
+
this.learningIndex = new LearningIndex();
|
|
111
|
+
this.dirty = false;
|
|
112
|
+
this.saveTimer = null;
|
|
113
|
+
const cwd = process.cwd();
|
|
114
|
+
const resolved = path.resolve(cwd, filePath);
|
|
115
|
+
const allowedPrefix = cwd === '/' ? '/' : cwd + path.sep;
|
|
116
|
+
if (!resolved.startsWith(allowedPrefix)) {
|
|
117
|
+
throw new Error(`FileLearningStore path "${filePath}" resolves outside the working directory.\n` +
|
|
118
|
+
`Resolved: ${resolved}\nAllowed: ${cwd}`);
|
|
119
|
+
}
|
|
120
|
+
this.filePath = resolved;
|
|
121
|
+
logger.info(`FileLearningStore initialized — writing to: ${this.filePath}`);
|
|
122
|
+
activeStores.add(this);
|
|
123
|
+
if (!exitHandlersRegistered) {
|
|
124
|
+
exitHandlersRegistered = true;
|
|
125
|
+
process.on('exit', flushAllStores);
|
|
126
|
+
process.on('SIGTERM', () => { flushAllStores(); process.exit(0); });
|
|
127
|
+
process.on('SIGINT', () => { flushAllStores(); process.exit(0); });
|
|
117
128
|
}
|
|
118
129
|
}
|
|
119
|
-
|
|
120
|
-
|
|
130
|
+
flushSync() {
|
|
131
|
+
// Cancel pending timer — prevents scheduleSave firing after sync write
|
|
132
|
+
if (this.saveTimer) {
|
|
133
|
+
clearTimeout(this.saveTimer);
|
|
134
|
+
this.saveTimer = null;
|
|
135
|
+
}
|
|
136
|
+
if (!this.dirty)
|
|
121
137
|
return;
|
|
138
|
+
this.dirty = false;
|
|
139
|
+
try {
|
|
140
|
+
const dir = path.dirname(this.filePath);
|
|
141
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
142
|
+
const tmp = `${this.filePath}.tmp`;
|
|
143
|
+
const payload = JSON.stringify({ entries: this.entries, updatedAt: new Date().toISOString() }, null, 2);
|
|
144
|
+
// Write to .tmp then rename — matches _doSave() pattern so they can't interleave
|
|
145
|
+
fs.writeFileSync(tmp, payload);
|
|
146
|
+
fs.renameSync(tmp, this.filePath);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Best-effort in exit handler
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Removes this store from the exit flush registry and cancels any pending save timer.
|
|
154
|
+
* Call when the store is no longer needed to prevent memory leaks in long-running servers.
|
|
155
|
+
*/
|
|
156
|
+
async destroy() {
|
|
157
|
+
if (this.saveTimer) {
|
|
158
|
+
clearTimeout(this.saveTimer);
|
|
159
|
+
this.saveTimer = null;
|
|
160
|
+
}
|
|
161
|
+
if (this.dirty) {
|
|
162
|
+
this.dirty = false;
|
|
163
|
+
// Await final flush before removing from registry —
|
|
164
|
+
// ensures data is written before the store becomes unreachable
|
|
165
|
+
await this.save();
|
|
166
|
+
}
|
|
167
|
+
activeStores.delete(this);
|
|
168
|
+
}
|
|
169
|
+
load() {
|
|
170
|
+
if (!this.loadPromise) {
|
|
171
|
+
this.loadPromise = this._doLoad();
|
|
172
|
+
}
|
|
173
|
+
return this.loadPromise;
|
|
174
|
+
}
|
|
175
|
+
async _doLoad() {
|
|
122
176
|
try {
|
|
123
177
|
const raw = await fs.promises.readFile(this.filePath, 'utf-8');
|
|
124
178
|
const parsed = JSON.parse(raw);
|
|
125
179
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Array.isArray(parsed.entries)) {
|
|
126
180
|
this.entries = parsed.entries;
|
|
127
|
-
this.
|
|
181
|
+
this.learningIndex.rebuild(this.entries);
|
|
128
182
|
logger.debug(`Learning store loaded: ${this.entries.length} entries`);
|
|
129
183
|
}
|
|
130
184
|
else {
|
|
@@ -134,7 +188,19 @@ export class FileLearningStore {
|
|
|
134
188
|
catch {
|
|
135
189
|
// File doesn't exist yet — start fresh
|
|
136
190
|
}
|
|
137
|
-
|
|
191
|
+
}
|
|
192
|
+
scheduleSave(urgencyMs = 5_000) {
|
|
193
|
+
this.dirty = true;
|
|
194
|
+
if (!this.saveTimer) {
|
|
195
|
+
this.saveTimer = setTimeout(() => {
|
|
196
|
+
this.saveTimer = null;
|
|
197
|
+
if (this.dirty) {
|
|
198
|
+
this.dirty = false;
|
|
199
|
+
// Route through saveQueue — serializes with all other saves
|
|
200
|
+
this.save();
|
|
201
|
+
}
|
|
202
|
+
}, urgencyMs);
|
|
203
|
+
}
|
|
138
204
|
}
|
|
139
205
|
save() {
|
|
140
206
|
this.saveQueue = this.saveQueue.then(() => this._doSave());
|
|
@@ -144,10 +210,12 @@ export class FileLearningStore {
|
|
|
144
210
|
try {
|
|
145
211
|
const dir = path.dirname(this.filePath);
|
|
146
212
|
await fs.promises.mkdir(dir, { recursive: true });
|
|
147
|
-
|
|
213
|
+
const tmp = `${this.filePath}.tmp`;
|
|
214
|
+
await fs.promises.writeFile(tmp, JSON.stringify({
|
|
148
215
|
entries: this.entries,
|
|
149
216
|
updatedAt: new Date().toISOString(),
|
|
150
217
|
}, null, 2));
|
|
218
|
+
await fs.promises.rename(tmp, this.filePath);
|
|
151
219
|
}
|
|
152
220
|
catch (err) {
|
|
153
221
|
logger.warn(`Failed to save learning store to ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -155,35 +223,52 @@ export class FileLearningStore {
|
|
|
155
223
|
}
|
|
156
224
|
async record(entry) {
|
|
157
225
|
await this.load();
|
|
158
|
-
|
|
159
|
-
|
|
226
|
+
// Store only tokenized keywords — never raw query text.
|
|
227
|
+
// Raw queries may contain PII (emails, names, order IDs) that should
|
|
228
|
+
// not be persisted to disk under GDPR/CCPA data retention requirements.
|
|
229
|
+
const sanitized = {
|
|
230
|
+
...entry,
|
|
231
|
+
query: entry.query
|
|
232
|
+
.toLowerCase()
|
|
233
|
+
.split(/\W+/)
|
|
234
|
+
.filter(w => w.length > 2 && !STOPWORDS.has(w))
|
|
235
|
+
.join(' '),
|
|
236
|
+
};
|
|
237
|
+
this.entries.push(sanitized);
|
|
238
|
+
this.learningIndex.update(sanitized);
|
|
160
239
|
if (this.entries.length > MAX_LEARNING_ENTRIES) {
|
|
161
240
|
const excess = this.entries.length - MAX_LEARNING_ENTRIES;
|
|
162
241
|
const pruned = this.entries.splice(0, excess);
|
|
163
242
|
// Subtract pruned entries from index — O(pruned × w) instead of O(n × w) full rebuild
|
|
164
243
|
for (const entry of pruned) {
|
|
165
|
-
this.
|
|
244
|
+
this.learningIndex.subtract(entry);
|
|
166
245
|
}
|
|
167
246
|
logger.debug(`Learning store pruned ${excess} oldest entries (cap: ${MAX_LEARNING_ENTRIES})`);
|
|
168
247
|
}
|
|
169
|
-
|
|
248
|
+
this.scheduleSave();
|
|
170
249
|
}
|
|
171
250
|
async getStats() {
|
|
172
251
|
await this.load();
|
|
173
|
-
return
|
|
252
|
+
return this.learningIndex.getStats();
|
|
174
253
|
}
|
|
175
254
|
async getIndex() {
|
|
176
255
|
await this.load();
|
|
177
|
-
return
|
|
256
|
+
return this.learningIndex.getIndex();
|
|
178
257
|
}
|
|
179
258
|
async getTopCapabilities(limit = 5) {
|
|
180
259
|
await this.load();
|
|
181
260
|
return computeTopCapabilities(this.entries, limit);
|
|
182
261
|
}
|
|
183
262
|
async clear() {
|
|
263
|
+
// Cancel any pending debounced save — prevents stale data being written
|
|
264
|
+
// after clear() resets state
|
|
265
|
+
if (this.saveTimer) {
|
|
266
|
+
clearTimeout(this.saveTimer);
|
|
267
|
+
this.saveTimer = null;
|
|
268
|
+
}
|
|
269
|
+
this.dirty = false;
|
|
184
270
|
this.entries = [];
|
|
185
|
-
this.
|
|
186
|
-
this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
|
|
271
|
+
this.learningIndex.reset();
|
|
187
272
|
await this.save();
|
|
188
273
|
}
|
|
189
274
|
}
|
|
@@ -191,92 +276,41 @@ export class FileLearningStore {
|
|
|
191
276
|
export class MemoryLearningStore {
|
|
192
277
|
constructor() {
|
|
193
278
|
this.entries = [];
|
|
194
|
-
this.
|
|
195
|
-
this.statsCounter = {
|
|
196
|
-
totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0,
|
|
197
|
-
};
|
|
279
|
+
this.learningIndex = new LearningIndex();
|
|
198
280
|
}
|
|
199
281
|
async record(entry) {
|
|
200
|
-
|
|
201
|
-
|
|
282
|
+
const sanitized = {
|
|
283
|
+
...entry,
|
|
284
|
+
query: entry.query
|
|
285
|
+
.toLowerCase()
|
|
286
|
+
.split(/\W+/)
|
|
287
|
+
.filter(w => w.length > 2 && !STOPWORDS.has(w))
|
|
288
|
+
.join(' '),
|
|
289
|
+
};
|
|
290
|
+
this.entries.push(sanitized);
|
|
291
|
+
this.learningIndex.update(sanitized);
|
|
202
292
|
if (this.entries.length > MAX_LEARNING_ENTRIES) {
|
|
203
293
|
const excess = this.entries.length - MAX_LEARNING_ENTRIES;
|
|
204
294
|
const pruned = this.entries.splice(0, excess);
|
|
205
295
|
for (const entry of pruned) {
|
|
206
|
-
this.
|
|
296
|
+
this.learningIndex.subtract(entry);
|
|
207
297
|
}
|
|
208
298
|
}
|
|
209
299
|
}
|
|
210
300
|
async getStats() {
|
|
211
|
-
return
|
|
301
|
+
return this.learningIndex.getStats();
|
|
212
302
|
}
|
|
213
303
|
async getIndex() {
|
|
214
|
-
return
|
|
215
|
-
}
|
|
216
|
-
updateIndex(entry) {
|
|
217
|
-
var _a;
|
|
218
|
-
this.statsCounter.totalQueries++;
|
|
219
|
-
if (entry.resolvedVia === 'llm')
|
|
220
|
-
this.statsCounter.llmQueries++;
|
|
221
|
-
if (entry.resolvedVia === 'cache')
|
|
222
|
-
this.statsCounter.cacheHits++;
|
|
223
|
-
if (!entry.capabilityId)
|
|
224
|
-
this.statsCounter.outOfScope++;
|
|
225
|
-
if (entry.capabilityId) {
|
|
226
|
-
const words = entry.query.toLowerCase()
|
|
227
|
-
.split(/\W+/)
|
|
228
|
-
.filter(w => w.length > 2 && !STOPWORDS.has(w));
|
|
229
|
-
for (const word of words) {
|
|
230
|
-
(_a = this.index)[word] ?? (_a[word] = {});
|
|
231
|
-
this.index[word][entry.capabilityId] =
|
|
232
|
-
(this.index[word][entry.capabilityId] ?? 0) + 1;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
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
|
-
}
|
|
267
|
-
rebuildIndex() {
|
|
268
|
-
this.index = {};
|
|
269
|
-
this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
|
|
270
|
-
for (const entry of this.entries) {
|
|
271
|
-
this.updateIndex(entry);
|
|
272
|
-
}
|
|
304
|
+
return this.learningIndex.getIndex();
|
|
273
305
|
}
|
|
274
306
|
async getTopCapabilities(limit = 5) {
|
|
275
307
|
return computeTopCapabilities(this.entries, limit);
|
|
276
308
|
}
|
|
277
309
|
async clear() {
|
|
278
310
|
this.entries = [];
|
|
279
|
-
this.
|
|
280
|
-
|
|
311
|
+
this.learningIndex.reset();
|
|
312
|
+
}
|
|
313
|
+
async destroy() {
|
|
314
|
+
// No-op for memory store — nothing to flush or deregister
|
|
281
315
|
}
|
|
282
316
|
}
|
package/dist/esm/matcher.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Manifest, Capability, MatchResult } from './types';
|
|
2
|
+
export declare class LLMParseError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
2
5
|
export declare const STOPWORDS: Set<string>;
|
|
3
6
|
export declare function resolverToIntent(cap: Capability): MatchResult['intent'];
|
|
4
7
|
/**
|
package/dist/esm/matcher.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { logger } from './logger';
|
|
2
|
+
// ─── Typed error for LLM parse failures ──────────────────────────────────────
|
|
3
|
+
export class LLMParseError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'LLMParseError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
2
9
|
export const STOPWORDS = new Set([
|
|
3
10
|
'show', 'me', 'the', 'get', 'find', 'fetch', 'give', 'please',
|
|
4
11
|
'can', 'you', 'i', 'want', 'to', 'a', 'an', 'my', 'our', 'your',
|
|
@@ -65,7 +72,7 @@ export function extractParams(query, cap) {
|
|
|
65
72
|
for (const param of cap.params) {
|
|
66
73
|
// Session params come from auth context, not query
|
|
67
74
|
if (param.source === 'session') {
|
|
68
|
-
result[param.name] =
|
|
75
|
+
result[param.name] = null; // injected by resolver from auth context — not extracted from query
|
|
69
76
|
continue;
|
|
70
77
|
}
|
|
71
78
|
if (param.source !== 'user_query') {
|
|
@@ -141,7 +148,8 @@ export function match(query, manifest) {
|
|
|
141
148
|
candidates: [],
|
|
142
149
|
};
|
|
143
150
|
}
|
|
144
|
-
logger.info(`Matching query
|
|
151
|
+
logger.info(`Matching query (${query.length} chars)`);
|
|
152
|
+
logger.debug(`Full query: "${query}"`);
|
|
145
153
|
logger.debug(`Manifest has ${manifest.capabilities.length} capabilities`);
|
|
146
154
|
let best = null;
|
|
147
155
|
let bestScore = 0;
|
|
@@ -197,7 +205,13 @@ export function match(query, manifest) {
|
|
|
197
205
|
* fields can influence LLM routing decisions.
|
|
198
206
|
*/
|
|
199
207
|
export async function matchWithLLM(query, manifest, options) {
|
|
200
|
-
|
|
208
|
+
// Truncate description and examples — prevents context window overflow and
|
|
209
|
+
// reduces prompt injection surface from third-party OpenAPI spec content.
|
|
210
|
+
const MAX_DESC_LEN = 200;
|
|
211
|
+
const MAX_EXAMPLE_LEN = 100;
|
|
212
|
+
const manifestSummary = manifest.capabilities.map(c => `- ${c.id} (${c.resolver.type}): ${c.description.slice(0, MAX_DESC_LEN)}${c.description.length > MAX_DESC_LEN ? '…' : ''}${c.examples?.length
|
|
213
|
+
? `\n examples: ${c.examples.slice(0, 2).map(e => e.slice(0, MAX_EXAMPLE_LEN)).join(', ')}`
|
|
214
|
+
: ''}`).join('\n');
|
|
201
215
|
const prompt = `You are an intent matcher for an AI agent system.
|
|
202
216
|
|
|
203
217
|
App: ${manifest.app}
|
|
@@ -228,13 +242,13 @@ ${JSON.stringify({ user_query: query })}
|
|
|
228
242
|
parsed = JSON.parse(clean);
|
|
229
243
|
}
|
|
230
244
|
catch {
|
|
231
|
-
throw new
|
|
245
|
+
throw new LLMParseError(`LLM returned invalid JSON. First 300 chars: ${clean.slice(0, 300)}`);
|
|
232
246
|
}
|
|
233
247
|
if (typeof parsed.matched_capability !== 'string') {
|
|
234
|
-
throw new
|
|
248
|
+
throw new LLMParseError(`missing "matched_capability" field in response`);
|
|
235
249
|
}
|
|
236
250
|
if (typeof parsed.confidence !== 'number') {
|
|
237
|
-
throw new
|
|
251
|
+
throw new LLMParseError(`missing numeric "confidence" field in response`);
|
|
238
252
|
}
|
|
239
253
|
const isOOS = parsed.matched_capability === 'OUT_OF_SCOPE';
|
|
240
254
|
const capability = isOOS
|
|
@@ -245,16 +259,21 @@ ${JSON.stringify({ user_query: query })}
|
|
|
245
259
|
if (!isOOS && capability === null) {
|
|
246
260
|
logger.warn(`LLM returned unknown capability ID: "${parsed.matched_capability}" — treating as out_of_scope`);
|
|
247
261
|
}
|
|
262
|
+
// Build full candidate list — all capabilities scored, LLM winner marked as matched.
|
|
263
|
+
// This aligns the shape with keyword match results and allows the learning boost
|
|
264
|
+
// to surface alternatives if the LLM made a wrong call.
|
|
265
|
+
const llmConfidence = effectivelyOOS ? 0 : parsed.confidence;
|
|
266
|
+
const allCandidates = manifest.capabilities.map(c => ({
|
|
267
|
+
capabilityId: c.id,
|
|
268
|
+
score: c.id === capability?.id ? llmConfidence : 0,
|
|
269
|
+
matched: c.id === capability?.id,
|
|
270
|
+
}));
|
|
248
271
|
return {
|
|
249
272
|
capability,
|
|
250
|
-
confidence:
|
|
273
|
+
confidence: llmConfidence,
|
|
251
274
|
intent: effectivelyOOS ? 'out_of_scope' : parsed.intent,
|
|
252
275
|
extractedParams: (parsed.extracted_params ?? {}),
|
|
253
276
|
reasoning: parsed.reasoning ?? 'No reasoning provided',
|
|
254
|
-
candidates:
|
|
255
|
-
capabilityId: capability.id,
|
|
256
|
-
score: parsed.confidence,
|
|
257
|
-
matched: true,
|
|
258
|
-
}] : [],
|
|
277
|
+
candidates: allCandidates,
|
|
259
278
|
};
|
|
260
279
|
}
|
package/dist/esm/parser.js
CHANGED
|
@@ -11,7 +11,7 @@ async function loadSpec(source) {
|
|
|
11
11
|
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
12
12
|
logger.info(`Fetching OpenAPI spec from: ${source}`);
|
|
13
13
|
const controller = new AbortController();
|
|
14
|
-
const timer = setTimeout(() => controller.abort(),
|
|
14
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
15
15
|
// eslint-disable-next-line prefer-const
|
|
16
16
|
let res;
|
|
17
17
|
try {
|
|
@@ -36,7 +36,7 @@ async function loadSpec(source) {
|
|
|
36
36
|
throw new Error(`Spec file not found: ${resolved}`);
|
|
37
37
|
}
|
|
38
38
|
logger.info(`Reading OpenAPI spec from: ${resolved}`);
|
|
39
|
-
const text = fs.
|
|
39
|
+
const text = await fs.promises.readFile(resolved, 'utf-8');
|
|
40
40
|
return parseSpecText(text, source);
|
|
41
41
|
}
|
|
42
42
|
function parseSpecText(text, source) {
|
|
@@ -50,8 +50,14 @@ function parseSpecText(text, source) {
|
|
|
50
50
|
const yaml = require('js-yaml');
|
|
51
51
|
return yaml.load(text);
|
|
52
52
|
}
|
|
53
|
-
catch {
|
|
54
|
-
|
|
53
|
+
catch (err) {
|
|
54
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
55
|
+
// Distinguish "module not found" from actual YAML parse errors
|
|
56
|
+
const code = err.code;
|
|
57
|
+
if (code !== 'MODULE_NOT_FOUND') {
|
|
58
|
+
throw new Error(`YAML parse error in "${source}": ${msg}`);
|
|
59
|
+
}
|
|
60
|
+
// js-yaml not installed — fall through to extension check
|
|
55
61
|
if (source.endsWith('.yaml') || source.endsWith('.yml')) {
|
|
56
62
|
throw new Error('YAML spec detected but js-yaml is not installed.\n' +
|
|
57
63
|
'Install it: npm install js-yaml\n' +
|
package/dist/esm/resolver.d.ts
CHANGED
|
@@ -17,5 +17,12 @@ export interface ResolveOptions {
|
|
|
17
17
|
retries?: number;
|
|
18
18
|
/** Timeout in milliseconds (default: 5000) */
|
|
19
19
|
timeoutMs?: number;
|
|
20
|
+
/**
|
|
21
|
+
* When true, retries all HTTP methods including POST/PUT/PATCH/DELETE.
|
|
22
|
+
* Use only for idempotent write operations — retrying non-idempotent
|
|
23
|
+
* methods can cause duplicate side effects (duplicate orders, double charges).
|
|
24
|
+
* @default false
|
|
25
|
+
*/
|
|
26
|
+
retryAllMethods?: boolean;
|
|
20
27
|
}
|
|
21
28
|
export declare function resolve(matchResult: MatchResult, params?: Record<string, unknown>, options?: ResolveOptions): Promise<ResolveResult>;
|