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
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Primary node — and so the suite can drive it with a scripted transport.
|
|
7
7
|
|
|
8
8
|
const { isBlank } = require("./output.js");
|
|
9
|
+
const { collapsed, ratiosOf } = require("./recipes.js");
|
|
9
10
|
|
|
10
11
|
// --- The pagination loop ---
|
|
11
12
|
//
|
|
@@ -89,22 +90,19 @@ async function extractAll(sendCommand, args) {
|
|
|
89
90
|
// Fill rates against the first page. A spec learned on page 1 degrades
|
|
90
91
|
// quietly later: variant card layouts, a column that stops being populated.
|
|
91
92
|
// Halting beats emitting rows that are 40% empty.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
//
|
|
94
|
+
// The staleness rule itself lives in recipes.js, because a stored recipe's
|
|
95
|
+
// baseline has to be judged by exactly the same threshold as a paginated
|
|
96
|
+
// run's.
|
|
97
|
+
const ratios = ratiosOf(res);
|
|
96
98
|
if (!baseline) {
|
|
97
99
|
baseline = ratios;
|
|
98
|
-
} else
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
baseline[name] >= 0.5 &&
|
|
102
|
-
ratios[name] < baseline[name] * fill_tolerance,
|
|
103
|
-
);
|
|
104
|
-
if (collapsed.length) {
|
|
100
|
+
} else {
|
|
101
|
+
const stale = collapsed(baseline, ratios, fill_tolerance);
|
|
102
|
+
if (stale.length) {
|
|
105
103
|
stopped_because = "fill_rate_deviation";
|
|
106
104
|
warnings.push(
|
|
107
|
-
`page ${page}: ${
|
|
105
|
+
`page ${page}: ${stale
|
|
108
106
|
.map(
|
|
109
107
|
(n) =>
|
|
110
108
|
`${n} ${(ratios[n] * 100).toFixed(0)}% vs baseline ${(baseline[n] * 100).toFixed(0)}%`,
|
package/mcp-server/index.js
CHANGED
|
@@ -13,7 +13,26 @@ const { SMART_SCROLL_SETTLE_MS, POLTERTAB_HOME } = require("./config.js");
|
|
|
13
13
|
const updates = require("./update-check.js");
|
|
14
14
|
const { BROWSER_TOOLS } = require("./tools.js");
|
|
15
15
|
const { writeOutput, summarizeOutput, rowsOf } = require("./output.js");
|
|
16
|
-
const {
|
|
16
|
+
const {
|
|
17
|
+
readMemory,
|
|
18
|
+
saveMemory,
|
|
19
|
+
getSelector,
|
|
20
|
+
recordSelector,
|
|
21
|
+
noteSelectorFail,
|
|
22
|
+
selectorKey,
|
|
23
|
+
pathPattern,
|
|
24
|
+
listRecipeSummaries,
|
|
25
|
+
} = require("./memory.js");
|
|
26
|
+
const {
|
|
27
|
+
hydrate,
|
|
28
|
+
observe,
|
|
29
|
+
noteStep,
|
|
30
|
+
noteNavigation,
|
|
31
|
+
redactValue,
|
|
32
|
+
hintFor,
|
|
33
|
+
describeRecipes,
|
|
34
|
+
RECIPES_NOTE,
|
|
35
|
+
} = require("./recipes.js");
|
|
17
36
|
const { extractAll } = require("./extract-all.js");
|
|
18
37
|
const bridge = require("./bridge.js");
|
|
19
38
|
const OWN_VERSION = require("./../package.json").version;
|
|
@@ -53,6 +72,103 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
53
72
|
};
|
|
54
73
|
});
|
|
55
74
|
|
|
75
|
+
// Which page a tab (or a named session) is on, learned from the URL any
|
|
76
|
+
// navigate/get_url/action result reveals — no extra round-trip to ask. The
|
|
77
|
+
// selector store is keyed by this, so it has to be the page the click actually
|
|
78
|
+
// lands on.
|
|
79
|
+
const pageByTab = new Map();
|
|
80
|
+
const pageBySession = new Map();
|
|
81
|
+
let lastPage = null;
|
|
82
|
+
|
|
83
|
+
function pageOf(url) {
|
|
84
|
+
try {
|
|
85
|
+
const u = new URL(url);
|
|
86
|
+
return u.hostname ? { host: u.hostname, path: u.pathname } : null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function rememberPage(args, result) {
|
|
93
|
+
const page = pageOf(result && result.url);
|
|
94
|
+
if (!page) return;
|
|
95
|
+
const tabId = (result && result.tabId) ?? (args && args.tabId);
|
|
96
|
+
if (tabId != null) pageByTab.set(tabId, page);
|
|
97
|
+
if (args && args.session) pageBySession.set(args.session, page);
|
|
98
|
+
lastPage = page;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// An explicitly targeted tab or session resolves to ITS OWN page or to nothing.
|
|
102
|
+
// Falling back to a process-global here is how a click in session s1 ended up
|
|
103
|
+
// keyed to whatever host s2 had navigated to most recently — and `session` is
|
|
104
|
+
// the documented multi-tab mechanism, so that path is normal usage, not a
|
|
105
|
+
// corner case. No page means no healing, which is the safe direction.
|
|
106
|
+
function pageFor(args) {
|
|
107
|
+
const a = args || {};
|
|
108
|
+
if (a.session) return pageBySession.get(a.session) || null;
|
|
109
|
+
if (a.tabId != null) return pageByTab.get(a.tabId) || null;
|
|
110
|
+
return lastPage;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The step buffer's key, resolved by exactly the same rule as pageFor: an
|
|
114
|
+
// explicit session or tab gets its own and never falls through to the
|
|
115
|
+
// process-global one. Without that, two concurrent tasks in two tabs borrow
|
|
116
|
+
// each other's preamble — the O13 failure, one layer up.
|
|
117
|
+
function targetKeyFor(args) {
|
|
118
|
+
const a = args || {};
|
|
119
|
+
if (a.session) return `session:${a.session}`;
|
|
120
|
+
if (a.tabId != null) return `tab:${a.tabId}`;
|
|
121
|
+
return "last";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Actions worth remembering as a recipe's preamble: the facet click, the
|
|
125
|
+
// "100 per page", the scroll that loaded the list.
|
|
126
|
+
const RECORDED = new Set(["click", "fill", "hover", "scroll", "smart_scroll"]);
|
|
127
|
+
|
|
128
|
+
// Bookkeeping for an action that has already happened, so it can never throw
|
|
129
|
+
// at the caller. A fill's value goes through redactValue and nowhere else: the
|
|
130
|
+
// store is a plaintext file in the user's home directory.
|
|
131
|
+
function noteStepFor(action, args, targetKey, page, result) {
|
|
132
|
+
try {
|
|
133
|
+
const { value, redacted } = redactValue(
|
|
134
|
+
action,
|
|
135
|
+
args,
|
|
136
|
+
result && result.fingerprint,
|
|
137
|
+
);
|
|
138
|
+
noteStep(targetKey, {
|
|
139
|
+
action,
|
|
140
|
+
selector: (args && args.selector) || null,
|
|
141
|
+
path: page && page.path,
|
|
142
|
+
host: page && page.host,
|
|
143
|
+
// Nothing at all for a redacted fill, not the null redactValue reports:
|
|
144
|
+
// a stored `value: null` reads back as "this field was filled with
|
|
145
|
+
// nothing", which is a different step from the one that happened.
|
|
146
|
+
value: redacted ? undefined : value,
|
|
147
|
+
redacted,
|
|
148
|
+
});
|
|
149
|
+
} catch (_) {
|
|
150
|
+
// A preamble we failed to record is a recipe with fewer steps, not a
|
|
151
|
+
// failed action.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Recall has to be implicit — the tool surface is already 23 schemas on every
|
|
156
|
+
// request — so a navigate says what has been learned about where it landed.
|
|
157
|
+
// One memory read, and nothing at all on a host with no recipes.
|
|
158
|
+
function onNavigate(args, targetKey, result) {
|
|
159
|
+
try {
|
|
160
|
+
const dest = pageOf(result && result.url);
|
|
161
|
+
if (!dest) return result;
|
|
162
|
+
noteNavigation(targetKey, dest.host);
|
|
163
|
+
const known = listRecipeSummaries(dest.host, pathPattern(dest.path));
|
|
164
|
+
if (!known.length) return result;
|
|
165
|
+
return { ...result, recipes_available: hintFor(known) };
|
|
166
|
+
} catch (_) {
|
|
167
|
+
// The navigation succeeded; a missing hint is the lesser evil.
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
56
172
|
const handleToolCall = async (request) => {
|
|
57
173
|
const { name, arguments: args } = request.params;
|
|
58
174
|
|
|
@@ -106,6 +222,16 @@ const handleToolCall = async (request) => {
|
|
|
106
222
|
const scrollResult = await bridge.sendCommand("scroll", args || {});
|
|
107
223
|
bridge.noteActiveTab(scrollResult && scrollResult.tabId);
|
|
108
224
|
|
|
225
|
+
// The scroll that loaded the list is part of the preamble a recipe
|
|
226
|
+
// replays, and this branch returns before the generic path can see it.
|
|
227
|
+
noteStepFor(
|
|
228
|
+
"smart_scroll",
|
|
229
|
+
args,
|
|
230
|
+
targetKeyFor(args),
|
|
231
|
+
pageFor(args),
|
|
232
|
+
scrollResult,
|
|
233
|
+
);
|
|
234
|
+
|
|
109
235
|
// Wait for network requests to arrive (lazy loading)
|
|
110
236
|
await new Promise((r) => setTimeout(r, SMART_SCROLL_SETTLE_MS));
|
|
111
237
|
|
|
@@ -142,10 +268,18 @@ const handleToolCall = async (request) => {
|
|
|
142
268
|
if (action === "get_site_memory") {
|
|
143
269
|
const host = args.hostname || args.domain || args.url;
|
|
144
270
|
if (!host) throw new Error("Missing 'hostname' parameter");
|
|
271
|
+
// The agent's own notes, plus what it worked out about extracting the
|
|
272
|
+
// site. The selectors map stays out: it is internal plumbing for
|
|
273
|
+
// self-healing and would just be noise here.
|
|
274
|
+
const memory = readMemory(host);
|
|
275
|
+
const payload = { notes: memory.notes };
|
|
276
|
+
const learned = describeRecipes(memory.recipes);
|
|
277
|
+
if (learned.length) {
|
|
278
|
+
payload.recipes = learned;
|
|
279
|
+
payload.recipes_note = RECIPES_NOTE;
|
|
280
|
+
}
|
|
145
281
|
return {
|
|
146
|
-
content: [
|
|
147
|
-
{ type: "text", text: JSON.stringify(readMemory(host), null, 2) },
|
|
148
|
-
],
|
|
282
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
149
283
|
};
|
|
150
284
|
}
|
|
151
285
|
|
|
@@ -160,12 +294,24 @@ const handleToolCall = async (request) => {
|
|
|
160
294
|
|
|
161
295
|
// Loops in the server, not in the model. One tool call covers every page.
|
|
162
296
|
if (action === "extract_all") {
|
|
163
|
-
const payload = await extractAll(bridge.sendCommand, args || {});
|
|
164
297
|
const opts = args || {};
|
|
298
|
+
const targetKey = targetKeyFor(opts);
|
|
299
|
+
// The page a recipe belongs to comes from the template, not from the tab:
|
|
300
|
+
// this tool drives its own navigation, so the tab can be anywhere. The
|
|
301
|
+
// inner sendCommand("extract", spec) calls always carry an explicit spec
|
|
302
|
+
// and so are never hydrated.
|
|
303
|
+
const page = pageOf(String(opts.url_template || "").replace("{page}", "1"));
|
|
304
|
+
const ctx = hydrate("extract_all", opts, page, targetKey);
|
|
305
|
+
|
|
306
|
+
const payload = await extractAll(bridge.sendCommand, opts);
|
|
307
|
+
const patch = ctx
|
|
308
|
+
? observe({ action, args: opts, result: payload, ctx, targetKey })
|
|
309
|
+
: null;
|
|
310
|
+
const out = patch ? { ...payload, ...patch } : payload;
|
|
165
311
|
|
|
166
312
|
if (opts.output_file) {
|
|
167
|
-
const written = writeOutput(opts.output_file,
|
|
168
|
-
const { rows, pages, ...rest } =
|
|
313
|
+
const written = writeOutput(opts.output_file, out, out.rows);
|
|
314
|
+
const { rows, pages, ...rest } = out;
|
|
169
315
|
const summary = {
|
|
170
316
|
...rest,
|
|
171
317
|
...written,
|
|
@@ -180,24 +326,120 @@ const handleToolCall = async (request) => {
|
|
|
180
326
|
}
|
|
181
327
|
|
|
182
328
|
return {
|
|
183
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
329
|
+
content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
|
|
184
330
|
};
|
|
185
331
|
}
|
|
186
332
|
|
|
187
|
-
|
|
333
|
+
// Self-healing: for click/fill, supply a remembered fingerprint when the
|
|
334
|
+
// selector has no explicit one, and remember the fingerprint that worked so
|
|
335
|
+
// a later drift can be relocated. Keyed by the page the action lands on and
|
|
336
|
+
// by the action itself — see memory.selectorKey.
|
|
337
|
+
const healable = action === "click" || action === "fill";
|
|
338
|
+
const selector = args && args.selector;
|
|
339
|
+
// Resolved for every action, not just a healable click: the recipe layer
|
|
340
|
+
// is keyed by the same page the selector store is.
|
|
341
|
+
const page = pageFor(args);
|
|
342
|
+
const key =
|
|
343
|
+
healable && selector && page
|
|
344
|
+
? selectorKey(action, page.path, selector)
|
|
345
|
+
: null;
|
|
346
|
+
if (key && !args.fingerprint) {
|
|
347
|
+
// Reading the store must never stop a click that would otherwise work.
|
|
348
|
+
try {
|
|
349
|
+
const stored = getSelector(page.host, key);
|
|
350
|
+
if (stored) args.fingerprint = stored.fingerprint;
|
|
351
|
+
} catch (_) {
|
|
352
|
+
// Unreadable store: proceed on the caller's own selector.
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Recipes live in the generic path on purpose: hydrate a missing extract
|
|
357
|
+
// spec before the command goes out, judge what came back after. A second
|
|
358
|
+
// `if (action === "extract")` branch would need its own copy of
|
|
359
|
+
// output_file, tab tracking and page learning. Throwing before sendCommand
|
|
360
|
+
// is deliberate too — an extract with no record selector is not a cheap
|
|
361
|
+
// mistake, it returns the page's whole text as one row.
|
|
362
|
+
const targetKey = targetKeyFor(args);
|
|
363
|
+
// targetKey, not just the page: hydrate matches a missing `variant`
|
|
364
|
+
// against the steps this server watched the caller issue on this target.
|
|
365
|
+
const ctx = hydrate(action, args, page, targetKey);
|
|
366
|
+
|
|
367
|
+
let result;
|
|
368
|
+
try {
|
|
369
|
+
result = await bridge.sendCommand(action, args || {});
|
|
370
|
+
} catch (err) {
|
|
371
|
+
// A selector that missed even with its stored fingerprint is drifting;
|
|
372
|
+
// enough misses and memory.js stops trusting it. Bookkeeping only — it
|
|
373
|
+
// must not replace the error the caller needs to see.
|
|
374
|
+
if (key && /not found/i.test(err.message)) {
|
|
375
|
+
try {
|
|
376
|
+
noteSelectorFail(page.host, key);
|
|
377
|
+
} catch (_) {
|
|
378
|
+
// Best effort.
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
throw err;
|
|
382
|
+
}
|
|
188
383
|
bridge.noteActiveTab((result && result.tabId) || (args && args.tabId));
|
|
189
384
|
|
|
385
|
+
// Learn the page for later, and store the fingerprint of what resolved.
|
|
386
|
+
// Wrapped because the browser has ALREADY acted: a failure here would be
|
|
387
|
+
// reported as a failed click, and the agent would retry and double-submit.
|
|
388
|
+
try {
|
|
389
|
+
rememberPage(args, result);
|
|
390
|
+
if (key && result && result.fingerprint) {
|
|
391
|
+
recordSelector(page.host, key, result.fingerprint);
|
|
392
|
+
}
|
|
393
|
+
} catch (_) {
|
|
394
|
+
// The action succeeded; losing the bookkeeping is the lesser evil.
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Recorded only once the browser has confirmed the action, so a stored
|
|
398
|
+
// preamble never replays a click that never landed.
|
|
399
|
+
if (RECORDED.has(action)) {
|
|
400
|
+
noteStepFor(action, args, targetKey, page, result);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// browser_fill echoes back the value it typed. Keeping a password out of
|
|
404
|
+
// the store is no use if the echo puts it in the model's context and from
|
|
405
|
+
// there into the transcript, which is the exposure the redaction denylist
|
|
406
|
+
// exists to close. Only a fill redactValue calls sensitive is touched — a
|
|
407
|
+
// plain fill echoing its value is worth reading. The extension still sends
|
|
408
|
+
// the value over the local WebSocket, so this narrows where a secret lands
|
|
409
|
+
// rather than making the fill secret end to end.
|
|
410
|
+
if (action === "fill" && result && typeof result === "object") {
|
|
411
|
+
try {
|
|
412
|
+
const { redacted } = redactValue(action, args, result.fingerprint);
|
|
413
|
+
if (redacted && "value" in result) {
|
|
414
|
+
result = { ...result, value: "(value withheld)" };
|
|
415
|
+
}
|
|
416
|
+
} catch (_) {
|
|
417
|
+
// Same contract as noteStepFor: the fill has already happened.
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (action === "navigate") result = onNavigate(args, targetKey, result);
|
|
422
|
+
|
|
423
|
+
// Patched before the output_file branch below: a run that writes to disk
|
|
424
|
+
// is exactly the run where the model cannot see the rows for itself, so
|
|
425
|
+
// losing used_recipe/stale there would hide a replay entirely.
|
|
426
|
+
const patch = ctx
|
|
427
|
+
? observe({ action, args, result, ctx, targetKey })
|
|
428
|
+
: null;
|
|
429
|
+
if (patch) result = { ...result, ...patch };
|
|
430
|
+
|
|
190
431
|
// Any read tool can send its payload to disk. Placed after tab tracking so
|
|
191
432
|
// taking the file path does not cost the session its tab bookkeeping.
|
|
192
433
|
if (args && args.output_file && result && typeof result === "object") {
|
|
193
434
|
const rows = rowsOf(result);
|
|
194
435
|
const written = writeOutput(args.output_file, result, rows);
|
|
436
|
+
const summary = summarizeOutput(result, rows, written);
|
|
195
437
|
return {
|
|
196
438
|
content: [
|
|
197
439
|
{
|
|
198
440
|
type: "text",
|
|
199
441
|
text: JSON.stringify(
|
|
200
|
-
|
|
442
|
+
patch ? { ...summary, ...patch } : summary,
|
|
201
443
|
null,
|
|
202
444
|
2,
|
|
203
445
|
),
|