capman 0.5.2 → 0.5.4
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 +43 -0
- package/CODEBASE.md +15 -9
- package/bin/lib/cmd-explain.js +2 -2
- package/bin/lib/cmd-run.js +2 -2
- package/bin/lib/shared.js +8 -2
- package/dist/cjs/cache.d.ts +2 -1
- package/dist/cjs/cache.d.ts.map +1 -1
- package/dist/cjs/cache.js +11 -6
- 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 +69 -25
- 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/learning.d.ts +20 -10
- package/dist/cjs/learning.d.ts.map +1 -1
- package/dist/cjs/learning.js +146 -129
- package/dist/cjs/learning.js.map +1 -1
- package/dist/cjs/matcher.d.ts +5 -2
- package/dist/cjs/matcher.d.ts.map +1 -1
- package/dist/cjs/matcher.js +73 -10
- package/dist/cjs/matcher.js.map +1 -1
- package/dist/cjs/parser.js +8 -2
- 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 +47 -23
- package/dist/cjs/resolver.js.map +1 -1
- package/dist/cjs/schema.d.ts +93 -1
- package/dist/cjs/schema.d.ts.map +1 -1
- package/dist/cjs/schema.js +5 -2
- package/dist/cjs/schema.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 +2 -1
- package/dist/esm/cache.js +11 -6
- package/dist/esm/engine.d.ts +30 -0
- package/dist/esm/engine.js +69 -25
- package/dist/esm/generator.js +16 -1
- package/dist/esm/learning.d.ts +20 -10
- package/dist/esm/learning.js +146 -129
- package/dist/esm/matcher.d.ts +5 -2
- package/dist/esm/matcher.js +70 -10
- package/dist/esm/parser.js +8 -2
- package/dist/esm/resolver.d.ts +7 -0
- package/dist/esm/resolver.js +47 -23
- package/dist/esm/schema.d.ts +93 -1
- package/dist/esm/schema.js +5 -2
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +11 -10
package/dist/esm/learning.js
CHANGED
|
@@ -3,35 +3,16 @@ import * as path from 'path';
|
|
|
3
3
|
import { logger } from './logger';
|
|
4
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,28 +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
|
-
const cwd = process.cwd();
|
|
59
|
-
const resolved = path.resolve(cwd, filePath);
|
|
60
|
-
const allowedPrefix = cwd === '/' ? '/' : cwd + path.sep;
|
|
61
|
-
if (!resolved.startsWith(allowedPrefix)) {
|
|
62
|
-
throw new Error(`FileCache path "${filePath}" resolves outside the working directory.\n` +
|
|
63
|
-
`Resolved: ${resolved}\nAllowed: ${cwd}`);
|
|
64
|
-
}
|
|
65
|
-
this.filePath = resolved;
|
|
66
|
-
logger.info(`FileLearningStore initialized — writing to: ${this.filePath}`);
|
|
67
38
|
}
|
|
68
|
-
|
|
39
|
+
update(entry) {
|
|
69
40
|
this.statsCounter.totalQueries++;
|
|
70
41
|
if (entry.resolvedVia === 'llm')
|
|
71
42
|
this.statsCounter.llmQueries++;
|
|
@@ -84,21 +55,18 @@ export class FileLearningStore {
|
|
|
84
55
|
}
|
|
85
56
|
}
|
|
86
57
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
this.statsCounter.outOfScope = Math.max(0, this.statsCounter.outOfScope - 1);
|
|
90
|
-
this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
|
|
91
|
-
if (entry.resolvedVia === 'llm')
|
|
92
|
-
this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
|
|
93
|
-
if (entry.resolvedVia === 'cache')
|
|
94
|
-
this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
58
|
+
subtract(entry) {
|
|
59
|
+
// Shared counter decrements regardless of capabilityId
|
|
97
60
|
this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
|
|
98
61
|
if (entry.resolvedVia === 'llm')
|
|
99
62
|
this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
|
|
100
63
|
if (entry.resolvedVia === 'cache')
|
|
101
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
|
|
102
70
|
const words = entry.query.toLowerCase()
|
|
103
71
|
.split(/\W+/)
|
|
104
72
|
.filter(w => w.length > 2 && !STOPWORDS.has(w));
|
|
@@ -115,22 +83,102 @@ export class FileLearningStore {
|
|
|
115
83
|
}
|
|
116
84
|
}
|
|
117
85
|
}
|
|
118
|
-
|
|
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() {
|
|
119
94
|
this.index = {};
|
|
120
95
|
this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
|
|
121
|
-
|
|
122
|
-
|
|
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); });
|
|
123
128
|
}
|
|
124
129
|
}
|
|
125
|
-
|
|
126
|
-
|
|
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)
|
|
127
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() {
|
|
128
176
|
try {
|
|
129
177
|
const raw = await fs.promises.readFile(this.filePath, 'utf-8');
|
|
130
178
|
const parsed = JSON.parse(raw);
|
|
131
179
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Array.isArray(parsed.entries)) {
|
|
132
180
|
this.entries = parsed.entries;
|
|
133
|
-
this.
|
|
181
|
+
this.learningIndex.rebuild(this.entries);
|
|
134
182
|
logger.debug(`Learning store loaded: ${this.entries.length} entries`);
|
|
135
183
|
}
|
|
136
184
|
else {
|
|
@@ -140,7 +188,19 @@ export class FileLearningStore {
|
|
|
140
188
|
catch {
|
|
141
189
|
// File doesn't exist yet — start fresh
|
|
142
190
|
}
|
|
143
|
-
|
|
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
|
+
}
|
|
144
204
|
}
|
|
145
205
|
save() {
|
|
146
206
|
this.saveQueue = this.saveQueue.then(() => this._doSave());
|
|
@@ -150,10 +210,12 @@ export class FileLearningStore {
|
|
|
150
210
|
try {
|
|
151
211
|
const dir = path.dirname(this.filePath);
|
|
152
212
|
await fs.promises.mkdir(dir, { recursive: true });
|
|
153
|
-
|
|
213
|
+
const tmp = `${this.filePath}.tmp`;
|
|
214
|
+
await fs.promises.writeFile(tmp, JSON.stringify({
|
|
154
215
|
entries: this.entries,
|
|
155
216
|
updatedAt: new Date().toISOString(),
|
|
156
217
|
}, null, 2));
|
|
218
|
+
await fs.promises.rename(tmp, this.filePath);
|
|
157
219
|
}
|
|
158
220
|
catch (err) {
|
|
159
221
|
logger.warn(`Failed to save learning store to ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -173,34 +235,40 @@ export class FileLearningStore {
|
|
|
173
235
|
.join(' '),
|
|
174
236
|
};
|
|
175
237
|
this.entries.push(sanitized);
|
|
176
|
-
this.
|
|
238
|
+
this.learningIndex.update(sanitized);
|
|
177
239
|
if (this.entries.length > MAX_LEARNING_ENTRIES) {
|
|
178
240
|
const excess = this.entries.length - MAX_LEARNING_ENTRIES;
|
|
179
241
|
const pruned = this.entries.splice(0, excess);
|
|
180
242
|
// Subtract pruned entries from index — O(pruned × w) instead of O(n × w) full rebuild
|
|
181
243
|
for (const entry of pruned) {
|
|
182
|
-
this.
|
|
244
|
+
this.learningIndex.subtract(entry);
|
|
183
245
|
}
|
|
184
246
|
logger.debug(`Learning store pruned ${excess} oldest entries (cap: ${MAX_LEARNING_ENTRIES})`);
|
|
185
247
|
}
|
|
186
|
-
|
|
248
|
+
this.scheduleSave();
|
|
187
249
|
}
|
|
188
250
|
async getStats() {
|
|
189
251
|
await this.load();
|
|
190
|
-
return
|
|
252
|
+
return this.learningIndex.getStats();
|
|
191
253
|
}
|
|
192
254
|
async getIndex() {
|
|
193
255
|
await this.load();
|
|
194
|
-
return
|
|
256
|
+
return this.learningIndex.getIndex();
|
|
195
257
|
}
|
|
196
258
|
async getTopCapabilities(limit = 5) {
|
|
197
259
|
await this.load();
|
|
198
260
|
return computeTopCapabilities(this.entries, limit);
|
|
199
261
|
}
|
|
200
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;
|
|
201
270
|
this.entries = [];
|
|
202
|
-
this.
|
|
203
|
-
this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
|
|
271
|
+
this.learningIndex.reset();
|
|
204
272
|
await this.save();
|
|
205
273
|
}
|
|
206
274
|
}
|
|
@@ -208,10 +276,7 @@ export class FileLearningStore {
|
|
|
208
276
|
export class MemoryLearningStore {
|
|
209
277
|
constructor() {
|
|
210
278
|
this.entries = [];
|
|
211
|
-
this.
|
|
212
|
-
this.statsCounter = {
|
|
213
|
-
totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0,
|
|
214
|
-
};
|
|
279
|
+
this.learningIndex = new LearningIndex();
|
|
215
280
|
}
|
|
216
281
|
async record(entry) {
|
|
217
282
|
const sanitized = {
|
|
@@ -223,77 +288,29 @@ export class MemoryLearningStore {
|
|
|
223
288
|
.join(' '),
|
|
224
289
|
};
|
|
225
290
|
this.entries.push(sanitized);
|
|
226
|
-
this.
|
|
291
|
+
this.learningIndex.update(sanitized);
|
|
227
292
|
if (this.entries.length > MAX_LEARNING_ENTRIES) {
|
|
228
293
|
const excess = this.entries.length - MAX_LEARNING_ENTRIES;
|
|
229
294
|
const pruned = this.entries.splice(0, excess);
|
|
230
295
|
for (const entry of pruned) {
|
|
231
|
-
this.
|
|
296
|
+
this.learningIndex.subtract(entry);
|
|
232
297
|
}
|
|
233
298
|
}
|
|
234
299
|
}
|
|
235
300
|
async getStats() {
|
|
236
|
-
return
|
|
301
|
+
return this.learningIndex.getStats();
|
|
237
302
|
}
|
|
238
303
|
async getIndex() {
|
|
239
|
-
return
|
|
240
|
-
}
|
|
241
|
-
updateIndex(entry) {
|
|
242
|
-
this.statsCounter.totalQueries++;
|
|
243
|
-
if (entry.resolvedVia === 'llm')
|
|
244
|
-
this.statsCounter.llmQueries++;
|
|
245
|
-
if (entry.resolvedVia === 'cache')
|
|
246
|
-
this.statsCounter.cacheHits++;
|
|
247
|
-
if (!entry.capabilityId)
|
|
248
|
-
this.statsCounter.outOfScope++;
|
|
249
|
-
if (entry.capabilityId) {
|
|
250
|
-
const words = entry.query.toLowerCase()
|
|
251
|
-
.split(/\W+/)
|
|
252
|
-
.filter(w => w.length > 2 && !STOPWORDS.has(w));
|
|
253
|
-
for (const word of words) {
|
|
254
|
-
this.index[word] ??= {};
|
|
255
|
-
this.index[word][entry.capabilityId] =
|
|
256
|
-
(this.index[word][entry.capabilityId] ?? 0) + 1;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
subtractFromIndex(entry) {
|
|
261
|
-
if (!entry.capabilityId) {
|
|
262
|
-
this.statsCounter.outOfScope = Math.max(0, this.statsCounter.outOfScope - 1);
|
|
263
|
-
this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
|
|
264
|
-
if (entry.resolvedVia === 'llm')
|
|
265
|
-
this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
|
|
266
|
-
if (entry.resolvedVia === 'cache')
|
|
267
|
-
this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
|
|
271
|
-
if (entry.resolvedVia === 'llm')
|
|
272
|
-
this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
|
|
273
|
-
if (entry.resolvedVia === 'cache')
|
|
274
|
-
this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
|
|
275
|
-
const words = entry.query.toLowerCase()
|
|
276
|
-
.split(/\W+/)
|
|
277
|
-
.filter(w => w.length > 2 && !STOPWORDS.has(w));
|
|
278
|
-
for (const word of words) {
|
|
279
|
-
if (!this.index[word])
|
|
280
|
-
continue;
|
|
281
|
-
this.index[word][entry.capabilityId] =
|
|
282
|
-
(this.index[word][entry.capabilityId] ?? 1) - 1;
|
|
283
|
-
if (this.index[word][entry.capabilityId] <= 0) {
|
|
284
|
-
delete this.index[word][entry.capabilityId];
|
|
285
|
-
}
|
|
286
|
-
if (Object.keys(this.index[word]).length === 0) {
|
|
287
|
-
delete this.index[word];
|
|
288
|
-
}
|
|
289
|
-
}
|
|
304
|
+
return this.learningIndex.getIndex();
|
|
290
305
|
}
|
|
291
306
|
async getTopCapabilities(limit = 5) {
|
|
292
307
|
return computeTopCapabilities(this.entries, limit);
|
|
293
308
|
}
|
|
294
309
|
async clear() {
|
|
295
310
|
this.entries = [];
|
|
296
|
-
this.
|
|
297
|
-
|
|
311
|
+
this.learningIndex.reset();
|
|
312
|
+
}
|
|
313
|
+
async destroy() {
|
|
314
|
+
// No-op for memory store — nothing to flush or deregister
|
|
298
315
|
}
|
|
299
316
|
}
|
package/dist/esm/matcher.d.ts
CHANGED
|
@@ -6,7 +6,6 @@ export declare const STOPWORDS: Set<string>;
|
|
|
6
6
|
export declare function resolverToIntent(cap: Capability): MatchResult['intent'];
|
|
7
7
|
/**
|
|
8
8
|
* Extracts parameter values from a user query using keyword heuristics.
|
|
9
|
-
*
|
|
10
9
|
* Known limits:
|
|
11
10
|
* - Extracts single tokens only — "jane smith" would extract "jane"
|
|
12
11
|
* - Keyword matching is positional — "articles from authors I follow"
|
|
@@ -15,7 +14,11 @@ export declare function resolverToIntent(cap: Capability): MatchResult['intent']
|
|
|
15
14
|
* param extraction more accurately via the LLM prompt
|
|
16
15
|
*/
|
|
17
16
|
export declare function extractParams(query: string, cap: Capability): Record<string, string | null>;
|
|
18
|
-
export
|
|
17
|
+
export interface MatchOptions {
|
|
18
|
+
fuzzyMatch?: boolean;
|
|
19
|
+
fuzzyThreshold?: number;
|
|
20
|
+
}
|
|
21
|
+
export declare function match(query: string, manifest: Manifest, options?: MatchOptions): MatchResult;
|
|
19
22
|
export interface LLMMatcherOptions {
|
|
20
23
|
llm: (prompt: string) => Promise<string>;
|
|
21
24
|
}
|
package/dist/esm/matcher.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { logger } from './logger';
|
|
2
|
+
import Fuse from 'fuse.js';
|
|
2
3
|
// ─── Typed error for LLM parse failures ──────────────────────────────────────
|
|
3
4
|
export class LLMParseError extends Error {
|
|
4
5
|
constructor(message) {
|
|
@@ -24,14 +25,20 @@ function scoreCapability(query, cap) {
|
|
|
24
25
|
const q = query.toLowerCase();
|
|
25
26
|
let score = 0;
|
|
26
27
|
const qWords = filterStopwords(q.split(/\W+/).filter(Boolean));
|
|
27
|
-
// Check examples —
|
|
28
|
+
// Check examples — take the best single example match, not the sum.
|
|
29
|
+
// Accumulating across examples rewards bloated example lists over precise ones:
|
|
30
|
+
// 10 examples at 50% overlap = 300 points (clamped to 60) beats 1 perfect example at 60.
|
|
31
|
+
// Taking Math.max means quality of examples matters, not quantity.
|
|
32
|
+
let bestExampleScore = 0;
|
|
28
33
|
for (const example of cap.examples ?? []) {
|
|
29
34
|
const exWords = filterStopwords(example.toLowerCase().split(/\s+/));
|
|
30
35
|
if (exWords.length === 0)
|
|
31
36
|
continue;
|
|
32
37
|
const overlap = exWords.filter(w => qWords.includes(w)).length;
|
|
33
|
-
|
|
38
|
+
const contribution = (overlap / exWords.length) * 60;
|
|
39
|
+
bestExampleScore = Math.max(bestExampleScore, contribution);
|
|
34
40
|
}
|
|
41
|
+
score += bestExampleScore;
|
|
35
42
|
// Check description words
|
|
36
43
|
const descWords = filterStopwords(cap.description.toLowerCase().split(/\W+/).filter(Boolean));
|
|
37
44
|
if (descWords.length > 0) {
|
|
@@ -58,7 +65,6 @@ export function resolverToIntent(cap) {
|
|
|
58
65
|
}
|
|
59
66
|
/**
|
|
60
67
|
* Extracts parameter values from a user query using keyword heuristics.
|
|
61
|
-
*
|
|
62
68
|
* Known limits:
|
|
63
69
|
* - Extracts single tokens only — "jane smith" would extract "jane"
|
|
64
70
|
* - Keyword matching is positional — "articles from authors I follow"
|
|
@@ -136,7 +142,7 @@ export function extractParams(query, cap) {
|
|
|
136
142
|
}
|
|
137
143
|
return result;
|
|
138
144
|
}
|
|
139
|
-
export function match(query, manifest) {
|
|
145
|
+
export function match(query, manifest, options = {}) {
|
|
140
146
|
if (!query?.trim()) {
|
|
141
147
|
logger.warn('Empty query received');
|
|
142
148
|
return {
|
|
@@ -148,15 +154,65 @@ export function match(query, manifest) {
|
|
|
148
154
|
candidates: [],
|
|
149
155
|
};
|
|
150
156
|
}
|
|
151
|
-
logger.info(`Matching query
|
|
157
|
+
logger.info(`Matching query (${query.length} chars)`);
|
|
158
|
+
logger.debug(`Full query: "${query}"`);
|
|
152
159
|
logger.debug(`Manifest has ${manifest.capabilities.length} capabilities`);
|
|
153
160
|
let best = null;
|
|
154
161
|
let bestScore = 0;
|
|
162
|
+
// ── Build Fuse index once per match() call ────────────────────────────────
|
|
163
|
+
// Flat corpus — each example/description/name is its own searchable record,
|
|
164
|
+
// tagged with the owning capability id. This avoids two pitfalls of using
|
|
165
|
+
// Fuse's multi-key mode here:
|
|
166
|
+
// (1) joining examples into one string dilutes single-example matches,
|
|
167
|
+
// (2) multi-key weighted aggregation mixes good and bad field matches
|
|
168
|
+
// when we actually want the best single match across all fields.
|
|
169
|
+
// After searching, we group hits by capability and take the BEST score.
|
|
170
|
+
// Field prioritization (examples > description > name) is already applied
|
|
171
|
+
// by the keyword scorer (60/30/10 weights in scoreCapability), so fuzzy
|
|
172
|
+
// here is a pure similarity signal.
|
|
173
|
+
const fuzzyScoreMap = new Map();
|
|
174
|
+
if (options.fuzzyMatch) {
|
|
175
|
+
const corpus = [];
|
|
176
|
+
for (const cap of manifest.capabilities) {
|
|
177
|
+
for (const ex of cap.examples ?? []) {
|
|
178
|
+
if (ex?.trim())
|
|
179
|
+
corpus.push({ capabilityId: cap.id, text: ex });
|
|
180
|
+
}
|
|
181
|
+
if (cap.description?.trim())
|
|
182
|
+
corpus.push({ capabilityId: cap.id, text: cap.description });
|
|
183
|
+
if (cap.name?.trim())
|
|
184
|
+
corpus.push({ capabilityId: cap.id, text: cap.name });
|
|
185
|
+
}
|
|
186
|
+
if (corpus.length > 0) {
|
|
187
|
+
const fuse = new Fuse(corpus, {
|
|
188
|
+
keys: ['text'],
|
|
189
|
+
threshold: options.fuzzyThreshold ?? 0.4,
|
|
190
|
+
includeScore: true,
|
|
191
|
+
ignoreLocation: true,
|
|
192
|
+
minMatchCharLength: 3,
|
|
193
|
+
});
|
|
194
|
+
// Group hits by capability, keeping the best (lowest fuse score = highest similarity).
|
|
195
|
+
// Convert to 0-100 contribution: fuseScore 0.0 = 100%, fuseScore 1.0 = 0%.
|
|
196
|
+
// Multiplier 100 (not 60) lets a strong fuzzy match alone reach the standard
|
|
197
|
+
// 50% confidence cutoff for typo-only queries that have no keyword overlap.
|
|
198
|
+
for (const hit of fuse.search(query)) {
|
|
199
|
+
const capId = hit.item.capabilityId;
|
|
200
|
+
const contribution = (1 - (hit.score ?? 1)) * 100;
|
|
201
|
+
const existing = fuzzyScoreMap.get(capId) ?? 0;
|
|
202
|
+
if (contribution > existing)
|
|
203
|
+
fuzzyScoreMap.set(capId, contribution);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// ── Score all capabilities ────────────────────────────────────────────────
|
|
155
208
|
const allScores = [];
|
|
156
209
|
for (const cap of manifest.capabilities) {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
210
|
+
const keywordScore = scoreCapability(query, cap);
|
|
211
|
+
const fuzzyScore = fuzzyScoreMap.get(cap.id) ?? 0;
|
|
212
|
+
const via = fuzzyScore > keywordScore ? 'fuzzy' : 'keyword';
|
|
213
|
+
const score = Math.min(100, Math.round(Math.max(keywordScore, fuzzyScore)));
|
|
214
|
+
logger.debug(` scored "${cap.id}": ${score}% (keyword: ${keywordScore}%, fuzzy: ${Math.round(fuzzyScore)}%)`);
|
|
215
|
+
allScores.push({ cap, score, via });
|
|
160
216
|
if (score > bestScore) {
|
|
161
217
|
bestScore = score;
|
|
162
218
|
best = cap;
|
|
@@ -168,7 +224,8 @@ export function match(query, manifest) {
|
|
|
168
224
|
matched: cap.id === best?.id,
|
|
169
225
|
}));
|
|
170
226
|
if (!best || bestScore < 50) {
|
|
171
|
-
|
|
227
|
+
const bestId = best ? best.id : 'none';
|
|
228
|
+
logger.info(`No match above threshold (best: ${bestScore}% for "${bestId}")`);
|
|
172
229
|
// Out of scope return:
|
|
173
230
|
return {
|
|
174
231
|
capability: null,
|
|
@@ -182,13 +239,16 @@ export function match(query, manifest) {
|
|
|
182
239
|
const params = extractParams(query, best);
|
|
183
240
|
logger.info(`Matched "${best.id}" at ${bestScore}% confidence`);
|
|
184
241
|
logger.debug(`Extracted params: ${JSON.stringify(Object.fromEntries(Object.entries(params).map(([k, v]) => [k, v != null ? '[REDACTED]' : 'null'])))}`);
|
|
242
|
+
// Use the via tag tracked during scoring — avoids redundant scoreCapability call.
|
|
243
|
+
const bestEntry = allScores.find(s => s.cap.id === best.id);
|
|
244
|
+
const winner = bestEntry?.via === 'fuzzy' ? 'fuzzy match' : 'keyword scoring';
|
|
185
245
|
// Matched return:
|
|
186
246
|
return {
|
|
187
247
|
capability: best,
|
|
188
248
|
confidence: bestScore,
|
|
189
249
|
intent: resolverToIntent(best),
|
|
190
250
|
extractedParams: params,
|
|
191
|
-
reasoning: `Matched "${best.id}" via
|
|
251
|
+
reasoning: `Matched "${best.id}" via ${winner} (score: ${bestScore})`,
|
|
192
252
|
candidates,
|
|
193
253
|
};
|
|
194
254
|
}
|
package/dist/esm/parser.js
CHANGED
|
@@ -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>;
|