poltertab 1.5.0 → 1.6.0-beta.1
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/index.js +95 -3
- package/mcp-server/memory.js +132 -6
- package/mcp-server/tools.js +17 -2
- package/package.json +1 -1
package/mcp-server/index.js
CHANGED
|
@@ -13,7 +13,14 @@ 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
|
+
} = require("./memory.js");
|
|
17
24
|
const { extractAll } = require("./extract-all.js");
|
|
18
25
|
const bridge = require("./bridge.js");
|
|
19
26
|
const OWN_VERSION = require("./../package.json").version;
|
|
@@ -53,6 +60,44 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
53
60
|
};
|
|
54
61
|
});
|
|
55
62
|
|
|
63
|
+
// Which page a tab (or a named session) is on, learned from the URL any
|
|
64
|
+
// navigate/get_url/action result reveals — no extra round-trip to ask. The
|
|
65
|
+
// selector store is keyed by this, so it has to be the page the click actually
|
|
66
|
+
// lands on.
|
|
67
|
+
const pageByTab = new Map();
|
|
68
|
+
const pageBySession = new Map();
|
|
69
|
+
let lastPage = null;
|
|
70
|
+
|
|
71
|
+
function pageOf(url) {
|
|
72
|
+
try {
|
|
73
|
+
const u = new URL(url);
|
|
74
|
+
return u.hostname ? { host: u.hostname, path: u.pathname } : null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function rememberPage(args, result) {
|
|
81
|
+
const page = pageOf(result && result.url);
|
|
82
|
+
if (!page) return;
|
|
83
|
+
const tabId = (result && result.tabId) ?? (args && args.tabId);
|
|
84
|
+
if (tabId != null) pageByTab.set(tabId, page);
|
|
85
|
+
if (args && args.session) pageBySession.set(args.session, page);
|
|
86
|
+
lastPage = page;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// An explicitly targeted tab or session resolves to ITS OWN page or to nothing.
|
|
90
|
+
// Falling back to a process-global here is how a click in session s1 ended up
|
|
91
|
+
// keyed to whatever host s2 had navigated to most recently — and `session` is
|
|
92
|
+
// the documented multi-tab mechanism, so that path is normal usage, not a
|
|
93
|
+
// corner case. No page means no healing, which is the safe direction.
|
|
94
|
+
function pageFor(args) {
|
|
95
|
+
const a = args || {};
|
|
96
|
+
if (a.session) return pageBySession.get(a.session) || null;
|
|
97
|
+
if (a.tabId != null) return pageByTab.get(a.tabId) || null;
|
|
98
|
+
return lastPage;
|
|
99
|
+
}
|
|
100
|
+
|
|
56
101
|
const handleToolCall = async (request) => {
|
|
57
102
|
const { name, arguments: args } = request.params;
|
|
58
103
|
|
|
@@ -142,9 +187,11 @@ const handleToolCall = async (request) => {
|
|
|
142
187
|
if (action === "get_site_memory") {
|
|
143
188
|
const host = args.hostname || args.domain || args.url;
|
|
144
189
|
if (!host) throw new Error("Missing 'hostname' parameter");
|
|
190
|
+
// The agent only wants its own notes; the selectors map is internal
|
|
191
|
+
// plumbing for self-healing and would just be noise here.
|
|
145
192
|
return {
|
|
146
193
|
content: [
|
|
147
|
-
{ type: "text", text: JSON.stringify(readMemory(host), null, 2) },
|
|
194
|
+
{ type: "text", text: JSON.stringify(readMemory(host).notes, null, 2) },
|
|
148
195
|
],
|
|
149
196
|
};
|
|
150
197
|
}
|
|
@@ -184,9 +231,54 @@ const handleToolCall = async (request) => {
|
|
|
184
231
|
};
|
|
185
232
|
}
|
|
186
233
|
|
|
187
|
-
|
|
234
|
+
// Self-healing: for click/fill, supply a remembered fingerprint when the
|
|
235
|
+
// selector has no explicit one, and remember the fingerprint that worked so
|
|
236
|
+
// a later drift can be relocated. Keyed by the page the action lands on and
|
|
237
|
+
// by the action itself — see memory.selectorKey.
|
|
238
|
+
const healable = action === "click" || action === "fill";
|
|
239
|
+
const selector = args && args.selector;
|
|
240
|
+
const page = healable && selector ? pageFor(args) : null;
|
|
241
|
+
const key = page ? selectorKey(action, page.path, selector) : null;
|
|
242
|
+
if (key && !args.fingerprint) {
|
|
243
|
+
// Reading the store must never stop a click that would otherwise work.
|
|
244
|
+
try {
|
|
245
|
+
const stored = getSelector(page.host, key);
|
|
246
|
+
if (stored) args.fingerprint = stored.fingerprint;
|
|
247
|
+
} catch (_) {
|
|
248
|
+
// Unreadable store: proceed on the caller's own selector.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let result;
|
|
253
|
+
try {
|
|
254
|
+
result = await bridge.sendCommand(action, args || {});
|
|
255
|
+
} catch (err) {
|
|
256
|
+
// A selector that missed even with its stored fingerprint is drifting;
|
|
257
|
+
// enough misses and memory.js stops trusting it. Bookkeeping only — it
|
|
258
|
+
// must not replace the error the caller needs to see.
|
|
259
|
+
if (key && /not found/i.test(err.message)) {
|
|
260
|
+
try {
|
|
261
|
+
noteSelectorFail(page.host, key);
|
|
262
|
+
} catch (_) {
|
|
263
|
+
// Best effort.
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
188
268
|
bridge.noteActiveTab((result && result.tabId) || (args && args.tabId));
|
|
189
269
|
|
|
270
|
+
// Learn the page for later, and store the fingerprint of what resolved.
|
|
271
|
+
// Wrapped because the browser has ALREADY acted: a failure here would be
|
|
272
|
+
// reported as a failed click, and the agent would retry and double-submit.
|
|
273
|
+
try {
|
|
274
|
+
rememberPage(args, result);
|
|
275
|
+
if (key && result && result.fingerprint) {
|
|
276
|
+
recordSelector(page.host, key, result.fingerprint);
|
|
277
|
+
}
|
|
278
|
+
} catch (_) {
|
|
279
|
+
// The action succeeded; losing the bookkeeping is the lesser evil.
|
|
280
|
+
}
|
|
281
|
+
|
|
190
282
|
// Any read tool can send its payload to disk. Placed after tab tracking so
|
|
191
283
|
// taking the file path does not cost the session its tab bookkeeping.
|
|
192
284
|
if (args && args.output_file && result && typeof result === "object") {
|
package/mcp-server/memory.js
CHANGED
|
@@ -65,17 +65,143 @@ 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 selector that keeps missing even with its fingerprint has drifted past
|
|
73
|
+
// recognition — stop trusting it rather than relocating against a dead signature.
|
|
74
|
+
const MAX_FAILS = 3;
|
|
75
|
+
|
|
76
|
+
// Normalize any on-disk shape — including the original bare array — to the
|
|
77
|
+
// current { notes, selectors } form. Old files upgrade in place on the next
|
|
78
|
+
// write, so there is no migration step.
|
|
79
|
+
function normalize(raw) {
|
|
80
|
+
if (Array.isArray(raw)) return { notes: raw, selectors: {} };
|
|
81
|
+
return {
|
|
82
|
+
notes: raw && Array.isArray(raw.notes) ? raw.notes : [],
|
|
83
|
+
selectors:
|
|
84
|
+
raw && raw.selectors && typeof raw.selectors === "object"
|
|
85
|
+
? raw.selectors
|
|
86
|
+
: {},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
function readMemory(rawHost) {
|
|
69
91
|
const file = memoryFile(rawHost);
|
|
70
|
-
|
|
92
|
+
if (!fs.existsSync(file)) return normalize(null);
|
|
93
|
+
// getSelector sits on the click/fill path, so an unreadable file here used to
|
|
94
|
+
// brick every click on the host. A truncated or hand-edited file is not worth
|
|
95
|
+
// that: read it as empty and let the action proceed on its own selector.
|
|
96
|
+
try {
|
|
97
|
+
return normalize(JSON.parse(fs.readFileSync(file, "utf8")));
|
|
98
|
+
} catch (_) {
|
|
99
|
+
return normalize(null);
|
|
100
|
+
}
|
|
71
101
|
}
|
|
72
102
|
|
|
73
|
-
|
|
103
|
+
// writeFileSync truncates in place, so a crash — or a second MCP process, which
|
|
104
|
+
// bridge.js explicitly supports — mid-write leaves a half-written file that the
|
|
105
|
+
// next read cannot parse. Write a sibling temp file and rename: rename is atomic
|
|
106
|
+
// within a directory, so a reader sees the old file or the new one, never a
|
|
107
|
+
// partial one.
|
|
108
|
+
function writeMemory(rawHost, data) {
|
|
74
109
|
const file = memoryFile(rawHost);
|
|
110
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
111
|
+
try {
|
|
112
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
113
|
+
fs.renameSync(tmp, file);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
try {
|
|
116
|
+
fs.unlinkSync(tmp);
|
|
117
|
+
} catch (_) {
|
|
118
|
+
// Nothing to clean up.
|
|
119
|
+
}
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The selector store is bookkeeping for an action that already happened, so a
|
|
125
|
+
// failed write must never surface as a failed click — the agent would retry and
|
|
126
|
+
// double-submit. saveMemory stays strict: it IS the user's action.
|
|
127
|
+
function bestEffort(fn) {
|
|
128
|
+
try {
|
|
129
|
+
fn();
|
|
130
|
+
} catch (_) {
|
|
131
|
+
// Read-only or full home: keep the healing we cannot persist to ourselves.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function saveMemory(rawHost, obstacle, solution) {
|
|
75
136
|
const data = readMemory(rawHost);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
137
|
+
// Re-saving an identical note is a no-op, not another copy of the same line.
|
|
138
|
+
const dup = data.notes.some(
|
|
139
|
+
(n) => n.obstacle === obstacle && n.solution === solution,
|
|
140
|
+
);
|
|
141
|
+
if (!dup) {
|
|
142
|
+
data.notes.push({ obstacle, solution, timestamp: Date.now() });
|
|
143
|
+
if (data.notes.length > NOTES_CAP)
|
|
144
|
+
data.notes = data.notes.slice(-NOTES_CAP);
|
|
145
|
+
}
|
|
146
|
+
writeMemory(rawHost, data);
|
|
147
|
+
return data.notes.length;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Selector entries are keyed by action + page path + selector, not by selector
|
|
151
|
+
// alone. Host-only keying meant a fingerprint learned for "#submit" on
|
|
152
|
+
// /checkout was injected into a click on "#submit" on /settings, and one
|
|
153
|
+
// recorded by fill was injected into a click — different elements, and on
|
|
154
|
+
// destructive controls that is a wrong click, then persisted as the new truth.
|
|
155
|
+
function selectorKey(action, path, selector) {
|
|
156
|
+
return `${action}|${path || "/"}|${selector}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getSelector(rawHost, selector) {
|
|
160
|
+
return readMemory(rawHost).selectors[selector] || null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function recordSelector(rawHost, selector, fingerprint) {
|
|
164
|
+
if (!fingerprint) return; // nothing worth remembering
|
|
165
|
+
bestEffort(() => {
|
|
166
|
+
const data = readMemory(rawHost);
|
|
167
|
+
data.selectors[selector] = {
|
|
168
|
+
fingerprint,
|
|
169
|
+
lastOk: Date.now(),
|
|
170
|
+
failCount: 0,
|
|
171
|
+
};
|
|
172
|
+
evictSelectors(data.selectors);
|
|
173
|
+
writeMemory(rawHost, data);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function noteSelectorFail(rawHost, selector) {
|
|
178
|
+
bestEffort(() => {
|
|
179
|
+
const data = readMemory(rawHost);
|
|
180
|
+
const entry = data.selectors[selector];
|
|
181
|
+
if (!entry) return;
|
|
182
|
+
entry.failCount = (entry.failCount || 0) + 1;
|
|
183
|
+
if (entry.failCount >= MAX_FAILS) delete data.selectors[selector];
|
|
184
|
+
writeMemory(rawHost, data);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Bound the store: when a host accrues too many selectors, drop the ones whose
|
|
189
|
+
// last success is oldest.
|
|
190
|
+
function evictSelectors(selectors) {
|
|
191
|
+
const keys = Object.keys(selectors);
|
|
192
|
+
if (keys.length <= SELECTORS_CAP) return;
|
|
193
|
+
keys
|
|
194
|
+
.sort((a, b) => (selectors[a].lastOk || 0) - (selectors[b].lastOk || 0))
|
|
195
|
+
.slice(0, keys.length - SELECTORS_CAP)
|
|
196
|
+
.forEach((k) => delete selectors[k]);
|
|
79
197
|
}
|
|
80
198
|
|
|
81
|
-
module.exports = {
|
|
199
|
+
module.exports = {
|
|
200
|
+
memoryFile,
|
|
201
|
+
selectorKey,
|
|
202
|
+
readMemory,
|
|
203
|
+
saveMemory,
|
|
204
|
+
getSelector,
|
|
205
|
+
recordSelector,
|
|
206
|
+
noteSelectorFail,
|
|
207
|
+
};
|
package/mcp-server/tools.js
CHANGED
|
@@ -29,6 +29,17 @@ const SINK = {
|
|
|
29
29
|
},
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
+
// Self-healing: browser_click/browser_fill return the resolved element's
|
|
33
|
+
// fingerprint. Pass it back on a later call and, if the selector has drifted
|
|
34
|
+
// (a renamed id/class), the element is relocated by structural similarity.
|
|
35
|
+
const FINGERPRINT = {
|
|
36
|
+
fingerprint: {
|
|
37
|
+
type: "object",
|
|
38
|
+
description:
|
|
39
|
+
"Optional. The `fingerprint` object a previous browser_click/browser_fill returned for this element. When the selector no longer matches, the element is relocated by structural similarity and the result carries healed:true.",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
32
43
|
// Define tools
|
|
33
44
|
const BROWSER_TOOLS = [
|
|
34
45
|
{
|
|
@@ -45,11 +56,13 @@ const BROWSER_TOOLS = [
|
|
|
45
56
|
},
|
|
46
57
|
{
|
|
47
58
|
name: "browser_click",
|
|
48
|
-
description:
|
|
59
|
+
description:
|
|
60
|
+
"Click an element on the page. Returns the element's `fingerprint` (pass it back later to survive a selector change) and `healed:true` if the selector had drifted and the element was relocated.",
|
|
49
61
|
inputSchema: {
|
|
50
62
|
type: "object",
|
|
51
63
|
properties: {
|
|
52
64
|
selector: { type: "string" },
|
|
65
|
+
...FINGERPRINT,
|
|
53
66
|
...TARGET,
|
|
54
67
|
},
|
|
55
68
|
required: ["selector"],
|
|
@@ -57,13 +70,15 @@ const BROWSER_TOOLS = [
|
|
|
57
70
|
},
|
|
58
71
|
{
|
|
59
72
|
name: "browser_fill",
|
|
60
|
-
description:
|
|
73
|
+
description:
|
|
74
|
+
"Fill an input field with text. Returns the field's `fingerprint` (pass it back later to survive a selector change) and `healed:true` if the selector had drifted and the field was relocated.",
|
|
61
75
|
inputSchema: {
|
|
62
76
|
type: "object",
|
|
63
77
|
properties: {
|
|
64
78
|
selector: { type: "string" },
|
|
65
79
|
value: { type: "string" },
|
|
66
80
|
submit: { type: "boolean" },
|
|
81
|
+
...FINGERPRINT,
|
|
67
82
|
...TARGET,
|
|
68
83
|
},
|
|
69
84
|
required: ["selector", "value"],
|
package/package.json
CHANGED