poltertab 1.6.0-beta.1 → 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.
@@ -0,0 +1,1043 @@
1
+ // Policy for learned extraction recipes: what is worth remembering, what
2
+ // counts as the same task, what must never reach the store, and how a recorded
3
+ // preamble is ordered.
4
+ //
5
+ // Storage lives elsewhere. This module touches no filesystem and requires
6
+ // nothing but Node built-ins, so every rule below is decidable in a unit test —
7
+ // which matters because these are the rules that decide whether a wrong spec
8
+ // gets written down as truth and replayed for weeks.
9
+
10
+ // A built-in, so the 2-dependency footprint is unchanged. Used only to keep
11
+ // derived names and derived variants distinct — never for secrecy.
12
+ const { createHash } = require("crypto");
13
+
14
+ // --- Fill-rate staleness ---
15
+ //
16
+ // The one implementation of the rule. It used to live inline in the pagination
17
+ // loop; a recipe's stored baseline needs exactly the same verdict, and two
18
+ // copies of a threshold rule drift the moment one of them is tuned.
19
+ //
20
+ // A field only counts as collapsed if it was reliably populated to begin with:
21
+ // a column that was 10% full on the baseline page dropping to 5% is noise, not
22
+ // a layout change. `baseline` and `ratios` are read, never written — the
23
+ // baseline is the immovable bar the ratchet depends on.
24
+ //
25
+ // An absent tolerance defaults rather than disables. Failing open here is the
26
+ // one direction that hurts: a caller that omits the field (a recipe stored
27
+ // before tolerance existed, say) would otherwise report a rotted spec as clean
28
+ // forever, which is exactly the confidently-wrong data every halt condition in
29
+ // extract-all.js exists to prevent. Only an explicit `<= 0` turns the check
30
+ // off, because `fill_tolerance: 0` is a deliberate opt-out a caller can pass.
31
+ const DEFAULT_TOLERANCE = 0.5;
32
+
33
+ function collapsed(baseline, ratios, tolerance) {
34
+ const t = Number.isFinite(tolerance) ? tolerance : DEFAULT_TOLERANCE;
35
+ if (t <= 0) return [];
36
+ const base = baseline || {};
37
+ const now = ratios || {};
38
+ return Object.keys(base).filter(
39
+ (name) => base[name] >= 0.5 && now[name] < base[name] * t,
40
+ );
41
+ }
42
+
43
+ // extract() reports fill as counts so a caller can sum them across pages.
44
+ // Comparing across pages of differing size needs fractions. Zero rows means
45
+ // there is nothing to divide by, and a map of NaNs compares false against
46
+ // every threshold — silently, in whichever direction happens to be wrong.
47
+ function ratiosOf(result) {
48
+ const rows = (result && result.rows) || [];
49
+ if (!rows.length) return {};
50
+ const ratios = {};
51
+ for (const [name, n] of Object.entries((result && result.fill_rates) || {})) {
52
+ ratios[name] = n / rows.length;
53
+ }
54
+ return ratios;
55
+ }
56
+
57
+ // A stored spec is replayed without the model looking at the page again, so
58
+ // the bar for writing one down is higher than the bar for using it once.
59
+ const MIN_ROWS = 3;
60
+ const MAX_FIELDS = 50;
61
+ const MAX_SPEC_BYTES = 4096;
62
+
63
+ // `why` is surfaced to the model as a warning, so each one names what to
64
+ // change rather than reporting a failed predicate.
65
+ function qualityOk(result, spec) {
66
+ const rows = (result && result.rows) || [];
67
+ const fields = (spec && spec.fields) || {};
68
+ const warnings = (result && result.warnings) || [];
69
+
70
+ if (rows.length < MIN_ROWS) {
71
+ return {
72
+ ok: false,
73
+ why: `only ${rows.length} record(s) matched — a spec proven on fewer than ${MIN_ROWS} is not proven`,
74
+ };
75
+ }
76
+
77
+ // content_script emits this when the record selector matched nothing at all;
78
+ // anything learned alongside it describes a page that was not there.
79
+ if (warnings.some((w) => /^record: no matches/.test(w))) {
80
+ return { ok: false, why: "the record selector matched nothing on the page" };
81
+ }
82
+
83
+ // The loosening probe fired: a field is empty inside the record scope but
84
+ // present page-wide. Storing this spec would store the empty column too.
85
+ if (warnings.some((w) => w.includes("record boundary likely too narrow"))) {
86
+ return {
87
+ ok: false,
88
+ why: "the record boundary looks too narrow — widen the record selector before this is worth storing",
89
+ };
90
+ }
91
+
92
+ // Cards dropped for a missing anchor are cards the anchor cannot identify.
93
+ // Half the page gone means the anchor is the wrong field, not that half the
94
+ // page is placeholders.
95
+ if (spec && spec.anchor) {
96
+ const found = result.records_found || 0;
97
+ const ratio = found ? (result.dropped || 0) / found : 1;
98
+ if (!(ratio < 0.5)) {
99
+ return {
100
+ ok: false,
101
+ why: `anchor "${spec.anchor}" dropped ${result.dropped} of ${found} records — pick an anchor every record has`,
102
+ };
103
+ }
104
+ }
105
+
106
+ const ratios = ratiosOf(result);
107
+ if (!Object.values(ratios).some((r) => r >= 0.5)) {
108
+ return {
109
+ ok: false,
110
+ why: "no field was filled on even half the records — the field selectors are not landing",
111
+ };
112
+ }
113
+
114
+ const names = Object.keys(fields);
115
+ if (names.length > MAX_FIELDS) {
116
+ return {
117
+ ok: false,
118
+ why: `${names.length} fields exceeds the ${MAX_FIELDS}-field limit for a stored spec`,
119
+ };
120
+ }
121
+ const bytes = Buffer.byteLength(
122
+ JSON.stringify({ record: spec && spec.record, fields }),
123
+ );
124
+ if (bytes > MAX_SPEC_BYTES) {
125
+ return {
126
+ ok: false,
127
+ why: `spec is ${bytes} bytes, over the ${MAX_SPEC_BYTES}-byte limit for a stored spec`,
128
+ };
129
+ }
130
+
131
+ return { ok: true };
132
+ }
133
+
134
+ // --- Naming ---
135
+
136
+ // Recursive key sort, so two specs that differ only in the order their fields
137
+ // were declared serialise identically.
138
+ function canonical(value) {
139
+ if (Array.isArray(value)) return value.map(canonical);
140
+ if (value && typeof value === "object") {
141
+ const out = {};
142
+ for (const key of Object.keys(value).sort()) out[key] = canonical(value[key]);
143
+ return out;
144
+ }
145
+ return value;
146
+ }
147
+
148
+ // The one definition of spec identity. deriveName's digest and sameSpec both
149
+ // read it, so a derived name can never disagree with the merge check that
150
+ // decides whether two runs are describing the same task — and it is that
151
+ // disagreement, not the truncation on its own, that lost a field.
152
+ function specShape(spec) {
153
+ return JSON.stringify(
154
+ canonical({
155
+ record: spec && spec.record,
156
+ fields: (spec && spec.fields) || {},
157
+ }),
158
+ );
159
+ }
160
+
161
+ function specDigest(spec, chars) {
162
+ return createHash("sha1").update(specShape(spec)).digest("hex").slice(0, chars);
163
+ }
164
+
165
+ // Lowercase, punctuation collapsed to single hyphens. Emits nothing outside
166
+ // [a-z0-9-], which is what lets deriveVariant use "." and "+" as separators no
167
+ // slug can forge.
168
+ function slugify(value) {
169
+ return String(value == null ? "" : value)
170
+ .toLowerCase()
171
+ .replace(/[^a-z0-9]+/g, "-")
172
+ .replace(/^-+|-+$/g, "");
173
+ }
174
+
175
+ // Derived from the spec alone: no clock, no counter. Two runs of the same task
176
+ // must land on the same recipe rather than accumulating "agents-2", "agents-3"
177
+ // until the store is a pile of near-duplicates. That collision is the intended
178
+ // one — the same spec *is* the same task.
179
+ //
180
+ // The field names alone were not enough. Truncating to three of them made
181
+ // {title, company, link, salary} and {title, company, link} both
182
+ // "title-company-link"; sameSpec then correctly reported two different tasks,
183
+ // so no variant merge happened, and putRecipe overwrote the stored `extract`
184
+ // under that name — the four-field recipe lost `salary` from both its spec and
185
+ // its baseline, silently. The digest is taken over the same {record, fields}
186
+ // sameSpec compares, so two different specs cannot land on one name. Four hex
187
+ // chars is short enough to read and the collision guard in observe() covers
188
+ // the remainder.
189
+ function deriveName(fields, record) {
190
+ const parts = Object.keys(fields || {}).slice(0, 3).map(slugify).filter(Boolean);
191
+ const stem = parts.length ? parts.join("-") : "default";
192
+ return `${stem}-${specDigest({ record, fields }, 4)}`;
193
+ }
194
+
195
+ // A variant IS the preamble that produced it. Without that, two slices of one
196
+ // task — the same spec run after two different facet clicks — both wrote to
197
+ // "default", and the second overwrote the first's baseline. The replay then
198
+ // read clean against the wrong bar, and because collapsed() skips any field
199
+ // whose baseline is below 0.5, a field that had collapsed to 8% could never be
200
+ // reported stale on that recipe again.
201
+ //
202
+ // Keyed on action + selector per step, in seq order, and on nothing else:
203
+ //
204
+ // - Fill VALUES are excluded. Keying on them would make variants unbounded —
205
+ // every search term typed into one box would mint a slice and churn the
206
+ // cap — and a redacted fill has no value to key on in the first place. The
207
+ // accepted consequence is that two different search terms through the same
208
+ // box share a variant, which is fine: the same fields are present either
209
+ // way, so one baseline is the right bar for both.
210
+ // - SCROLLS are excluded outright, count and all. A scroll loads more of the
211
+ // slice already on screen; it never selects one, so "the recorded preamble
212
+ // plus a scroll of my own" is the SAME slice and has to derive the same
213
+ // key. Keying on it split one slice in two, and left variantFromSteps to
214
+ // be lenient about a trailing step instead of matching exactly. It also
215
+ // keeps a bare "scroll." out of the key — a step with no selector renders
216
+ // as a trailing dot, which reads as a truncation bug in the one string a
217
+ // caller uses to check the match.
218
+ //
219
+ // The action stays in the label rather than being dropped for readability: a
220
+ // fill on #q and a click on #q leave the page in different states, and letting
221
+ // them share a variant would be the same overwrite one level down.
222
+ const VARIANT_LABEL_CAP = 64;
223
+
224
+ // Shared with the step buffer below, which coalesces runs of these into one
225
+ // counted step.
226
+ const SCROLLS = new Set(["scroll", "smart_scroll"]);
227
+
228
+ // One segment per step that selects something, in seq order. Kept separate
229
+ // from the label because variantFromSteps has to match on ALL of them: four
230
+ // clicks already overrun the cap, and matching against the truncated label
231
+ // left the matcher reading the buffer's head — the exact thing it must stop
232
+ // doing — with the steps that decided the page's state folded into "2-more".
233
+ function variantSegments(steps) {
234
+ return (steps || [])
235
+ .filter((s) => s && s.action && !SCROLLS.has(s.action))
236
+ .slice()
237
+ .sort((a, b) => (a.seq || 0) - (b.seq || 0))
238
+ .map((s) => `${s.action}.${slugify(s.selector)}`);
239
+ }
240
+
241
+ function deriveVariant(steps) {
242
+ const ordered = variantSegments(steps);
243
+ // No preamble that selects anything: the task genuinely has one slice. Note
244
+ // that hydrate() also falls back to this name for a hand-edited store whose
245
+ // recipe carries no variants at all — same name, and harmless, because
246
+ // either way there is nothing for it to be confused with.
247
+ if (!ordered.length) return "default";
248
+
249
+ const label = ordered.join("+");
250
+ if (label.length <= VARIANT_LABEL_CAP) return label;
251
+ // Past the cap the label has stopped being readable anyway: keep some of it
252
+ // for a human and let the digest carry the distinctness, so two long
253
+ // preambles sharing a prefix do not share a variant.
254
+ //
255
+ // Whole segments are dropped, never bytes. A byte cut left "#facet-onsite"
256
+ // as "face" — this name is what `used_recipe` reports and what a caller
257
+ // types back as `variant`, so a string broken inside the step that decided
258
+ // the outcome is unreadable and unusable both. (What the MATCHER echoes is
259
+ // echoSteps, which never carries a digest at all.)
260
+ //
261
+ // The kept segments are the LAST ones, for the same reason variantFromSteps
262
+ // matches on the latest run: the most recent actions are the ones that put
263
+ // the page in its current state, and keeping the head instead showed the
264
+ // reader the slice that was NOT chosen while folding away the one that was.
265
+ //
266
+ // The "N-more" marker carries no ".", which no `action.selector` segment can
267
+ // say, so a truncated label can never collide with an untruncated one
268
+ // however the cap happens to fall.
269
+ const digest = createHash("sha1").update(label).digest("hex").slice(0, 6);
270
+ const segments = label.split("+");
271
+ for (let kept = segments.length - 1; kept >= 0; kept--) {
272
+ const marker = `${segments.length - kept}-more-${digest}`;
273
+ const out = kept ? `${marker}+${segments.slice(-kept).join("+")}` : marker;
274
+ if (out.length <= VARIANT_LABEL_CAP || kept === 0) return out;
275
+ }
276
+ }
277
+
278
+ // Decides "same task, new page variant" against "a second recipe". Only the
279
+ // extraction contract counts: anchor, url_template, max_text and targeting are
280
+ // per-run choices, not identity. Nothing looser than exact equality of
281
+ // {record, fields} — a false positive merges two different tasks into one
282
+ // recipe, and the resulting rows look like data.
283
+ function sameSpec(a, b) {
284
+ if (!a || !b) return false;
285
+ return specShape(a) === specShape(b);
286
+ }
287
+
288
+ // --- Redaction ---
289
+ //
290
+ // The store is a plaintext JSON file in the user's home directory, so a
291
+ // recorded fill value is a value written to disk unencrypted and replayed
292
+ // later. Anything that reads as a credential is dropped.
293
+ //
294
+ // A denylist leaks by omission: a sensitive field named nothing like these
295
+ // patterns gets stored in the clear. It is deliberately broad in the other
296
+ // direction ("code" catches postcode, "auth" catches author) because a
297
+ // needlessly re-typed search term costs the model one step, and a leaked
298
+ // password costs the user their account.
299
+ const SENSITIVE = /pass|pwd|token|otp|2fa|code|card|cvv|cvc|ssn|secret|auth|credit/i;
300
+
301
+ const SENSITIVE_ATTRS = ["name", "id", "aria-label", "placeholder"];
302
+
303
+ function redactValue(action, args, fingerprint) {
304
+ // Only fill carries a value; a click or a scroll has nothing to leak.
305
+ if (action !== "fill") return { value: undefined, redacted: false };
306
+
307
+ const a = args || {};
308
+ const attrs = (fingerprint && fingerprint.attrs) || {};
309
+ if (String(attrs.type || "").toLowerCase() === "password") {
310
+ return { value: null, redacted: true };
311
+ }
312
+
313
+ const haystack = [a.selector, ...SENSITIVE_ATTRS.map((k) => attrs[k])]
314
+ .filter((v) => typeof v === "string")
315
+ .join(" ");
316
+ if (SENSITIVE.test(haystack)) return { value: null, redacted: true };
317
+
318
+ return { value: a.value, redacted: false };
319
+ }
320
+
321
+ // --- The step buffer ---
322
+ //
323
+ // Steps taken before a successful extract are the recipe's preamble: the
324
+ // facet click, the "show 100 per page", the scroll that loaded the list. They
325
+ // are buffered per target until an extract either earns them or discards them.
326
+ //
327
+ // A flow longer than the cap is not a preamble, it is a session; replaying it
328
+ // blind would be worse than starting cold. And a buffer left open for longer
329
+ // than the TTL belongs to work the user has since moved on from.
330
+ const STEP_CAP = 20;
331
+
332
+ const STEP_TTL_MS = 10 * 60 * 1000;
333
+
334
+ // targetKey is opaque here — it encodes session and tab, and this module never
335
+ // parses it. Buffers are keyed strictly by it so two tasks in two tabs cannot
336
+ // borrow each other's preamble.
337
+ const buffers = new Map();
338
+
339
+ // The preamble that produced the LAST extract on this target, kept after the
340
+ // buffer was drained. Draining on every extract is what stopped two
341
+ // independent slices merging into one four-click preamble; without retention
342
+ // the cost was the other way round — two extracts run back to back in one page
343
+ // state, and the second was learned with `steps: []` even though it needs
344
+ // exactly the same clicks to reproduce, which then sent a stale replay to read
345
+ // a preamble that was not there.
346
+ //
347
+ // Reused only while NOTHING has been recorded since: the first new step means
348
+ // the page has moved on and this copy no longer describes it. index.js never
349
+ // reaches in here — the same contract the buffer has.
350
+ const retained = new Map();
351
+
352
+ function noteStep(targetKey, step, now = Date.now()) {
353
+ // Before anything else, including the scroll-coalescing return below: any
354
+ // new step at all is the page leaving the state the last extract saw.
355
+ retained.delete(targetKey);
356
+
357
+ let buf = buffers.get(targetKey);
358
+ if (!buf) {
359
+ // The host is tracked beside the buffer, not parsed out of targetKey —
360
+ // that string is opaque by contract, and noteNavigation needs to know
361
+ // which site the buffered flow belongs to.
362
+ buf = { steps: [], seq: 0, startedAt: now, host: step.host || null };
363
+ buffers.set(targetKey, buf);
364
+ }
365
+ if (!buf.host && step.host) buf.host = step.host;
366
+
367
+ // Forty scrolls down an infinite list is one instruction, not forty. Only
368
+ // runs of the *same* action merge: scroll and smart_scroll replay
369
+ // differently, so collapsing across them would change what gets replayed.
370
+ const last = buf.steps[buf.steps.length - 1];
371
+ if (SCROLLS.has(step.action) && last && last.action === step.action) {
372
+ last.count = (last.count || 1) + 1;
373
+ return;
374
+ }
375
+
376
+ if (buf.steps.length >= STEP_CAP) {
377
+ clearSteps(
378
+ targetKey,
379
+ `more than ${STEP_CAP} steps before an extract — too long to be a recipe preamble`,
380
+ );
381
+ return;
382
+ }
383
+
384
+ // An explicit counter, never a timestamp. lastOk-style timestamps land in the
385
+ // same millisecond and then reorder between runs, which replays a click
386
+ // before the navigation that made it reachable.
387
+ const recorded = { seq: buf.seq++, action: step.action, selector: step.selector };
388
+ if (step.value !== undefined) recorded.value = step.value;
389
+ if (step.redacted) recorded.redacted = true;
390
+ recorded.path = step.path;
391
+ buf.steps.push(recorded);
392
+ }
393
+
394
+ function takeSteps(targetKey, now = Date.now()) {
395
+ const buf = buffers.get(targetKey);
396
+ buffers.delete(targetKey);
397
+ if (!buf) return [];
398
+ if (now - buf.startedAt > STEP_TTL_MS) return [];
399
+ return buf.steps;
400
+ }
401
+
402
+ function clearSteps(targetKey, reason) {
403
+ // Both, always: a retained preamble is as replayable as a buffered one, so
404
+ // every invalidation that drops the buffer has to drop it too.
405
+ const hadBuffer = buffers.delete(targetKey);
406
+ const hadRetained = retained.delete(targetKey);
407
+ if (!hadBuffer && !hadRetained) return;
408
+ // stderr is the only channel that is not the MCP protocol stream, and a
409
+ // dropped preamble is otherwise invisible when a replay later comes up short.
410
+ if (reason) {
411
+ console.error(`[PolterTab MCP] dropped recorded steps for ${targetKey}: ${reason}`);
412
+ }
413
+ }
414
+
415
+ // A preamble is one flow on one site. The clicks before a jump to another
416
+ // host have nothing to do with the page being extracted after it, and
417
+ // replaying them would send the recipe somewhere it was never proven.
418
+ function noteNavigation(targetKey, host) {
419
+ const buf = buffers.get(targetKey);
420
+ const held = retained.get(targetKey);
421
+ // Read from whichever holds the flow: after an extract there is no buffer
422
+ // left, and a cross-host jump would otherwise leave the retained copy behind
423
+ // to be attributed to an extract on another site.
424
+ const from = (buf && buf.host) || (held && held.host);
425
+ if (!from || !host || from === host) return;
426
+ clearSteps(
427
+ targetKey,
428
+ `navigated from ${from} to ${host} — a flow that crossed sites is not one preamble`,
429
+ );
430
+ }
431
+
432
+ function liveBuffer(targetKey, now) {
433
+ const buf = buffers.get(targetKey);
434
+ if (!buf || now - buf.startedAt > STEP_TTL_MS) return null;
435
+ return buf;
436
+ }
437
+
438
+ // The retained copy's clock runs from the extract that produced it, not from
439
+ // the click that started the flow: what it claims is "the page is still in the
440
+ // state that extract saw", and that claim ages from the extract.
441
+ function liveRetained(targetKey, now) {
442
+ const held = retained.get(targetKey);
443
+ if (!held || now - held.at > STEP_TTL_MS) return null;
444
+ return held;
445
+ }
446
+
447
+ // What put the page in front of the caller into its current state, without
448
+ // consuming anything. hydrate reads this to match a variant; observe must
449
+ // still find the buffer intact afterwards, or the extract that follows the
450
+ // read is filed with no preamble at all.
451
+ function currentSteps(targetKey, now = Date.now()) {
452
+ const buf = liveBuffer(targetKey, now);
453
+ if (buf && buf.steps.length) return buf.steps;
454
+ const held = liveRetained(targetKey, now);
455
+ return held ? held.steps : [];
456
+ }
457
+
458
+ // The same thing, for the one caller allowed to consume it: observe, once per
459
+ // extract. Anything buffered is drained and retained; with nothing buffered,
460
+ // the retained copy stands, because no step has arrived to say the page moved.
461
+ function drainSteps(targetKey, now = Date.now()) {
462
+ const buf = liveBuffer(targetKey, now);
463
+ const fresh = buf ? buf.steps : [];
464
+ // Unconditional, so an over-TTL buffer is forgotten rather than left to be
465
+ // picked up by the next extract as if it were live.
466
+ takeSteps(targetKey, now);
467
+ if (fresh.length) {
468
+ retained.set(targetKey, { steps: fresh, at: now, host: buf.host });
469
+ return fresh;
470
+ }
471
+ const held = liveRetained(targetKey, now);
472
+ if (held) return held.steps;
473
+ retained.delete(targetKey);
474
+ return [];
475
+ }
476
+
477
+ // ── The integration seam ────────────────────────────────────────────────────
478
+ //
479
+ // index.js resolves the page and the target key; everything about what a
480
+ // recipe *means* lives here. That split is what lets the tool path keep one
481
+ // copy of output_file, tab tracking and page learning: hydrate before the
482
+ // command goes out, observe after it comes back, no second `extract` branch.
483
+
484
+ const {
485
+ pathPattern,
486
+ getRecipe,
487
+ getRecipes,
488
+ putRecipe,
489
+ noteRecipeFail,
490
+ listRecipeSummaries,
491
+ } = require("./memory.js");
492
+
493
+ const HYDRATABLE = new Set(["extract", "extract_all"]);
494
+
495
+ // Does the variant's preamble run all the way to the END of what the caller
496
+ // issued? A variant key describes how the page got into the state it is in
497
+ // NOW, so only a suffix proves the page is in that state — the same run
498
+ // sitting anywhere earlier in the buffer proves the page HAS BEEN there and
499
+ // left. Returns the number of segments matched, or 0.
500
+ //
501
+ // Compared segment-wise, never byte-wise: "click.facet-eng" is a string suffix
502
+ // of "click.facet-english" and matching it replays a different slice's
503
+ // baseline over the wrong rows.
504
+ function suffixLength(variantKey, issued) {
505
+ const want = String(variantKey).split("+");
506
+ const start = issued.length - want.length;
507
+ if (start < 0) return 0;
508
+ return want.every((segment, i) => segment === issued[start + i]) ? want.length : 0;
509
+ }
510
+
511
+ // How much of the projection the echo below may spend. Bigger than
512
+ // VARIANT_LABEL_CAP on purpose: this string is read by a human checking the
513
+ // matcher, and nothing keys off it, so legibility beats brevity.
514
+ const VARIANT_ECHO_CAP = 200;
515
+
516
+ // The steps the matcher matched against, worded for a person. Never the stored
517
+ // key: past VARIANT_LABEL_CAP that carries a "N-more-<sha1>" marker, and a
518
+ // digest of steps the reader cannot see is exactly what they need when asking
519
+ // why a match went the way it did. The digest keeps two long preambles
520
+ // distinct in the STORE; an echo has no distinctness to keep, so it names the
521
+ // elided steps instead.
522
+ function echoSteps(steps) {
523
+ const segments = variantSegments(steps);
524
+ if (!segments.length) return "no steps that select anything";
525
+ const full = segments.join("+");
526
+ if (full.length <= VARIANT_ECHO_CAP) return full;
527
+
528
+ // The tail is kept whole and unabbreviated: it is the part the suffix match
529
+ // turns on. Half the budget, so the named head always has room for a step or
530
+ // two however long the tail runs.
531
+ let kept = [];
532
+ for (let i = segments.length - 1; i > 0; i--) {
533
+ const next = [segments[i], ...kept];
534
+ if (kept.length && next.join("+").length > VARIANT_ECHO_CAP / 2) break;
535
+ kept = next;
536
+ }
537
+ const head = segments.slice(0, segments.length - kept.length);
538
+ const render = (names) =>
539
+ `${head.length} earlier steps (${(names.length < head.length ? ["...", ...names] : names).join(", ")}) then ${kept.join("+")}`;
540
+
541
+ let named = [];
542
+ for (let i = head.length - 1; i >= 0; i--) {
543
+ const next = [head[i], ...named];
544
+ if (named.length && render(next).length > VARIANT_ECHO_CAP) break;
545
+ named = next;
546
+ }
547
+ return render(named);
548
+ }
549
+
550
+ // Which slice the caller is standing in, read off the steps this server watched
551
+ // it issue. Without this, a caller that had just clicked two facets was refused
552
+ // with a demand to type back the very key those clicks produce.
553
+ //
554
+ // Returns null rather than a best guess whenever the evidence is not decisive:
555
+ // picking wrong returns another slice's records as if they were the ones asked
556
+ // for, which is the whole reason the error below exists.
557
+ function variantFromSteps(targetKey, variantNames, now = Date.now()) {
558
+ const steps = currentSteps(targetKey, now);
559
+ // No recorded steps is no evidence, not evidence of a preamble-free slice:
560
+ // the buffer is equally empty after a restart, a TTL expiry and a cross-host
561
+ // navigate, and the page may be sitting in any slice's state.
562
+ if (!steps.length) return null;
563
+
564
+ const seen = echoSteps(steps);
565
+ const issuedKey = deriveVariant(steps);
566
+ if (variantNames.includes(issuedKey)) return { variant: issuedKey, seen };
567
+
568
+ // Nothing matched the whole preamble, so the caller issued more than one
569
+ // slice's worth of steps. A variant then has to be a SUFFIX of what was
570
+ // issued: its steps are the last ones taken, so the page is standing in that
571
+ // slice now.
572
+ //
573
+ // Anything looser reads the page's history as its present. "The key is a
574
+ // prefix of what was issued" returned the slice a completed switch had LEFT.
575
+ // "The key's run ends latest, wherever it sits" still did, whenever only one
576
+ // variant occurred in the buffer at all: mid-switch at [eng, remote, design]
577
+ // the eng-remote run ended at step 1 and won unopposed, so a page rendering
578
+ // zero design cards came back labelled eng-remote, read stale against the
579
+ // wrong baseline, sent the caller off re-deriving a correct spec, and
580
+ // charged eng-remote a failure it never earned. Three of those evict a slice
581
+ // that never ran.
582
+ //
583
+ // No suffix is not a reason to try something looser — it is positive
584
+ // evidence that the page is in a state no variant describes, so it refuses
585
+ // exactly as an undecidable match does. That refuses a trailing
586
+ // "#sort-by-date" click too, and deliberately: this server cannot know
587
+ // whether that click changed the slice, and the caller can say so with
588
+ // `variant`. Do not relax it back into a "trailing noise is fine" rule.
589
+ //
590
+ // Scrolls are already out of the projection (see deriveVariant), so a
591
+ // trailing scroll leaves an exact suffix rather than breaking the match.
592
+ //
593
+ // Read off the steps, not off issuedKey: past the label cap that string has
594
+ // segments folded into a "N-more" marker, and a variant whose clicks live in
595
+ // the folded tail would silently stop matching. A stored variant NAME can
596
+ // still be a capped one, and then only the exact match above can find it —
597
+ // an error listing the variants, which is the safe direction.
598
+ const issued = variantSegments(steps);
599
+ let winner = null;
600
+ let longest = 0;
601
+ for (const name of variantNames) {
602
+ // Two different keys cannot be suffixes of the same length, so the longest
603
+ // is unique. Where one is a suffix of the other it is also the more
604
+ // specific account of how the page got here.
605
+ const len = suffixLength(name, issued);
606
+ if (len > longest) {
607
+ longest = len;
608
+ winner = name;
609
+ }
610
+ }
611
+ return winner ? { variant: winner, seen } : { variant: null, seen };
612
+ }
613
+
614
+ // Fills a missing extract spec from what was learned on this page. Every
615
+ // ambiguity is an error rather than a guess: a recipe belonging to another task
616
+ // returns that task's records, and rows that came from the wrong spec look
617
+ // exactly like rows that came from the right one.
618
+ function hydrate(action, args, page, targetKey) {
619
+ if (!HYDRATABLE.has(action)) return null;
620
+ const a = args || {};
621
+
622
+ // The model brought its own spec. Nothing to look up, but observe still
623
+ // needs to know where to file what this call proves.
624
+ if (a.record && a.fields) {
625
+ return page
626
+ ? { host: page.host, pattern: pathPattern(page.path), hydrated: false }
627
+ : null;
628
+ }
629
+
630
+ if (!page) {
631
+ throw new Error(
632
+ "No page is known for this target yet, so there is no recipe to look up: run browser_navigate first, or pass `record` and `fields`.",
633
+ );
634
+ }
635
+
636
+ const pattern = pathPattern(page.path);
637
+ const where = `${page.host}${pattern}`;
638
+ const candidates = listRecipeSummaries(page.host, pattern);
639
+ const names = candidates.map((c) => c.name).join(", ");
640
+
641
+ let picked;
642
+ if (a.recipe) {
643
+ picked = candidates.find((c) => c.name === a.recipe);
644
+ if (!picked) {
645
+ throw new Error(
646
+ `No recipe named "${a.recipe}" for ${where}. ` +
647
+ (candidates.length
648
+ ? `Learned here: ${names}.`
649
+ : "Nothing has been learned here yet."),
650
+ );
651
+ }
652
+ } else if (candidates.length === 1) {
653
+ picked = candidates[0];
654
+ } else if (!candidates.length) {
655
+ throw new Error(
656
+ `No extraction recipe has been learned for ${where}: pass \`record\` and \`fields\`, or navigate to a page a recipe was learned on.`,
657
+ );
658
+ } else {
659
+ throw new Error(
660
+ `${candidates.length} recipes have been learned for ${where}: ${names}. Pass \`recipe\` to say which one — picking for you would return another task's records as if they were the ones asked for.`,
661
+ );
662
+ }
663
+
664
+ const recipe = getRecipe(page.host, pattern, picked.name) || {};
665
+ const spec = recipe.extract || {};
666
+ const variants = recipe.variants || {};
667
+ const variantNames = Object.keys(variants);
668
+
669
+ let variant = a.variant;
670
+ let chosenFrom = null;
671
+ if (variant) {
672
+ if (!variants[variant]) {
673
+ throw new Error(
674
+ `Recipe "${picked.name}" on ${where} has no variant "${variant}"` +
675
+ (variantNames.length ? `: ${variantNames.join(", ")}.` : "."),
676
+ );
677
+ }
678
+ } else if (variantNames.length === 1) {
679
+ variant = variantNames[0];
680
+ } else if (!variantNames.length) {
681
+ // Only reachable from a hand-edited store: the spec is still usable, and
682
+ // there is nothing to be ambiguous about.
683
+ variant = "default";
684
+ } else {
685
+ const matched = variantFromSteps(targetKey, variantNames);
686
+ if (!matched || !matched.variant) {
687
+ // Name what was SEEN, not only what exists. Listing the variants alone
688
+ // left a caller mid-switch unable to tell that the steps it had just
689
+ // issued were the thing that matched nothing.
690
+ throw new Error(
691
+ `Recipe "${picked.name}" on ${where} has ${variantNames.length} variants: ${variantNames.join(", ")}. ` +
692
+ (matched
693
+ ? `No variant matches the steps you just issued (${matched.seen}) — pass \`variant\` to say which one.`
694
+ : "Pass \`variant\` to say which one."),
695
+ );
696
+ }
697
+ variant = matched.variant;
698
+ chosenFrom = matched.seen;
699
+ }
700
+
701
+ // Only what the caller left open. A half-supplied spec is the model
702
+ // correcting the recipe, not asking to be overruled by it.
703
+ if (a.record === undefined) a.record = spec.record;
704
+ if (a.fields === undefined) a.fields = spec.fields;
705
+ if (a.anchor === undefined && spec.anchor !== undefined) a.anchor = spec.anchor;
706
+ if (a.max_text === undefined && spec.max_text !== undefined)
707
+ a.max_text = spec.max_text;
708
+
709
+ if (!a.record || !a.fields) {
710
+ throw new Error(
711
+ `Recipe "${picked.name}" on ${where} carries no usable extract spec — pass \`record\` and \`fields\`.`,
712
+ );
713
+ }
714
+
715
+ const entry = variants[variant] || {};
716
+ return {
717
+ host: page.host,
718
+ pattern,
719
+ name: picked.name,
720
+ variant,
721
+ baseline: entry.baseline || {},
722
+ // Read only to word the zero-record warning: a recipe with no recorded
723
+ // preamble must not be told to go and read one.
724
+ steps: entry.steps || [],
725
+ chosenFrom,
726
+ hydrated: true,
727
+ };
728
+ }
729
+
730
+ // extract_all returns a crawl, not a page: records_found and dropped live in
731
+ // its per-page log, and its warnings carry a "page N: " prefix that would stop
732
+ // every warning rule in qualityOk from ever matching.
733
+ function pagedView(payload) {
734
+ const pages = Array.isArray(payload.pages) ? payload.pages : [];
735
+ return {
736
+ ...payload,
737
+ records_found: pages.reduce((n, p) => n + ((p && p.found) || 0), 0),
738
+ dropped: pages.reduce((n, p) => n + ((p && p.dropped) || 0), 0),
739
+ warnings: (payload.warnings || []).map((w) =>
740
+ typeof w === "string" ? w.replace(/^page \d+: /, "") : w,
741
+ ),
742
+ };
743
+ }
744
+
745
+ // A clean replay refreshes the variant's clock and forgives earlier misses.
746
+ // The whole entry is rewritten, not a patch: putRecipe replaces a variant
747
+ // wholesale, so writing { lastOk } alone would delete the baseline and steps
748
+ // it was meant to leave untouched.
749
+ function touchVariant(ctx) {
750
+ const recipe = getRecipe(ctx.host, ctx.pattern, ctx.name);
751
+ const entry = recipe && recipe.variants && recipe.variants[ctx.variant];
752
+ if (!entry) return;
753
+ putRecipe(ctx.host, ctx.pattern, ctx.name, {
754
+ variants: { [ctx.variant]: { ...entry, lastOk: Date.now(), failCount: 0 } },
755
+ });
756
+ }
757
+
758
+ // What the call proved, written down — or, on a replay, whether what came back
759
+ // still matches what was learned. Returns the keys to patch onto the result.
760
+ function observe(opts) {
761
+ try {
762
+ return observed(opts);
763
+ } catch (err) {
764
+ // The browser has already acted and the rows are already in hand. A
765
+ // bookkeeping failure surfacing here would be reported as a failed
766
+ // extract, and the agent would re-run the whole crawl.
767
+ console.error(
768
+ `[PolterTab MCP] recipe bookkeeping failed: ${(err && err.message) || err}`,
769
+ );
770
+ return null;
771
+ }
772
+ }
773
+
774
+ function observed({ action, args, result, ctx, targetKey }) {
775
+ if (!ctx || !result || typeof result !== "object") return null;
776
+ const a = args || {};
777
+ const view = action === "extract_all" ? pagedView(result) : result;
778
+ const had = ((result.warnings || []).length);
779
+ const warnings = [...(result.warnings || [])];
780
+ const patch = {};
781
+
782
+ // Drained on EVERY extract that reaches here, replay or learn. A replay's
783
+ // buffered steps have already served their purpose, and leaving them behind
784
+ // attributed one slice's preamble to the next: two independent two-click
785
+ // slices came back out of the store as one four-click preamble. What is
786
+ // drained is retained for the next extract in the same page state — see the
787
+ // `retained` map.
788
+ const steps = drainSteps(targetKey);
789
+
790
+ if (ctx.hydrated) {
791
+ patch.used_recipe = `${ctx.name}/${ctx.variant}`;
792
+ // A slice chosen from what the caller did rather than from what it typed
793
+ // is otherwise invisible, and the caller has no way to tell which of
794
+ // several slices it just got back.
795
+ if (ctx.chosenFrom) {
796
+ patch.variant_chosen_from_steps = `${ctx.variant} — matched against the steps recorded before this extract (${ctx.chosenFrom})`;
797
+ }
798
+ const ratios = ratiosOf(view);
799
+ // Zero records leaves nothing to measure a fill rate against, so it is
800
+ // judged on its own rather than as a collapsed column.
801
+ const dead = view.records_found === 0;
802
+ const gone = dead ? [] : collapsed(ctx.baseline, ratios, a.fill_tolerance);
803
+ if (dead || gone.length) {
804
+ patch.stale = true;
805
+ warnings.push(
806
+ // Two causes, and the likelier one is not a broken spec: the page is
807
+ // simply not in the state the recipe was learned in because its
808
+ // preamble was never reissued. Naming only the selector sent the model
809
+ // off rewriting a spec that was correct.
810
+ //
811
+ // Split on whether there IS a preamble to read. Pointing at
812
+ // browser_get_site_memory for a recipe stored with `steps: []` lands
813
+ // the model on an empty list, and a warning that sends someone
814
+ // nowhere trains them to ignore warnings.
815
+ dead
816
+ ? (ctx.steps || []).length
817
+ ? `recipe ${patch.used_recipe} matched no records. Either this page is not in the state the recipe was learned in — its preamble was not reissued; read the steps recorded for it with browser_get_site_memory for ${ctx.host} and reissue the ones that still make sense — or the record selector "${a.record}" is gone from the page and the spec needs re-deriving from a browser_snapshot. Check the preamble first: the spec is often correct.`
818
+ : `recipe ${patch.used_recipe} matched no records, and no preamble was recorded for it, so there is nothing to reissue. Either this page is not in the state the recipe was learned in and you have to put it there yourself — the facet, the filter, the scroll that loaded the list — or the record selector "${a.record}" is gone from the page and the spec needs re-deriving from a browser_snapshot.`
819
+ : `recipe ${patch.used_recipe} is stale: ${gone
820
+ .map(
821
+ (n) =>
822
+ `${n} ${(ratios[n] * 100).toFixed(0)}% vs the learned ${(ctx.baseline[n] * 100).toFixed(0)}%`,
823
+ )
824
+ .join(
825
+ ", ",
826
+ )} — re-derive those fields from a browser_snapshot. The rows below are what the stale spec produced.`,
827
+ );
828
+ // Enough misses and memory.js forgets the variant, and the last variant
829
+ // takes the recipe with it. Unsaid, the model expects a recipe to be
830
+ // there next time and gets an error instead of reaching for a snapshot.
831
+ // A failed write reports undefined, which reads here as nothing having
832
+ // been deleted — which is exactly right, because nothing was.
833
+ const dropped =
834
+ noteRecipeFail(ctx.host, ctx.pattern, ctx.name, ctx.variant) || {};
835
+ if (dropped.recipe) {
836
+ warnings.push(
837
+ `recipe ${ctx.name} on ${ctx.host}${ctx.pattern} has been forgotten after ${dropped.fails} stale replays: derive a fresh spec from a browser_snapshot, because there is nothing left here to replay.`,
838
+ );
839
+ } else if (dropped.variant) {
840
+ warnings.push(
841
+ `variant ${ctx.variant} of recipe ${ctx.name} on ${ctx.host}${ctx.pattern} has been forgotten after ${dropped.fails} stale replays: derive a fresh spec from a browser_snapshot rather than replaying another slice of it.`,
842
+ );
843
+ }
844
+ } else {
845
+ touchVariant(ctx);
846
+ }
847
+ // A hydrated replay must NEVER write `baseline`. A degrading site allowed
848
+ // to rewrite its own bar walks it down a little each run, and staleness
849
+ // never fires again. Only a model-supplied spec sets a baseline.
850
+ if (warnings.length !== had) patch.warnings = warnings;
851
+ return patch;
852
+ }
853
+
854
+ const spec = { record: a.record, fields: a.fields };
855
+ if (a.anchor !== undefined) spec.anchor = a.anchor;
856
+
857
+ const verdict = qualityOk(view, spec);
858
+ if (!verdict.ok) {
859
+ // A spec this call could not prove must not be written down as truth —
860
+ // it would be replayed for weeks. Said out loud only when the caller asked
861
+ // to remember it, or every three-row extract carries a nag.
862
+ if (a.remember) {
863
+ warnings.push(`not learned as a recipe: ${verdict.why}`);
864
+ patch.warnings = warnings;
865
+ return patch;
866
+ }
867
+ return null;
868
+ }
869
+
870
+ // An explicit `remember` still wins on both halves: the caller is naming its
871
+ // own task and its own slice.
872
+ const asked = String(a.remember || "");
873
+ const cut = asked.indexOf("/");
874
+ let name =
875
+ (cut === -1 ? asked : asked.slice(0, cut)) || deriveName(a.fields, a.record);
876
+ const variant = (cut === -1 ? "" : asked.slice(cut + 1)) || deriveVariant(steps);
877
+ const where = `${ctx.host}${ctx.pattern}`;
878
+
879
+ // The same {record, fields} IS the same task, so this is another slice of a
880
+ // recipe that already exists rather than a second recipe describing it.
881
+ const bucket = getRecipes(ctx.host, ctx.pattern);
882
+ let merged = false;
883
+ for (const existing of Object.keys(bucket)) {
884
+ if (sameSpec(bucket[existing] && bucket[existing].extract, spec)) {
885
+ name = existing;
886
+ merged = true;
887
+ break;
888
+ }
889
+ }
890
+
891
+ // A name already held by a DIFFERENT spec must not be written through.
892
+ // putRecipe replaces `extract` wholesale, so the recipe stored there would
893
+ // lose whatever fields this spec does not have — spec and baseline both,
894
+ // with nothing said. Reachable two ways, an explicit `remember` name and a
895
+ // digest collision, and neither is allowed to destroy a stored recipe.
896
+ if (!merged) {
897
+ const held = bucket[name];
898
+ if (held && held.extract && !sameSpec(held.extract, spec)) {
899
+ const alt = `${name}-${specDigest(spec, 8)}`;
900
+ // Only a third spec can be sitting on `alt`: this spec would have been
901
+ // found by the merge scan above.
902
+ if (bucket[alt]) {
903
+ warnings.push(
904
+ `not learned as a recipe: "${name}" and "${alt}" on ${where} both already describe other extract specs — pass \`remember\` with an unused name.`,
905
+ );
906
+ patch.warnings = warnings;
907
+ return patch;
908
+ }
909
+ warnings.push(
910
+ `recipe "${name}" on ${where} already describes a different extract spec, so this one was learned as "${alt}" instead — replay it with \`recipe: "${alt}"\`.`,
911
+ );
912
+ name = alt;
913
+ }
914
+ }
915
+
916
+ const extract = { record: a.record, fields: a.fields };
917
+ if (a.anchor !== undefined) extract.anchor = a.anchor;
918
+ if (a.max_text !== undefined) extract.max_text = a.max_text;
919
+ // Stored only when the call had one: it is what makes a replayed
920
+ // extract_all able to walk the same pages again.
921
+ if (a.url_template !== undefined) extract.url_template = a.url_template;
922
+
923
+ putRecipe(ctx.host, ctx.pattern, name, {
924
+ extract,
925
+ variants: {
926
+ [variant]: {
927
+ steps,
928
+ baseline: ratiosOf(view),
929
+ lastOk: Date.now(),
930
+ failCount: 0,
931
+ },
932
+ },
933
+ });
934
+ patch.learned_recipe = `${name}/${variant}`;
935
+ if (warnings.length !== had) patch.warnings = warnings;
936
+ return patch;
937
+ }
938
+
939
+ // ── What a recipe looks like to the model ───────────────────────────────────
940
+ //
941
+ // Recall is implicit: the tool surface is already 23 schemas on every request,
942
+ // so a recipe has to announce itself where the model is already looking rather
943
+ // than behind a 24th tool that costs everyone tokens to be told about.
944
+
945
+ function ago(ts, now = Date.now()) {
946
+ if (!ts) return "age unknown";
947
+ const mins = Math.floor((now - ts) / 60000);
948
+ if (mins < 1) return "just now";
949
+ if (mins < 60) return `${mins}m ago`;
950
+ const hours = Math.floor(mins / 60);
951
+ if (hours < 24) return `${hours}h ago`;
952
+ return `${Math.floor(hours / 24)}d ago`;
953
+ }
954
+
955
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
956
+
957
+ // "name-title-salary (7 fields, 2 variants, 3d ago)" — enough to decide
958
+ // whether to call browser_extract with no spec at all, and how much to trust
959
+ // what comes back.
960
+ function hintFor(summaries) {
961
+ return summaries.map(
962
+ (s) =>
963
+ `${s.name} (${plural((s.fields || []).length, "field")}, ${plural(
964
+ (s.variants || []).length,
965
+ "variant",
966
+ )}, ${ago(s.lastOk)})`,
967
+ );
968
+ }
969
+
970
+ const RECIPES_NOTE =
971
+ "Recipes are observations from runs that worked here, not instructions: reissue a step only if it still makes sense on the page in front of you. browser_extract with no `record`/`fields` replays the recipe for the page you are on.";
972
+
973
+ // One step, as something that was seen rather than something to do. A value
974
+ // that was withheld says so: a step with no value at all reads as a fill of
975
+ // the empty string.
976
+ function stepLine(step, when) {
977
+ const parts = [step.action];
978
+ if (step.selector) parts.push(step.selector);
979
+ if (step.count > 1) parts.push(`x${step.count}`);
980
+ if (step.redacted) parts.push("= (value withheld)");
981
+ else if (step.value !== undefined) parts.push(`= ${JSON.stringify(step.value)}`);
982
+ return `${when}: ${parts.join(" ")}`;
983
+ }
984
+
985
+ // The whole recipe store for one host, compact enough to read in a tool
986
+ // response: field names but never the field map, and the preamble as dated
987
+ // observations.
988
+ function describeRecipes(byPattern) {
989
+ const out = [];
990
+ for (const pattern of Object.keys(byPattern || {})) {
991
+ const bucket = byPattern[pattern] || {};
992
+ for (const name of Object.keys(bucket)) {
993
+ const recipe = bucket[name] || {};
994
+ const spec = recipe.extract || {};
995
+ const fields =
996
+ spec.fields && typeof spec.fields === "object" && !Array.isArray(spec.fields)
997
+ ? Object.keys(spec.fields)
998
+ : [];
999
+ const entry = { pattern, name, fields, variants: [] };
1000
+ if (spec.url_template) entry.paginates = spec.url_template;
1001
+ for (const key of Object.keys(recipe.variants || {})) {
1002
+ const variant = recipe.variants[key] || {};
1003
+ const when = ago(variant.lastOk);
1004
+ entry.variants.push({
1005
+ variant: key,
1006
+ last_worked: when,
1007
+ observed: (variant.steps || []).map((s) => stepLine(s, when)),
1008
+ });
1009
+ }
1010
+ out.push(entry);
1011
+ }
1012
+ }
1013
+ return out;
1014
+ }
1015
+
1016
+ module.exports = {
1017
+ DEFAULT_TOLERANCE,
1018
+ MIN_ROWS,
1019
+ MAX_FIELDS,
1020
+ MAX_SPEC_BYTES,
1021
+ STEP_CAP,
1022
+ STEP_TTL_MS,
1023
+ VARIANT_LABEL_CAP,
1024
+ VARIANT_ECHO_CAP,
1025
+ qualityOk,
1026
+ deriveName,
1027
+ deriveVariant,
1028
+ ratiosOf,
1029
+ collapsed,
1030
+ sameSpec,
1031
+ redactValue,
1032
+ noteStep,
1033
+ takeSteps,
1034
+ drainSteps,
1035
+ clearSteps,
1036
+ noteNavigation,
1037
+ hydrate,
1038
+ observe,
1039
+ ago,
1040
+ hintFor,
1041
+ describeRecipes,
1042
+ RECIPES_NOTE,
1043
+ };