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.
- package/mcp-server/extract-all.js +10 -12
- package/mcp-server/index.js +162 -12
- package/mcp-server/memory.js +208 -4
- package/mcp-server/recipes.js +1043 -0
- package/mcp-server/tools.js +35 -5
- package/package.json +1 -1
|
@@ -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
|
+
};
|