capman 0.5.2 → 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 +28 -0
- package/CODEBASE.md +2 -1
- 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.map +1 -1
- package/dist/cjs/engine.js +35 -17
- 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.map +1 -1
- package/dist/cjs/matcher.js +2 -1
- 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.js +35 -17
- 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.js +2 -1
- 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 +1 -1
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.js
CHANGED
|
@@ -148,7 +148,8 @@ export function match(query, manifest) {
|
|
|
148
148
|
candidates: [],
|
|
149
149
|
};
|
|
150
150
|
}
|
|
151
|
-
logger.info(`Matching query
|
|
151
|
+
logger.info(`Matching query (${query.length} chars)`);
|
|
152
|
+
logger.debug(`Full query: "${query}"`);
|
|
152
153
|
logger.debug(`Manifest has ${manifest.capabilities.length} capabilities`);
|
|
153
154
|
let best = null;
|
|
154
155
|
let bestScore = 0;
|
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>;
|
package/dist/esm/resolver.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { logger } from './logger';
|
|
2
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
3
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
2
4
|
function redactParams(params) {
|
|
3
5
|
return Object.fromEntries(Object.entries(params).map(([k, v]) => [k, v != null ? '[REDACTED]' : 'null']));
|
|
4
6
|
}
|
|
@@ -49,12 +51,8 @@ export async function resolve(matchResult, params = {}, options = {}) {
|
|
|
49
51
|
// they must never leak into the query string as ?user_id=xyz
|
|
50
52
|
const enrichedParams = { ...params };
|
|
51
53
|
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 : '';
|
|
56
54
|
for (const param of capability.params) {
|
|
57
|
-
if (param.source === 'session'
|
|
55
|
+
if (param.source === 'session') {
|
|
58
56
|
enrichedParams[param.name] = options.auth.userId;
|
|
59
57
|
logger.debug(`Injected session param "${param.name}" (value redacted)`);
|
|
60
58
|
}
|
|
@@ -65,15 +63,18 @@ export async function resolve(matchResult, params = {}, options = {}) {
|
|
|
65
63
|
logger.debug(`Params: ${JSON.stringify(redactParams(params))}`);
|
|
66
64
|
logger.debug(`Options: baseUrl=${options.baseUrl} dryRun=${options.dryRun}`);
|
|
67
65
|
try {
|
|
66
|
+
const sessionParamNames = new Set(capability.params
|
|
67
|
+
.filter(p => p.source === 'session')
|
|
68
|
+
.map(p => p.name));
|
|
68
69
|
switch (resolver.type) {
|
|
69
70
|
case 'api':
|
|
70
|
-
return await resolveApi(resolver, enrichedParams, options);
|
|
71
|
+
return await resolveApi(resolver, enrichedParams, options, sessionParamNames);
|
|
71
72
|
case 'nav':
|
|
72
73
|
return resolveNav(resolver, enrichedParams);
|
|
73
74
|
case 'hybrid': {
|
|
74
75
|
logger.debug('Hybrid resolver — running API and nav in parallel');
|
|
75
76
|
const [apiResult, navResult] = await Promise.all([
|
|
76
|
-
resolveApi(resolver.api, enrichedParams, options),
|
|
77
|
+
resolveApi(resolver.api, enrichedParams, options, sessionParamNames),
|
|
77
78
|
Promise.resolve(resolveNav(resolver.nav, enrichedParams)),
|
|
78
79
|
]);
|
|
79
80
|
return {
|
|
@@ -109,15 +110,26 @@ export async function resolve(matchResult, params = {}, options = {}) {
|
|
|
109
110
|
* For capabilities where ordering or rollback matters, define separate capabilities
|
|
110
111
|
* with single endpoints and orchestrate them at the application layer.
|
|
111
112
|
*/
|
|
112
|
-
async function resolveApi(resolver, params, options) {
|
|
113
|
+
async function resolveApi(resolver, params, options, sessionParamNames = new Set()) {
|
|
113
114
|
const startTime = Date.now();
|
|
114
115
|
const retries = options.retries ?? 0;
|
|
115
116
|
const timeoutMs = options.timeoutMs ?? 5000;
|
|
116
|
-
const apiCalls = resolver.endpoints.map(endpoint =>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
const apiCalls = resolver.endpoints.map(endpoint => {
|
|
118
|
+
// Build per-endpoint params — only inject session params if this
|
|
119
|
+
// specific endpoint has the placeholder. Prevents userId leaking
|
|
120
|
+
// as ?user_id=xyz on endpoints that don't use it in their path.
|
|
121
|
+
const endpointParams = { ...params };
|
|
122
|
+
for (const name of sessionParamNames) {
|
|
123
|
+
if (!endpoint.path.includes(`{${name}}`)) {
|
|
124
|
+
delete endpointParams[name]; // strip session param — not in this endpoint's path
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
method: endpoint.method,
|
|
129
|
+
url: buildUrl(options.baseUrl ?? '', endpoint.path, endpointParams),
|
|
130
|
+
params: Object.fromEntries(Object.entries(endpointParams).filter(([, v]) => v !== null && v !== undefined)),
|
|
131
|
+
};
|
|
132
|
+
});
|
|
121
133
|
if (options.dryRun) {
|
|
122
134
|
return { success: true, resolverType: 'api', apiCalls, durationMs: Date.now() - startTime };
|
|
123
135
|
}
|
|
@@ -130,9 +142,14 @@ async function resolveApi(resolver, params, options) {
|
|
|
130
142
|
};
|
|
131
143
|
}
|
|
132
144
|
// ── Fetch with retry + timeout (iterative — no recursion) ────────────────
|
|
145
|
+
// Only retry safe/idempotent methods — retrying POST/PUT/PATCH/DELETE
|
|
146
|
+
// can cause duplicate side effects (e.g. duplicate orders, double charges).
|
|
133
147
|
async function fetchWithRetry(call) {
|
|
148
|
+
const effectiveRetries = (options.retryAllMethods || SAFE_METHODS.has(call.method))
|
|
149
|
+
? retries
|
|
150
|
+
: 0;
|
|
134
151
|
let lastErr;
|
|
135
|
-
for (let attempt = 0; attempt <=
|
|
152
|
+
for (let attempt = 0; attempt <= effectiveRetries; attempt++) {
|
|
136
153
|
const controller = new AbortController();
|
|
137
154
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
138
155
|
try {
|
|
@@ -151,8 +168,8 @@ async function resolveApi(resolver, params, options) {
|
|
|
151
168
|
clearTimeout(timer);
|
|
152
169
|
lastErr = err;
|
|
153
170
|
const isTimeout = err instanceof Error && err.name === 'AbortError';
|
|
154
|
-
if (attempt <
|
|
155
|
-
logger.warn(`Request failed (attempt ${attempt + 1}/${
|
|
171
|
+
if (attempt < effectiveRetries) {
|
|
172
|
+
logger.warn(`Request failed (attempt ${attempt + 1}/${effectiveRetries + 1}) — retrying: ${isTimeout ? 'timeout' : err}`);
|
|
156
173
|
}
|
|
157
174
|
else {
|
|
158
175
|
throw isTimeout ? new Error(`Request timed out after ${timeoutMs}ms`) : err;
|
|
@@ -209,12 +226,17 @@ function resolveNav(resolver, params) {
|
|
|
209
226
|
}
|
|
210
227
|
return { success: true, resolverType: 'nav', navTarget: destination };
|
|
211
228
|
}
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
229
|
+
function validateApiPathParam(key, value) {
|
|
230
|
+
// Prevent path traversal via unencoded slashes — encodeURIComponent does not
|
|
231
|
+
// encode '/' so a value like '../../admin' would traverse the path hierarchy.
|
|
232
|
+
// This mirrors the allowlist validation already applied in resolveNav().
|
|
233
|
+
if (!/^[a-zA-Z0-9_\-.:@]+$/.test(value)) {
|
|
234
|
+
throw new Error(`API path param "${key}" contains invalid characters: "${value}". ` +
|
|
235
|
+
`Only alphanumeric, hyphens, underscores, dots, colons, and @ are allowed.`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Both buildUrl (API) and resolveNav (nav) validate path param values against
|
|
239
|
+
// an allowlist before substitution — prevents path traversal via unencoded slashes.
|
|
218
240
|
function buildUrl(baseUrl, urlPath, params) {
|
|
219
241
|
let resolved = urlPath;
|
|
220
242
|
const unused = {};
|
|
@@ -222,7 +244,9 @@ function buildUrl(baseUrl, urlPath, params) {
|
|
|
222
244
|
if (value === null || value === undefined)
|
|
223
245
|
continue; // never write null into URLs
|
|
224
246
|
if (resolved.includes(`{${key}}`)) {
|
|
225
|
-
|
|
247
|
+
const str = String(value);
|
|
248
|
+
validateApiPathParam(key, str);
|
|
249
|
+
resolved = resolved.replaceAll(`{${key}}`, encodeURIComponent(str));
|
|
226
250
|
}
|
|
227
251
|
else {
|
|
228
252
|
unused[key] = value;
|