poltertab 1.5.0 → 1.6.0-beta.2
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/mcp-server/extract-all.js +10 -12
- package/mcp-server/index.js +252 -10
- package/mcp-server/memory.js +336 -6
- package/mcp-server/recipes.js +1043 -0
- package/mcp-server/tools.js +52 -7
- package/package.json +1 -1
package/mcp-server/memory.js
CHANGED
|
@@ -65,17 +65,347 @@ function memoryFile(rawHost) {
|
|
|
65
65
|
return path.join(MEMORY_DIR, `${bare}.json`);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Free-text notes are capped so a chatty agent can't grow one file without
|
|
69
|
+
// bound; selectors likewise, evicting the least-recently-used first.
|
|
70
|
+
const NOTES_CAP = 100;
|
|
71
|
+
const SELECTORS_CAP = 200;
|
|
72
|
+
// A learned recipe carries a whole extract spec plus replay steps per variant,
|
|
73
|
+
// so it is orders of magnitude larger than a selector entry — cap it far
|
|
74
|
+
// tighter, and count across path patterns because the file size is what the
|
|
75
|
+
// cap actually protects.
|
|
76
|
+
const RECIPES_CAP = 20;
|
|
77
|
+
// And per recipe: a site with many facet combinations learns one variant per
|
|
78
|
+
// combination under a single name, and evictRecipes counts recipes rather than
|
|
79
|
+
// variants, so without this one recipe on one path pattern grows without
|
|
80
|
+
// bound. Eight is enough slices of one task to be worth keeping.
|
|
81
|
+
const VARIANTS_CAP = 8;
|
|
82
|
+
// A selector that keeps missing even with its fingerprint has drifted past
|
|
83
|
+
// recognition — stop trusting it rather than relocating against a dead signature.
|
|
84
|
+
const MAX_FAILS = 3;
|
|
85
|
+
|
|
86
|
+
// Normalize any on-disk shape — including the original bare array — to the
|
|
87
|
+
// current { notes, selectors, recipes } form. Old files upgrade in place on the
|
|
88
|
+
// next write, so there is no migration step.
|
|
89
|
+
function normalize(raw) {
|
|
90
|
+
if (Array.isArray(raw)) return { notes: raw, selectors: {}, recipes: {} };
|
|
91
|
+
return {
|
|
92
|
+
notes: raw && Array.isArray(raw.notes) ? raw.notes : [],
|
|
93
|
+
selectors:
|
|
94
|
+
raw && raw.selectors && typeof raw.selectors === "object"
|
|
95
|
+
? raw.selectors
|
|
96
|
+
: {},
|
|
97
|
+
// An array here would survive the typeof check and then hand out numeric
|
|
98
|
+
// recipe names from Object.keys, so reject anything but a plain object.
|
|
99
|
+
recipes:
|
|
100
|
+
raw &&
|
|
101
|
+
raw.recipes &&
|
|
102
|
+
typeof raw.recipes === "object" &&
|
|
103
|
+
!Array.isArray(raw.recipes)
|
|
104
|
+
? raw.recipes
|
|
105
|
+
: {},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
68
109
|
function readMemory(rawHost) {
|
|
69
110
|
const file = memoryFile(rawHost);
|
|
70
|
-
|
|
111
|
+
if (!fs.existsSync(file)) return normalize(null);
|
|
112
|
+
// getSelector sits on the click/fill path, so an unreadable file here used to
|
|
113
|
+
// brick every click on the host. A truncated or hand-edited file is not worth
|
|
114
|
+
// that: read it as empty and let the action proceed on its own selector.
|
|
115
|
+
try {
|
|
116
|
+
return normalize(JSON.parse(fs.readFileSync(file, "utf8")));
|
|
117
|
+
} catch (_) {
|
|
118
|
+
return normalize(null);
|
|
119
|
+
}
|
|
71
120
|
}
|
|
72
121
|
|
|
73
|
-
|
|
122
|
+
// writeFileSync truncates in place, so a crash — or a second MCP process, which
|
|
123
|
+
// bridge.js explicitly supports — mid-write leaves a half-written file that the
|
|
124
|
+
// next read cannot parse. Write a sibling temp file and rename: rename is atomic
|
|
125
|
+
// within a directory, so a reader sees the old file or the new one, never a
|
|
126
|
+
// partial one.
|
|
127
|
+
function writeMemory(rawHost, data) {
|
|
74
128
|
const file = memoryFile(rawHost);
|
|
129
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
130
|
+
try {
|
|
131
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
132
|
+
fs.renameSync(tmp, file);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
try {
|
|
135
|
+
fs.unlinkSync(tmp);
|
|
136
|
+
} catch (_) {
|
|
137
|
+
// Nothing to clean up.
|
|
138
|
+
}
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The selector store is bookkeeping for an action that already happened, so a
|
|
144
|
+
// failed write must never surface as a failed click — the agent would retry and
|
|
145
|
+
// double-submit. saveMemory stays strict: it IS the user's action.
|
|
146
|
+
//
|
|
147
|
+
// Whatever fn returned comes back out, so noteRecipeFail can report what it
|
|
148
|
+
// deleted. A throw reads as undefined, which is the right answer either way: a
|
|
149
|
+
// write that failed deleted nothing.
|
|
150
|
+
function bestEffort(fn) {
|
|
151
|
+
try {
|
|
152
|
+
return fn();
|
|
153
|
+
} catch (_) {
|
|
154
|
+
// Read-only or full home: keep the healing we cannot persist to ourselves.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function saveMemory(rawHost, obstacle, solution) {
|
|
75
159
|
const data = readMemory(rawHost);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
160
|
+
// Re-saving an identical note is a no-op, not another copy of the same line.
|
|
161
|
+
const dup = data.notes.some(
|
|
162
|
+
(n) => n.obstacle === obstacle && n.solution === solution,
|
|
163
|
+
);
|
|
164
|
+
if (!dup) {
|
|
165
|
+
data.notes.push({ obstacle, solution, timestamp: Date.now() });
|
|
166
|
+
if (data.notes.length > NOTES_CAP)
|
|
167
|
+
data.notes = data.notes.slice(-NOTES_CAP);
|
|
168
|
+
}
|
|
169
|
+
writeMemory(rawHost, data);
|
|
170
|
+
return data.notes.length;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Selector entries are keyed by action + page path + selector, not by selector
|
|
174
|
+
// alone. Host-only keying meant a fingerprint learned for "#submit" on
|
|
175
|
+
// /checkout was injected into a click on "#submit" on /settings, and one
|
|
176
|
+
// recorded by fill was injected into a click — different elements, and on
|
|
177
|
+
// destructive controls that is a wrong click, then persisted as the new truth.
|
|
178
|
+
function selectorKey(action, path, selector) {
|
|
179
|
+
return `${action}|${path || "/"}|${selector}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function getSelector(rawHost, selector) {
|
|
183
|
+
return readMemory(rawHost).selectors[selector] || null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function recordSelector(rawHost, selector, fingerprint) {
|
|
187
|
+
if (!fingerprint) return; // nothing worth remembering
|
|
188
|
+
bestEffort(() => {
|
|
189
|
+
const data = readMemory(rawHost);
|
|
190
|
+
data.selectors[selector] = {
|
|
191
|
+
fingerprint,
|
|
192
|
+
lastOk: Date.now(),
|
|
193
|
+
failCount: 0,
|
|
194
|
+
};
|
|
195
|
+
evictSelectors(data.selectors);
|
|
196
|
+
writeMemory(rawHost, data);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function noteSelectorFail(rawHost, selector) {
|
|
201
|
+
bestEffort(() => {
|
|
202
|
+
const data = readMemory(rawHost);
|
|
203
|
+
const entry = data.selectors[selector];
|
|
204
|
+
if (!entry) return;
|
|
205
|
+
entry.failCount = (entry.failCount || 0) + 1;
|
|
206
|
+
if (entry.failCount >= MAX_FAILS) delete data.selectors[selector];
|
|
207
|
+
writeMemory(rawHost, data);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Bound the store: when a host accrues too many selectors, drop the ones whose
|
|
212
|
+
// last success is oldest.
|
|
213
|
+
function evictSelectors(selectors) {
|
|
214
|
+
const keys = Object.keys(selectors);
|
|
215
|
+
if (keys.length <= SELECTORS_CAP) return;
|
|
216
|
+
keys
|
|
217
|
+
.sort((a, b) => (selectors[a].lastOk || 0) - (selectors[b].lastOk || 0))
|
|
218
|
+
.slice(0, keys.length - SELECTORS_CAP)
|
|
219
|
+
.forEach((k) => delete selectors[k]);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Learned extraction recipes ──────────────────────────────────────────────
|
|
223
|
+
//
|
|
224
|
+
// A recipe is what the agent worked out about a listing page: the record
|
|
225
|
+
// selector and field map that produced clean rows, and — per variant, a slice
|
|
226
|
+
// of the same task with its own filters — the steps that got there and the
|
|
227
|
+
// fill rate to expect next time. Keyed by path pattern → recipe name →
|
|
228
|
+
// variant, so /jobs/12345 and /jobs/99 share what was learned once.
|
|
229
|
+
|
|
230
|
+
const UUID_SEGMENT =
|
|
231
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
232
|
+
|
|
233
|
+
// Volatile path segments have to collapse or every record page teaches a recipe
|
|
234
|
+
// that nothing ever reads back: /jobs/12345 is the same page shape as /jobs/99,
|
|
235
|
+
// and a recipe learned on one is what makes the other free.
|
|
236
|
+
function pathPattern(rawPath) {
|
|
237
|
+
let p = rawPath == null ? "" : String(rawPath);
|
|
238
|
+
|
|
239
|
+
// Callers pass a pathname, but what the page reported is often the full URL.
|
|
240
|
+
if (p.includes("://")) {
|
|
241
|
+
try {
|
|
242
|
+
p = new URL(p).pathname;
|
|
243
|
+
} catch {
|
|
244
|
+
// Not parseable as a URL; the query/hash strip below still applies.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
p = p.split("#")[0].split("?")[0];
|
|
248
|
+
|
|
249
|
+
const segments = p
|
|
250
|
+
.split("/")
|
|
251
|
+
.filter(Boolean)
|
|
252
|
+
.map((seg) => {
|
|
253
|
+
if (/^\d+$/.test(seg)) return ":n";
|
|
254
|
+
if (UUID_SEGMENT.test(seg)) return ":id";
|
|
255
|
+
// A long run of letters and digits with no separator is an opaque id.
|
|
256
|
+
// Slugs are excluded by the separator rule on purpose: "senior-engineer-2024"
|
|
257
|
+
// names a page shape, and collapsing it would merge unrelated pages into
|
|
258
|
+
// one recipe.
|
|
259
|
+
if (seg.length >= 12 && /\d/.test(seg) && /^[a-z0-9]+$/i.test(seg))
|
|
260
|
+
return ":id";
|
|
261
|
+
return seg;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
return segments.length ? `/${segments.join("/")}` : "/";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A recipe is as fresh as its freshest variant: one dead filter combination
|
|
268
|
+
// must not make the whole learned spec look stale to eviction.
|
|
269
|
+
function recipeLastOk(recipe) {
|
|
270
|
+
const variants = recipe && recipe.variants ? recipe.variants : {};
|
|
271
|
+
return Object.keys(variants).reduce(
|
|
272
|
+
(max, k) => Math.max(max, (variants[k] && variants[k].lastOk) || 0),
|
|
273
|
+
0,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function getRecipes(rawHost, pattern) {
|
|
278
|
+
return readMemory(rawHost).recipes[pattern] || {};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function getRecipe(rawHost, pattern, name) {
|
|
282
|
+
return getRecipes(rawHost, pattern)[name] || null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Same contract as recordSelector: the extract that taught us this already
|
|
286
|
+
// returned its rows, so a failed write must not surface as a failed extract —
|
|
287
|
+
// the agent would re-run the whole crawl.
|
|
288
|
+
function putRecipe(rawHost, pattern, name, recipe) {
|
|
289
|
+
if (!recipe) return;
|
|
290
|
+
bestEffort(() => {
|
|
291
|
+
const data = readMemory(rawHost);
|
|
292
|
+
const bucket = data.recipes[pattern] || (data.recipes[pattern] = {});
|
|
293
|
+
const existing = bucket[name];
|
|
294
|
+
bucket[name] = {
|
|
295
|
+
extract: recipe.extract || (existing && existing.extract),
|
|
296
|
+
// Merge: variants of one task are learned one run at a time, so writing
|
|
297
|
+
// the second must not forget the first.
|
|
298
|
+
variants: {
|
|
299
|
+
...(existing && existing.variants),
|
|
300
|
+
...(recipe.variants || {}),
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
evictVariants(bucket[name].variants);
|
|
304
|
+
evictRecipes(data.recipes);
|
|
305
|
+
writeMemory(rawHost, data);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// What was deleted, so the caller can say so: a recipe that vanished under the
|
|
310
|
+
// agent is a recipe it will expect to be there next time. Undefined when the
|
|
311
|
+
// write failed — see bestEffort.
|
|
312
|
+
function noteRecipeFail(rawHost, pattern, name, variant) {
|
|
313
|
+
return bestEffort(() => {
|
|
314
|
+
const data = readMemory(rawHost);
|
|
315
|
+
const bucket = data.recipes[pattern];
|
|
316
|
+
const recipe = bucket && bucket[name];
|
|
317
|
+
const entry = recipe && recipe.variants && recipe.variants[variant];
|
|
318
|
+
if (!entry) return { variant: false, recipe: false, fails: 0 };
|
|
319
|
+
entry.failCount = (entry.failCount || 0) + 1;
|
|
320
|
+
const dropped = { variant: false, recipe: false, fails: entry.failCount };
|
|
321
|
+
if (entry.failCount >= MAX_FAILS) {
|
|
322
|
+
delete recipe.variants[variant];
|
|
323
|
+
dropped.variant = true;
|
|
324
|
+
// Unwind the empty levels above it, or the file accumulates husks that
|
|
325
|
+
// every later read has to page in and every hint has to filter out.
|
|
326
|
+
if (!Object.keys(recipe.variants).length) {
|
|
327
|
+
delete bucket[name];
|
|
328
|
+
dropped.recipe = true;
|
|
329
|
+
}
|
|
330
|
+
if (!Object.keys(bucket).length) delete data.recipes[pattern];
|
|
331
|
+
}
|
|
332
|
+
writeMemory(rawHost, data);
|
|
333
|
+
return dropped;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Oldest slice of one task first, by the same rule as evictSelectors. Keyed on
|
|
338
|
+
// lastOk rather than on insertion order because the variant nothing has
|
|
339
|
+
// replayed in weeks is the one worth losing.
|
|
340
|
+
function evictVariants(variants) {
|
|
341
|
+
const keys = Object.keys(variants || {});
|
|
342
|
+
if (keys.length <= VARIANTS_CAP) return;
|
|
343
|
+
keys
|
|
344
|
+
.sort(
|
|
345
|
+
(a, b) =>
|
|
346
|
+
((variants[a] && variants[a].lastOk) || 0) -
|
|
347
|
+
((variants[b] && variants[b].lastOk) || 0),
|
|
348
|
+
)
|
|
349
|
+
.slice(0, keys.length - VARIANTS_CAP)
|
|
350
|
+
.forEach((k) => delete variants[k]);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Bound the store the way evictSelectors does, but across path patterns: a
|
|
354
|
+
// crawler that walks a thousand listing URLs would otherwise learn a recipe per
|
|
355
|
+
// pattern and grow one host's file without limit.
|
|
356
|
+
function evictRecipes(recipes) {
|
|
357
|
+
const entries = [];
|
|
358
|
+
for (const pattern of Object.keys(recipes))
|
|
359
|
+
for (const name of Object.keys(recipes[pattern]))
|
|
360
|
+
entries.push({
|
|
361
|
+
pattern,
|
|
362
|
+
name,
|
|
363
|
+
lastOk: recipeLastOk(recipes[pattern][name]),
|
|
364
|
+
});
|
|
365
|
+
if (entries.length <= RECIPES_CAP) return;
|
|
366
|
+
entries
|
|
367
|
+
.sort((a, b) => a.lastOk - b.lastOk)
|
|
368
|
+
.slice(0, entries.length - RECIPES_CAP)
|
|
369
|
+
.forEach(({ pattern, name }) => {
|
|
370
|
+
delete recipes[pattern][name];
|
|
371
|
+
if (!Object.keys(recipes[pattern]).length) delete recipes[pattern];
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// What a recipe offers, without what it costs: the hint that tells an agent a
|
|
376
|
+
// recipe exists is worth nothing if quoting it costs as much context as the
|
|
377
|
+
// extract it saves, so the field map and the replay steps stay on disk.
|
|
378
|
+
function listRecipeSummaries(rawHost, pattern) {
|
|
379
|
+
const bucket = getRecipes(rawHost, pattern);
|
|
380
|
+
return Object.keys(bucket).map((name) => {
|
|
381
|
+
const recipe = bucket[name];
|
|
382
|
+
const fields = recipe && recipe.extract && recipe.extract.fields;
|
|
383
|
+
return {
|
|
384
|
+
name,
|
|
385
|
+
fields:
|
|
386
|
+
fields && typeof fields === "object" && !Array.isArray(fields)
|
|
387
|
+
? Object.keys(fields)
|
|
388
|
+
: [],
|
|
389
|
+
variants: recipe && recipe.variants ? Object.keys(recipe.variants) : [],
|
|
390
|
+
lastOk: recipeLastOk(recipe),
|
|
391
|
+
};
|
|
392
|
+
});
|
|
79
393
|
}
|
|
80
394
|
|
|
81
|
-
module.exports = {
|
|
395
|
+
module.exports = {
|
|
396
|
+
memoryFile,
|
|
397
|
+
selectorKey,
|
|
398
|
+
readMemory,
|
|
399
|
+
saveMemory,
|
|
400
|
+
getSelector,
|
|
401
|
+
recordSelector,
|
|
402
|
+
noteSelectorFail,
|
|
403
|
+
RECIPES_CAP,
|
|
404
|
+
VARIANTS_CAP,
|
|
405
|
+
pathPattern,
|
|
406
|
+
getRecipes,
|
|
407
|
+
getRecipe,
|
|
408
|
+
putRecipe,
|
|
409
|
+
noteRecipeFail,
|
|
410
|
+
listRecipeSummaries,
|
|
411
|
+
};
|