poltertab 1.4.0 → 1.5.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.
@@ -0,0 +1,81 @@
1
+ // Site memory: the notes an agent leaves itself about a domain, keyed by
2
+ // hostname. The key arrives from a model, so it is untrusted input rather than
3
+ // a filename — see memoryFile.
4
+
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const { MEMORY_DIR } = require("./config.js");
8
+
9
+ fs.mkdirSync(MEMORY_DIR, { recursive: true });
10
+
11
+ // Installs predating the move kept memory beside the code. Copy it forward once
12
+ // so an upgrade does not look like the agent forgot everything it learned.
13
+ // Never overwrite: if both sides have a note for a domain, the one already in
14
+ // the new location is the live one.
15
+ (() => {
16
+ const legacy = path.join(__dirname, "navigation_memory");
17
+ if (legacy === MEMORY_DIR || !fs.existsSync(legacy)) return;
18
+ let copied = 0;
19
+ for (const name of fs.readdirSync(legacy)) {
20
+ const to = path.join(MEMORY_DIR, name);
21
+ if (!name.endsWith(".json") || fs.existsSync(to)) continue;
22
+ try {
23
+ fs.copyFileSync(path.join(legacy, name), to);
24
+ copied++;
25
+ } catch (_) {
26
+ // A read-only or half-removed legacy dir is not worth failing startup.
27
+ }
28
+ }
29
+ if (copied) {
30
+ console.error(
31
+ `[PolterTab MCP] Migrated ${copied} site memory file(s) to ${MEMORY_DIR}`,
32
+ );
33
+ }
34
+ })();
35
+
36
+ // Site memory is keyed by hostname, and that key arrives from a model — so it
37
+ // is untrusted input rather than a filename. Two failures this closes: a note
38
+ // saved under kw.com was invisible to a lookup for www.kw.com (the same site),
39
+ // and the raw value was interpolated straight into a path, so "../.." reached
40
+ // outside MEMORY_DIR.
41
+ function memoryFile(rawHost) {
42
+ let host = String(rawHost).trim().toLowerCase();
43
+
44
+ // The parameter is also documented as accepting `url`, so a full URL turning
45
+ // up here is expected rather than a caller mistake.
46
+ if (host.includes("/")) {
47
+ try {
48
+ host = new URL(host.includes("://") ? host : `https://${host}`).hostname;
49
+ } catch {
50
+ host = host.split("/")[0];
51
+ }
52
+ }
53
+
54
+ host = host.replace(/[^a-z0-9.-]/g, "").replace(/^\.+/, "");
55
+ if (!host) throw new Error(`Not a usable hostname: ${rawHost}`);
56
+
57
+ // Existing notes live under whichever spelling first created them — the store
58
+ // already holds both kw.com.json and www.linkedin.com.json — so try the
59
+ // variants before concluding this is a new file.
60
+ const bare = host.replace(/^www\./, "");
61
+ for (const name of [bare, host, `www.${bare}`]) {
62
+ const p = path.join(MEMORY_DIR, `${name}.json`);
63
+ if (fs.existsSync(p)) return p;
64
+ }
65
+ return path.join(MEMORY_DIR, `${bare}.json`);
66
+ }
67
+
68
+ function readMemory(rawHost) {
69
+ const file = memoryFile(rawHost);
70
+ return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : [];
71
+ }
72
+
73
+ function saveMemory(rawHost, obstacle, solution) {
74
+ const file = memoryFile(rawHost);
75
+ 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;
79
+ }
80
+
81
+ module.exports = { memoryFile, readMemory, saveMemory };
@@ -0,0 +1,103 @@
1
+ // Writing a payload to disk instead of into the context window, and the CSV /
2
+ // JSONL encoders that make the file the deliverable rather than something the
3
+ // agent has to re-type.
4
+
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const { DOWNLOADS_DIR } = require("./config.js");
8
+
9
+ // --- Output handling ---
10
+ //
11
+ // The raw payload must never be the default path into the context window. Two
12
+ // costs dominated a 100-record scrape and neither was the data: envelopes
13
+ // several lines of JSON deep to carry one short string, and the agent then
14
+ // re-typing every record by hand into a file. Writing straight to disk removes
15
+ // both.
16
+
17
+ function toCsv(rows) {
18
+ if (!rows.length) return "";
19
+ const cols = [...new Set(rows.flatMap((r) => Object.keys(r)))];
20
+ const cell = (v) => {
21
+ if (v === null || v === undefined) return "";
22
+ const s = Array.isArray(v)
23
+ ? v.join(" | ")
24
+ : typeof v === "object"
25
+ ? JSON.stringify(v) // beats a column of "[object Object]"
26
+ : String(v);
27
+ // Quote only when it would otherwise break the row, and double any
28
+ // embedded quote — the two mistakes that produce a file which imports
29
+ // silently and wrongly.
30
+ return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
31
+ };
32
+ return [
33
+ cols.join(","),
34
+ ...rows.map((r) => cols.map((c) => cell(r[c])).join(",")),
35
+ ].join("\n");
36
+ }
37
+
38
+ const isBlank = (v) =>
39
+ v === null || v === undefined || v === "" || (Array.isArray(v) && !v.length);
40
+
41
+ // `rows` is passed separately when the payload has a records array: .jsonl and
42
+ // .csv are formats for records, not for envelopes.
43
+ function writeOutput(name, payload, rows) {
44
+ const requested = String(name);
45
+ const safeName = path.basename(requested);
46
+ const parts = safeName.split(".");
47
+ const ext = parts.length > 1 ? `.${parts.pop()}` : "";
48
+ const base = parts.join(".");
49
+ const finalName = `${base}_${Date.now()}${ext}`;
50
+
51
+ fs.mkdirSync(DOWNLOADS_DIR, { recursive: true });
52
+ const safePath = path.join(DOWNLOADS_DIR, finalName);
53
+
54
+ let body;
55
+ if (rows && ext === ".jsonl") {
56
+ body = rows.map((r) => JSON.stringify(r)).join("\n");
57
+ } else if (rows && ext === ".csv") {
58
+ body = toCsv(rows);
59
+ } else {
60
+ body = JSON.stringify(payload, null, 2);
61
+ }
62
+
63
+ fs.writeFileSync(safePath, body);
64
+ const out = { file: safePath, bytes: Buffer.byteLength(body) };
65
+
66
+ // Output stays inside DOWNLOADS_DIR: this path comes from a model, and a tool
67
+ // that writes to an arbitrary absolute path is a different capability than
68
+ // one that saves a scrape. But relocating without a word is how a caller ends
69
+ // up looking for a file that was never going to be there.
70
+ if (safeName !== requested) {
71
+ out.note = `output_file is confined to ${DOWNLOADS_DIR} — "${requested}" was written as ${path.basename(safePath)}, not to the path requested.`;
72
+ }
73
+ return out;
74
+ }
75
+
76
+ // What comes back inline when the payload went to disk: enough to know the call
77
+ // worked and the shape is right, and nothing more.
78
+ function summarizeOutput(payload, rows, written) {
79
+ const summary = { ...written };
80
+ if (rows) {
81
+ summary.rows = rows.length;
82
+ summary.fields = rows.length ? Object.keys(rows[0]) : [];
83
+ summary.sample = rows.slice(0, 2);
84
+ for (const k of ["fill_rates", "dropped", "warnings", "stopped_because", "pages_fetched"]) {
85
+ if (payload && payload[k] !== undefined) summary[k] = payload[k];
86
+ }
87
+ } else if (Array.isArray(payload)) {
88
+ summary.items = payload.length;
89
+ summary.sample = payload.slice(0, 2);
90
+ } else if (payload && typeof payload === "object") {
91
+ summary.keys = Object.keys(payload);
92
+ }
93
+ return summary;
94
+ }
95
+
96
+ // Payload shapes that carry a records array worth writing as jsonl/csv.
97
+ function rowsOf(payload) {
98
+ if (payload && Array.isArray(payload.rows)) return payload.rows;
99
+ if (Array.isArray(payload)) return payload;
100
+ return null;
101
+ }
102
+
103
+ module.exports = { toCsv, isBlank, writeOutput, summarizeOutput, rowsOf };
@@ -0,0 +1,439 @@
1
+ // The tool surface: what the model can see and call. Data only — every handler
2
+ // lives in index.js. Kept apart because this is the file that changes whenever
3
+ // a capability is added or a description is reworded, and it was 27% of a
4
+ // 1,600-line index.js that also held the transport, the writers and the loop.
5
+
6
+ // Every page-facing tool accepts the same two targeting parameters and, if it
7
+ // reads anything back, the same sink. They were copy-pasted into all 23 schemas,
8
+ // which is how `session` ended up described on browser_navigate and nowhere else
9
+ // — fifteen tools offered the model a bare string with no hint what it does.
10
+ const TARGET = {
11
+ tabId: {
12
+ type: "number",
13
+ description: "Target a specific tab. Omit to use the last tab navigated.",
14
+ },
15
+ session: {
16
+ type: "string",
17
+ description:
18
+ "Named session (tab) to run against. Created on first use by browser_navigate, so a loop can keep one tab instead of opening hundreds.",
19
+ },
20
+ };
21
+
22
+ // One description, because writeOutput's behaviour is uniform: records become
23
+ // .jsonl/.csv when the extension asks for them, everything else is JSON.
24
+ const SINK = {
25
+ output_file: {
26
+ type: "string",
27
+ description:
28
+ "Filename to write the payload under ~/.poltertab/downloads/, returning only a summary (row count, field names, fill rates, two sample records). .jsonl and .csv are written in those formats when the payload carries records, anything else as JSON. A path is reduced to its basename — output cannot be written elsewhere.",
29
+ },
30
+ };
31
+
32
+ // Define tools
33
+ const BROWSER_TOOLS = [
34
+ {
35
+ name: "browser_navigate",
36
+ description: "Navigate to any URL in the browser",
37
+ inputSchema: {
38
+ type: "object",
39
+ properties: {
40
+ url: { type: "string" },
41
+ ...TARGET,
42
+ },
43
+ required: ["url"],
44
+ },
45
+ },
46
+ {
47
+ name: "browser_click",
48
+ description: "Click an element on the page",
49
+ inputSchema: {
50
+ type: "object",
51
+ properties: {
52
+ selector: { type: "string" },
53
+ ...TARGET,
54
+ },
55
+ required: ["selector"],
56
+ },
57
+ },
58
+ {
59
+ name: "browser_fill",
60
+ description: "Fill an input field with text",
61
+ inputSchema: {
62
+ type: "object",
63
+ properties: {
64
+ selector: { type: "string" },
65
+ value: { type: "string" },
66
+ submit: { type: "boolean" },
67
+ ...TARGET,
68
+ },
69
+ required: ["selector", "value"],
70
+ },
71
+ },
72
+ {
73
+ name: "browser_scrape",
74
+ description:
75
+ "Scrape the page or specific elements. For repeating records (cards, rows, listings) use browser_extract instead — it keeps fields grouped per record.",
76
+ inputSchema: {
77
+ type: "object",
78
+ properties: {
79
+ selector: { type: "string" },
80
+ attribute: {
81
+ type: "string",
82
+ description:
83
+ "Attribute to read, or 'text' for the element's text. 'href'/'src' come back absolute.",
84
+ },
85
+ multiple: { type: "boolean" },
86
+ fields: {
87
+ type: "array",
88
+ items: {
89
+ type: "string",
90
+ enum: ["meta", "jsonld", "links", "headings", "bodyText"],
91
+ },
92
+ description:
93
+ "Full-page scrape only (no selector): which parts to return. title and url are always included. ['meta','jsonld'] is the cheap structured-data path — og: tags and schema.org blobs without 50KB of body text. Omit for everything.",
94
+ },
95
+ max_text: {
96
+ type: "number",
97
+ description: "Max characters of text per element (default 500)",
98
+ },
99
+ ...SINK,
100
+ ...TARGET,
101
+ },
102
+ required: [],
103
+ },
104
+ },
105
+ {
106
+ name: "browser_extract",
107
+ description:
108
+ "Extract repeating records (cards, rows, listings) with fields grouped per record. Fields resolve INSIDE each record root and a missing field yields null instead of shifting later records' values. Returns fill rates and warns when a field is empty inside the record scope but matches page-wide (record boundary too narrow).",
109
+ inputSchema: {
110
+ type: "object",
111
+ properties: {
112
+ record: {
113
+ type: "string",
114
+ description:
115
+ "Selector for the repeating container, e.g. '.agent-card'. Verify with browser_snapshot that it encloses ALL the fields you want — the element that looks like the card often excludes siblings holding some of them.",
116
+ },
117
+ fields: {
118
+ type: "object",
119
+ description:
120
+ "Map of field name -> {sel, get, many, strip}. sel is relative to the record root; omit it (or use '.') for the root itself. get: 'text' (default) | 'href' | 'src' | any attribute name. many: true collects all matches into an array. strip removes a leading prefix, e.g. 'tel:'.",
121
+ additionalProperties: {
122
+ type: "object",
123
+ properties: {
124
+ sel: { type: "string" },
125
+ get: { type: "string" },
126
+ many: { type: "boolean" },
127
+ strip: { type: "string" },
128
+ },
129
+ },
130
+ },
131
+ anchor: {
132
+ type: "string",
133
+ description:
134
+ "Name of the field that is always present on a real record (usually the detail-page link). Records missing it are dropped as placeholders and counted in 'dropped'.",
135
+ },
136
+ max_text: {
137
+ type: "number",
138
+ description: "Max characters per text field (default 500)",
139
+ },
140
+ probe: {
141
+ type: "boolean",
142
+ description:
143
+ "Page-wide re-check of any field that came back entirely empty (default true)",
144
+ },
145
+ ...SINK,
146
+ ...TARGET,
147
+ },
148
+ required: ["record", "fields"],
149
+ },
150
+ },
151
+ {
152
+ name: "browser_extract_all",
153
+ description:
154
+ "Paginate and extract in one call, with no model round-trip per page. Takes browser_extract's spec plus a URL template, walks pages, dedups on a key, and halts on: limit reached, empty page, a page whose records repeat an earlier page's (the trap where ignored page-size params silently return page 1 again), fill rates collapsing against page 1's baseline, or max_pages. Always reports which condition fired and returns everything collected so far.",
155
+ inputSchema: {
156
+ type: "object",
157
+ properties: {
158
+ url_template: {
159
+ type: "string",
160
+ description:
161
+ "URL with a {page} placeholder, e.g. 'https://www.kw.com/agents?page={page}'",
162
+ },
163
+ record: { type: "string" },
164
+ fields: { type: "object" },
165
+ anchor: { type: "string" },
166
+ key: {
167
+ type: "string",
168
+ description:
169
+ "Field name to dedup on — use a stable per-record identifier such as the detail URL. Falls back to whole-row equality.",
170
+ },
171
+ limit: {
172
+ type: "number",
173
+ description: "Stop after this many records (default 200)",
174
+ },
175
+ offset: {
176
+ type: "number",
177
+ description:
178
+ "Skip this many records from the start of the stream. Pages are re-fetched to reach the offset; pass start_page to skip cheaply.",
179
+ },
180
+ start_page: { type: "number", description: "First page (default 1)" },
181
+ max_pages: {
182
+ type: "number",
183
+ description: "Hard guard on pages fetched (default 50)",
184
+ },
185
+ fill_tolerance: {
186
+ type: "number",
187
+ description:
188
+ "Halt when a field well-populated on the baseline page falls below this fraction of it (default 0.5). 0 disables the check.",
189
+ },
190
+ max_text: { type: "number" },
191
+ ...SINK,
192
+ // Session only, deliberately: this tool drives its own navigation from
193
+ // url_template, so a tabId would be accepted and then ignored.
194
+ session: TARGET.session,
195
+ },
196
+ required: ["url_template", "record", "fields"],
197
+ },
198
+ },
199
+ {
200
+ name: "browser_screenshot",
201
+ description:
202
+ "Capture visible tab as base64 PNG. This action will focus the target tab.",
203
+ inputSchema: {
204
+ type: "object",
205
+ properties: {
206
+ ...TARGET,
207
+ },
208
+ required: [],
209
+ },
210
+ },
211
+ {
212
+ name: "browser_scroll",
213
+ description: "Scroll the page",
214
+ inputSchema: {
215
+ type: "object",
216
+ properties: {
217
+ direction: {
218
+ type: "string",
219
+ enum: ["up", "down", "left", "right", "top", "bottom"],
220
+ },
221
+ amount: { type: "number" },
222
+ selector: { type: "string" },
223
+ ...TARGET,
224
+ },
225
+ required: ["direction"],
226
+ },
227
+ },
228
+ {
229
+ name: "browser_hover",
230
+ description: "Hover over an element",
231
+ inputSchema: {
232
+ type: "object",
233
+ properties: {
234
+ selector: { type: "string" },
235
+ ...TARGET,
236
+ },
237
+ required: ["selector"],
238
+ },
239
+ },
240
+ {
241
+ name: "browser_get_text",
242
+ description: "Get text content of an element",
243
+ inputSchema: {
244
+ type: "object",
245
+ properties: {
246
+ selector: { type: "string" },
247
+ max_text: {
248
+ type: "number",
249
+ description:
250
+ "Max characters (default 10000). The result flags it when text was cut.",
251
+ },
252
+ ...SINK,
253
+ ...TARGET,
254
+ },
255
+ required: ["selector"],
256
+ },
257
+ },
258
+ {
259
+ name: "browser_get_title",
260
+ description: "Get page title and URL",
261
+ inputSchema: {
262
+ type: "object",
263
+ properties: {
264
+ ...TARGET,
265
+ },
266
+ required: [],
267
+ },
268
+ },
269
+ {
270
+ name: "browser_get_url",
271
+ description: "Get page URL",
272
+ inputSchema: {
273
+ type: "object",
274
+ properties: {
275
+ ...TARGET,
276
+ },
277
+ required: [],
278
+ },
279
+ },
280
+ {
281
+ name: "browser_snapshot",
282
+ description:
283
+ "Get a snapshot of the DOM. Large pages run to tens of KB — narrow it with interactive_only/max_nodes, or send it to output_file, before spending the context on it.",
284
+ inputSchema: {
285
+ type: "object",
286
+ properties: {
287
+ interactive_only: {
288
+ type: "boolean",
289
+ description: "Only clickable/typable elements",
290
+ },
291
+ max_nodes: {
292
+ type: "number",
293
+ description: "Cap on nodes returned (default 400)",
294
+ },
295
+ max_depth: { type: "number" },
296
+ ...SINK,
297
+ ...TARGET,
298
+ },
299
+ required: [],
300
+ },
301
+ },
302
+ {
303
+ name: "browser_get_network_state",
304
+ description:
305
+ "Get captured raw JSON data (GraphQL/XHR) for the current tab, bypassing DOM virtualization",
306
+ inputSchema: {
307
+ type: "object",
308
+ properties: {
309
+ ...TARGET,
310
+ clear: {
311
+ type: "boolean",
312
+ description: "Clear the buffer after reading (default: true)",
313
+ },
314
+ ...SINK,
315
+ },
316
+ required: [],
317
+ },
318
+ },
319
+ {
320
+ name: "browser_set_intercept_patterns",
321
+ description:
322
+ "Set URL substrings to determine which network requests are captured in the MAIN world.",
323
+ inputSchema: {
324
+ type: "object",
325
+ properties: {
326
+ patterns: {
327
+ type: "array",
328
+ items: { type: "string" },
329
+ description:
330
+ "Array of substrings (e.g. ['graphql', '/api/v1/comments'])",
331
+ },
332
+ ...TARGET,
333
+ },
334
+ required: ["patterns"],
335
+ },
336
+ },
337
+ {
338
+ name: "browser_smart_scroll",
339
+ description:
340
+ "Scroll the page and wait for new network data to load (handles lazy-loading)",
341
+ inputSchema: {
342
+ type: "object",
343
+ properties: {
344
+ direction: { type: "string", enum: ["up", "down"] },
345
+ amount: { type: "number" },
346
+ ...TARGET,
347
+ },
348
+ required: ["direction"],
349
+ },
350
+ },
351
+ {
352
+ name: "browser_session_create",
353
+ description: "Create or track a named browser session (tab)",
354
+ inputSchema: {
355
+ type: "object",
356
+ properties: {
357
+ name: { type: "string" },
358
+ url: { type: "string" },
359
+ },
360
+ required: ["name"],
361
+ },
362
+ },
363
+ {
364
+ name: "browser_session_switch",
365
+ description: "Switch to a different tracked session",
366
+ inputSchema: {
367
+ type: "object",
368
+ properties: {
369
+ name: { type: "string" },
370
+ },
371
+ required: ["name"],
372
+ },
373
+ },
374
+ {
375
+ name: "browser_session_list",
376
+ description: "List all tracked sessions",
377
+ inputSchema: {
378
+ type: "object",
379
+ properties: {},
380
+ required: [],
381
+ },
382
+ },
383
+ {
384
+ name: "browser_session_close",
385
+ description: "Close a tracked session",
386
+ inputSchema: {
387
+ type: "object",
388
+ properties: {
389
+ name: { type: "string" },
390
+ },
391
+ required: ["name"],
392
+ },
393
+ },
394
+ {
395
+ name: "browser_session_context",
396
+ description: "Get context info for the current active session",
397
+ inputSchema: {
398
+ type: "object",
399
+ properties: {},
400
+ required: [],
401
+ },
402
+ },
403
+ {
404
+ name: "browser_get_site_memory",
405
+ description:
406
+ "Get navigation memory, obstacles, and fixes for a specific website domain",
407
+ inputSchema: {
408
+ type: "object",
409
+ properties: {
410
+ hostname: {
411
+ type: "string",
412
+ description: "e.g., 'www.linkedin.com' or 'x.com'",
413
+ },
414
+ domain: { type: "string", description: "Alias for hostname" },
415
+ },
416
+ required: [],
417
+ },
418
+ },
419
+ {
420
+ name: "browser_save_site_memory",
421
+ description:
422
+ "Save a new navigation memory, obstacle, or fix for a specific website domain",
423
+ inputSchema: {
424
+ type: "object",
425
+ properties: {
426
+ hostname: { type: "string", description: "e.g., 'www.linkedin.com'" },
427
+ domain: { type: "string", description: "Alias for hostname" },
428
+ obstacle: {
429
+ type: "string",
430
+ description: "What broke or was difficult?",
431
+ },
432
+ solution: { type: "string", description: "How did you solve it?" },
433
+ },
434
+ required: ["obstacle", "solution"],
435
+ },
436
+ },
437
+ ];
438
+
439
+ module.exports = { BROWSER_TOOLS, TARGET, SINK };
@@ -196,7 +196,29 @@ function skew(serverVersion, extensionVersion) {
196
196
 
197
197
  // --- the one-line notice ---------------------------------------------------
198
198
 
199
- const EXTENSION_URL = "https://github.com/abhinav162/poltertab/releases/latest";
199
+ const RELEASES = "https://github.com/abhinav162/poltertab/releases";
200
+
201
+ // Which extension build to send someone to.
202
+ //
203
+ // It must match the server that drives it: mismatched halves are the exact
204
+ // failure the rest of this module exists to detect, so a link that manufactures
205
+ // one is worse than no link. GitHub's /releases/latest deliberately skips
206
+ // prereleases — so telling a 1.5.0-beta.1 install to "get the latest release"
207
+ // hands it a 1.4.0 extension, and the skew warning then fires on a fresh
208
+ // install that followed the instructions exactly. Worse, that was also the
209
+ // remediation advice printed *for* skew, so the fix re-created the fault.
210
+ //
211
+ // Ask for the release matching the version in hand. Callers pass whichever
212
+ // version is right for what they are saying: the running server for "your
213
+ // halves disagree", the version being upgraded to for "an update is available".
214
+ // /releases/latest survives only as the fallback for a build whose version we
215
+ // cannot parse — a dev checkout — where a tag URL would 404.
216
+ function extensionUrl(version) {
217
+ const v = String(version || "")
218
+ .trim()
219
+ .replace(/^v/, "");
220
+ return parse(v) ? `${RELEASES}/tag/v${v}` : `${RELEASES}/latest`;
221
+ }
200
222
 
201
223
  // Surfaced once per server process on a tool response, because it is the only
202
224
  // channel the user reliably reads: doctor and the popup both require them to
@@ -207,7 +229,7 @@ function notice({ current, latest, updateAvailable, skew: sk }) {
207
229
  parts.push(
208
230
  `The Chrome extension (${sk.extension}) is older than this server (${sk.server}) — ` +
209
231
  `browser commands may fail in ways that look like missing elements. ` +
210
- `Reload it from ${EXTENSION_URL}`,
232
+ `Reload it from ${extensionUrl(sk.server)}`,
211
233
  );
212
234
  } else if (sk && sk.kind === "server-behind") {
213
235
  parts.push(
@@ -218,7 +240,8 @@ function notice({ current, latest, updateAvailable, skew: sk }) {
218
240
  if (updateAvailable && latest) {
219
241
  parts.push(
220
242
  `PolterTab ${latest} is available (running ${current}). ` +
221
- `Update with: npm update -g poltertab && poltertab setup`,
243
+ `Update with: npm update -g poltertab && poltertab setup, ` +
244
+ `then reload the extension from ${extensionUrl(latest)}`,
222
245
  );
223
246
  }
224
247
  if (!parts.length) return null;
@@ -236,7 +259,7 @@ module.exports = {
236
259
  skew,
237
260
  notice,
238
261
  disabled,
239
- EXTENSION_URL,
262
+ extensionUrl,
240
263
  CACHE_FILE,
241
264
  STATE_FILE,
242
265
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poltertab",
3
- "version": "1.4.0",
3
+ "version": "1.5.0-beta.2",
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"
@@ -17,7 +17,6 @@
17
17
  "skills/",
18
18
  "assets/",
19
19
  "!mcp-server/test/",
20
- "!mcp-server/test-client.js",
21
20
  "!mcp-server/downloads/",
22
21
  "!mcp-server/navigation_memory/"
23
22
  ],