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.
@@ -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 { readMemory, saveMemory } = require("./memory.js");
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
- const result = await bridge.sendCommand(action, args || {});
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") {
@@ -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
- return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : [];
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
- function saveMemory(rawHost, obstacle, solution) {
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
- data.push({ obstacle, solution, timestamp: Date.now() });
77
- fs.writeFileSync(file, JSON.stringify(data, null, 2));
78
- return data.length;
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 = { memoryFile, readMemory, saveMemory };
199
+ module.exports = {
200
+ memoryFile,
201
+ selectorKey,
202
+ readMemory,
203
+ saveMemory,
204
+ getSelector,
205
+ recordSelector,
206
+ noteSelectorFail,
207
+ };
@@ -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: "Click an element on the page",
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: "Fill an input field with text",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poltertab",
3
- "version": "1.5.0",
3
+ "version": "1.6.0-beta.1",
4
4
  "description": "Let any AI assistant drive and scrape your existing Chrome profile — no headless browser, no complex setup.",
5
5
  "bin": {
6
6
  "poltertab": "./bin/poltertab.js"