sitelooper 0.3.0

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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +625 -0
  3. package/bin/sitelooper.js +6 -0
  4. package/dist/agent/llm.js +460 -0
  5. package/dist/agent/llm.js.map +1 -0
  6. package/dist/agent/loop.js +870 -0
  7. package/dist/agent/loop.js.map +1 -0
  8. package/dist/agent/prompt.js +40 -0
  9. package/dist/agent/prompt.js.map +1 -0
  10. package/dist/agent/report.js +545 -0
  11. package/dist/agent/report.js.map +1 -0
  12. package/dist/agent/tools.js +1147 -0
  13. package/dist/agent/tools.js.map +1 -0
  14. package/dist/cli.js +1692 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/daemon/browser.js +218 -0
  17. package/dist/daemon/browser.js.map +1 -0
  18. package/dist/daemon/codegen.js +241 -0
  19. package/dist/daemon/codegen.js.map +1 -0
  20. package/dist/daemon/dialogs.js +57 -0
  21. package/dist/daemon/dialogs.js.map +1 -0
  22. package/dist/daemon/diff.js +198 -0
  23. package/dist/daemon/diff.js.map +1 -0
  24. package/dist/daemon/fingerprint.js +98 -0
  25. package/dist/daemon/fingerprint.js.map +1 -0
  26. package/dist/daemon/inputs.js +134 -0
  27. package/dist/daemon/inputs.js.map +1 -0
  28. package/dist/daemon/recorder.js +1232 -0
  29. package/dist/daemon/recorder.js.map +1 -0
  30. package/dist/daemon/refs.js +194 -0
  31. package/dist/daemon/refs.js.map +1 -0
  32. package/dist/daemon/server.js +1724 -0
  33. package/dist/daemon/server.js.map +1 -0
  34. package/dist/daemon/state.js +239 -0
  35. package/dist/daemon/state.js.map +1 -0
  36. package/dist/doctor.js +90 -0
  37. package/dist/doctor.js.map +1 -0
  38. package/dist/shared/paths.js +80 -0
  39. package/dist/shared/paths.js.map +1 -0
  40. package/dist/shared/protocol.js +28 -0
  41. package/dist/shared/protocol.js.map +1 -0
  42. package/dist/shared/secrets.js +92 -0
  43. package/dist/shared/secrets.js.map +1 -0
  44. package/dist/shared/text.js +39 -0
  45. package/dist/shared/text.js.map +1 -0
  46. package/dist/skills/compile.js +1420 -0
  47. package/dist/skills/compile.js.map +1 -0
  48. package/dist/skills/components.js +456 -0
  49. package/dist/skills/components.js.map +1 -0
  50. package/dist/skills/flow.js +1041 -0
  51. package/dist/skills/flow.js.map +1 -0
  52. package/dist/skills/learn.js +406 -0
  53. package/dist/skills/learn.js.map +1 -0
  54. package/dist/skills/ledger.js +304 -0
  55. package/dist/skills/ledger.js.map +1 -0
  56. package/dist/skills/relabel.js +206 -0
  57. package/dist/skills/relabel.js.map +1 -0
  58. package/dist/skills/repair.js +570 -0
  59. package/dist/skills/repair.js.map +1 -0
  60. package/dist/skills/replay.js +1281 -0
  61. package/dist/skills/replay.js.map +1 -0
  62. package/dist/skills/store.js +147 -0
  63. package/dist/skills/store.js.map +1 -0
  64. package/dist/spec/check.js +428 -0
  65. package/dist/spec/check.js.map +1 -0
  66. package/dist/spec/diagnostics.js +58 -0
  67. package/dist/spec/diagnostics.js.map +1 -0
  68. package/dist/spec/emit.js +2084 -0
  69. package/dist/spec/emit.js.map +1 -0
  70. package/dist/spec/index.js +62 -0
  71. package/dist/spec/index.js.map +1 -0
  72. package/dist/spec/ir.js +216 -0
  73. package/dist/spec/ir.js.map +1 -0
  74. package/dist/spec/lift.js +162 -0
  75. package/dist/spec/lift.js.map +1 -0
  76. package/dist/spec/locators.js +270 -0
  77. package/dist/spec/locators.js.map +1 -0
  78. package/dist/spec/lower.js +124 -0
  79. package/dist/spec/lower.js.map +1 -0
  80. package/dist/spec/repair.js +657 -0
  81. package/dist/spec/repair.js.map +1 -0
  82. package/dist/spec/rerecord.js +169 -0
  83. package/dist/spec/rerecord.js.map +1 -0
  84. package/dist/spec/rethread.js +120 -0
  85. package/dist/spec/rethread.js.map +1 -0
  86. package/package.json +50 -0
  87. package/skills/sitelooper/SKILL.md +228 -0
@@ -0,0 +1,1281 @@
1
+ import { clip } from '../shared/text.js';
2
+ import { captureSignature } from '../daemon/diff.js';
3
+ import { cosine, fingerprintPage } from '../daemon/fingerprint.js';
4
+ import { candidateExpr, makeLocator, markPoint } from '../daemon/recorder.js';
5
+ import { retired } from './repair.js';
6
+ import { isRefTarget } from '../daemon/refs.js';
7
+ import { TRANSIENT_LINE, WILDCARD, fillParams, fillParamsDeep, maskMinted, maskVolatile, softUrlMatch, urlMatches, urlPart, urlPattern } from './compile.js';
8
+ /** Tools that look at or move to an element without setting or choosing anything. */
9
+ const OBSERVATION_TOOLS = new Set(['scroll_into_view', 'wait_for', 'hover', 'scroll', 'focus', 'screenshot', 'peek']);
10
+ import { originOf } from './store.js';
11
+ const MAX_LINE = 160;
12
+ // Shortest interacted/read value worth treating as an echo. Below this the
13
+ // coincidence rate is too high (a "1m" refresh, a "3" quantity) — a false echo
14
+ // would wrongly drop a legitimate finding, so only substantial values qualify.
15
+ const MIN_ECHO_LEN = 5;
16
+ /** Tools whose miss can be substituted by navigating to the step's recorded
17
+ * destination: plain navigation clicks. modifier_click (new tabs) and loop
18
+ * bodies are excluded. */
19
+ const NAV_FALLBACK_TOOLS = new Set(['click', 'dblclick']);
20
+ /** A soft-matched precondition needs the page's structural fingerprint to
21
+ * agree before replay proceeds on it. Same-template-different-record pages
22
+ * measured 0.94–1.0 in the swg sweeps; the different-template fixture pair
23
+ * measures 0.57. */
24
+ const SOFT_MATCH_MIN_SIMILARITY = 0.8;
25
+ /**
26
+ * Replay a stored skill deterministically: precondition → each step with its
27
+ * locator chain → expectation check → next. Stops at the first failure and
28
+ * hands back exactly what ran, so the agent can continue from the real page
29
+ * state without repeating anything.
30
+ */
31
+ export async function replaySkill(skill, params, opts) {
32
+ const { page } = opts;
33
+ const res = {
34
+ ok: false,
35
+ skill: skill.id,
36
+ stepsRun: 0,
37
+ stepsTotal: skill.steps.length,
38
+ values: {},
39
+ echoedValues: [],
40
+ lines: [],
41
+ warnings: [],
42
+ fallthroughs: 0,
43
+ misses: [],
44
+ derivedValues: {},
45
+ generalisations: [],
46
+ candidateEvidence: [],
47
+ created: [],
48
+ acted: false,
49
+ similarity: null,
50
+ url: page.url(),
51
+ };
52
+ // Values the skill puts on the page as it runs — fill/type values, and the
53
+ // names of options it clicks. A later read that returns one of these is an
54
+ // echo (confirming the control, not app persistence); see echoedValues.
55
+ // Loosely keyed so "Last 6 hours" matches "last 6 hours".
56
+ const interacted = new Set();
57
+ // A recorded dialog that did not open (see StepVerdict.absentDialog): while
58
+ // set, a step whose target cannot be found is skipped as belonging to that
59
+ // dialog rather than stopping the replay; cleared by the next step that
60
+ // resolves its target normally.
61
+ let absentDialog = null;
62
+ const looseKey = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
63
+ // Copy the caller's bindings: derived ({{dN}}) values minted mid-replay are
64
+ // bound into this map as steps execute, so later steps see them.
65
+ params = { ...params };
66
+ const missing = Object.keys(skill.params).filter((p) => !(p in params) || params[p] === '');
67
+ if (missing.length) {
68
+ res.refused = true;
69
+ res.reason = `missing params: ${missing.map((m) => `${m} (e.g. ${JSON.stringify(skill.params[m].example)})`).join(', ')} — nothing was run`;
70
+ return res;
71
+ }
72
+ const startUrl = page.url();
73
+ // A procedure whose FIRST step navigates (goto) carries its own
74
+ // precondition: wherever the browser is, step 1 puts it on the recorded
75
+ // page. Refusing it by start-url would make it permanently unreplayable on
76
+ // apps that redirect at load (the recorded start url is a race between the
77
+ // capture and the redirect) — the flow6 head step failed exactly this way.
78
+ const navigatesItself = skill.steps[0]?.tool === 'goto';
79
+ if (skill.preconditions.fingerprint) {
80
+ res.similarity = cosine(skill.preconditions.fingerprint, (await fingerprintPage(page)) ?? undefined);
81
+ }
82
+ if (!navigatesItself && !urlMatches(skill.preconditions.urlPattern, startUrl, params)) {
83
+ // Same page shape with 1-2 disagreeing segments is likely an
84
+ // environment-minted id (an Odoo action id, a Grafana uid): proceed
85
+ // optimistically instead of refusing — a hard fail here is what turned a
86
+ // one-segment difference into a dead flow, and it also makes the
87
+ // volatility evidence uncollectible. But "likely" is not evidence, so
88
+ // when the segment carries a structural fingerprint, that second gate
89
+ // decides: a different RECORD of the same template fingerprints close
90
+ // (0.94–1.0 in the swg sweeps); a different TEMPLATE does not (the
91
+ // fixture pair measures 0.57). Only a close page proceeds.
92
+ const soft = softUrlMatch(skill.preconditions.urlPattern, startUrl, params);
93
+ const structurallySame = res.similarity === null || res.similarity >= SOFT_MATCH_MIN_SIMILARITY;
94
+ if (!soft || !structurallySame) {
95
+ res.refused = true;
96
+ res.reason =
97
+ `not on the page this procedure starts from (expects ${fillParams(skill.preconditions.urlPattern, params)}, browser is at ${urlPattern(startUrl)}` +
98
+ (soft && !structurallySame ? `; the url shape is close but the page structure is not — similarity ${res.similarity}` : '') +
99
+ `) — nothing was run`;
100
+ return res;
101
+ }
102
+ res.warnings.push(`start url differs from the recorded pattern in ${soft.diffs.length} segment(s) (${soft.diffs.map((d) => `${d.expected}→${d.actual}`).join(', ')}) — proceeding optimistically`);
103
+ res.generalisations.push({ kind: 'precondition', pattern: soft.generalised });
104
+ }
105
+ // Identity: the url pattern and the fingerprint both match every record of
106
+ // this template, so neither can tell ticket t15 from ticket t14. A segment
107
+ // that started on a page showing caller-vouched values must find them
108
+ // again, or it is about to do this run's work on someone else's record.
109
+ //
110
+ // A self-navigating procedure is checked AFTER its goto, not skipped. The
111
+ // old rule was "step 1 decides the page", which is true and beside the
112
+ // point: the recorded goto carries the RECORDING run's record id, so it
113
+ // decides the page to be the wrong one. fwod10 replayed
114
+ // goto .../web#id=44&...&model=res.partner
115
+ // and steps 03-07 did this run's work on n1's records at tier A, published
116
+ // no values, reported success, and verified 1/6. The guard designed to stop
117
+ // exactly that was disabled precisely for the procedures most likely to
118
+ // need it.
119
+ const checkIdentity = async () => {
120
+ for (const marker of skill.preconditions.requireText ?? []) {
121
+ const want = fillParams(marker, params);
122
+ if (!want || /\{\{/.test(want))
123
+ continue; // unbound marker proves nothing
124
+ if (await presentOnPage(page, [want]))
125
+ continue;
126
+ res.refused = true;
127
+ res.wrongRecord = `the page at ${urlPattern(page.url())} does not show ${JSON.stringify(clip(want, 60))} — it matches this procedure's page template but is a different record — nothing was run`;
128
+ res.reason = res.wrongRecord;
129
+ return false;
130
+ }
131
+ return true;
132
+ };
133
+ if (!navigatesItself && skill.preconditions.requireText?.length && !(await checkIdentity()))
134
+ return res;
135
+ // One step against the live page. Mutates `res` (lines/warnings/values/
136
+ // stepsRun) and returns how it went; a 'stop' has already set failedAt/reason.
137
+ // `tag` labels the step for humans (e.g. "5" or, inside a loop, "9.2.1");
138
+ // `failIndex` is the top-level step number recorded in failedAt on a stop.
139
+ const runOneStep = async (step, tag, failIndex,
140
+ /** When set (loop bodies), collects what each target actually resolved to, for the loop's progress check. */
141
+ sink,
142
+ /** Loop-body cursor: which match an ambiguous per-record locator should act on (see resolveChain). */
143
+ ambiguousNth) => {
144
+ const args = fillParamsDeep(step.args, params);
145
+ const head = `${tag}. ${step.tool} ${describeArgs(step.tool, args)}`;
146
+ // The agent's observation turns were implicit waits; a replay has none,
147
+ // so let the DOM go quiet before looking for this step's target. Generic
148
+ // (no network-idle, no app knowledge) and instant on a static page.
149
+ await settleDom(page);
150
+ // A read/read_all is an OBSERVATION, not a state change: its failure means
151
+ // a value could not be re-captured, never that the procedure is broken. So
152
+ // a read that cannot resolve or errors is skipped with a warning and the
153
+ // replay continues — only an action step (click/fill/submit) or a hard
154
+ // expectation stops it. read_all also legitimately matches many elements,
155
+ // so its target need not be unique.
156
+ const isRead = step.tool === 'read' || step.tool === 'read_all';
157
+ // Resolve every target through its chain before touching the page.
158
+ const resolved = {};
159
+ let resolveError = null;
160
+ // Whether ANY target of this step resolved through a structural candidate
161
+ // — position, not identity. Sharpens the effect gate below: a positional
162
+ // resolution must be corroborated by a consequential page change, not by
163
+ // the fill's own echo.
164
+ let positionalResolution = false;
165
+ for (const key of ['target', 'source']) {
166
+ if (!(key in args))
167
+ continue;
168
+ const chain = fillParamsDeep(step.locators[key] ?? [], params) ?? [];
169
+ const identity = identityOfPrimary(step.locators[key] ?? [], skill, params);
170
+ const policy = {
171
+ rawTarget: typeof args[key] === 'string' ? String(args[key]) : '',
172
+ allowMultiple: step.tool === 'read_all',
173
+ ambiguousNth,
174
+ requireIdentity: identity,
175
+ // Polling to FIND something that is supposed to be gone only delays
176
+ // the answer; the absence is the condition (see waitsForAbsence).
177
+ waitMs: waitsForAbsence(step, args) ? 0 : resolveWaitMs(),
178
+ stayOnOrigin: originOf(step.expect?.urlPattern ?? '') ?? originOf(page.url()) ?? undefined,
179
+ };
180
+ let hit = await resolveChain(page, chain, policy);
181
+ // A virtualised page renders below-the-fold content only once it has
182
+ // been scrolled to. The agent's scrolls were evals, which never compile,
183
+ // so a read of the third panel heading found two headings and was
184
+ // skipped (fwgr23 01-open, both replays: objective 1 lost). One sweep
185
+ // of the page before giving up on an observation.
186
+ if (!hit && isRead && (await sweepPage(page)))
187
+ hit = await resolveChain(page, chain, { ...policy, waitMs: 0 });
188
+ if (!hit) {
189
+ // A wait for an element to be HIDDEN is satisfied by its absence: the
190
+ // step's own success condition is "nothing matches", so a dead chain
191
+ // here is the recorded outcome, not drift. fwrd42's 06-report waited
192
+ // for a deleted part's text to go and filed a drift ticket on every
193
+ // run, which no repair could clear because nothing was wrong.
194
+ if (waitsForAbsence(step, args)) {
195
+ res.lines.push(`${head} → condition met: ${String(args.state)} (nothing matched)`);
196
+ return 'ran';
197
+ }
198
+ resolveError = `no element matched any known locator for ${key}${chain.length ? ` (tried ${chain.length}: ${chain.slice(0, 3).map(candidateExpr).join(', ')}${chain.length > 3 ? ', …' : ''})` : ' (none recorded)'}`;
199
+ res.misses.push({ step: tag, key, primary: chain[0] ? candidateExpr(chain[0]) : '(none recorded)', used: null });
200
+ break;
201
+ }
202
+ resolved[key] = hit.locator;
203
+ if (structural(hit.candidate))
204
+ positionalResolution = true;
205
+ // Evidence ONLY from a pass whose winner names something. When a
206
+ // structural path won, that is precisely the resolution we distrust —
207
+ // it may have acted on whatever sorted into that position — and banking
208
+ // it would retire the anchors that missed and confirm the path that hit,
209
+ // turning one bad resolution into a permanent one. fwrd26l did exactly
210
+ // that: its 8/8 zero-model replay had retired two identity anchors in
211
+ // favour of `tr:nth-of-type(1)`.
212
+ if (hit.missed.length && !structural(hit.candidate)) {
213
+ res.candidateEvidence.push({ step: tag, key, hit: hit.index, missed: hit.missed });
214
+ }
215
+ sink?.push(`${key}=${candidateExpr(hit.candidate)}`);
216
+ // Record what this action put on the page (see `interacted`). Only for
217
+ // non-read steps: a read observes, it does not set. The accessible name
218
+ // of a clicked option ("Last 6 hours") is the value it selects.
219
+ // Only a step that can SET or SELECT something counts. A scroll to the
220
+ // heading "Latency by endpoint" set nothing, but its target's name
221
+ // landed here and the later read of that heading was discounted as an
222
+ // echo — fwgr23 published two of three panel titles on every replay
223
+ // and objective 1 failed each time.
224
+ if (!isRead && !OBSERVATION_TOOLS.has(step.tool)) {
225
+ for (const cand of chain) {
226
+ const named = cand.name ?? cand.label;
227
+ if (named && named.length >= MIN_ECHO_LEN)
228
+ interacted.add(looseKey(named));
229
+ }
230
+ }
231
+ if (hit.index > 0) {
232
+ res.fallthroughs++;
233
+ res.misses.push({ step: tag, key, primary: candidateExpr(chain[0]), used: candidateExpr(hit.candidate), usedIndex: hit.index });
234
+ res.warnings.push(`step ${tag}: primary locator did not resolve; used fallback #${hit.index + 1} ${candidateExpr(hit.candidate)}`);
235
+ }
236
+ }
237
+ // A typed/filled value is likewise something the skill put on the page.
238
+ const setsSomething = !isRead && !OBSERVATION_TOOLS.has(step.tool);
239
+ if (setsSomething && typeof args.value === 'string' && args.value.length >= MIN_ECHO_LEN)
240
+ interacted.add(looseKey(args.value));
241
+ if (setsSomething && typeof args.text === 'string' && args.text.length >= MIN_ECHO_LEN)
242
+ interacted.add(looseKey(args.text));
243
+ if (!resolveError)
244
+ absentDialog = null;
245
+ if (resolveError) {
246
+ if (isRead) {
247
+ res.warnings.push(`step ${tag}: skipped read — ${resolveError}`);
248
+ res.lines.push(`${head} → skipped (${resolveError})`);
249
+ return 'skipped';
250
+ }
251
+ if (absentDialog !== null) {
252
+ res.warnings.push(`step ${tag}: skipped — acts inside the dialog ${JSON.stringify(absentDialog)}, which did not open this time`);
253
+ res.lines.push(`${head} → skipped (dialog ${JSON.stringify(absentDialog)} did not open)`);
254
+ return 'skipped';
255
+ }
256
+ // Navigation by recorded destination (PLAN-replay-v2 "order of
257
+ // application", rung 3). A navigation step's recorded EVIDENCE includes
258
+ // where it landed; the clicked affordance (a recents list, a shortcut —
259
+ // anything session-local) may be gone on a fresh browser, but the
260
+ // destination is what the step was for. Two sub-rungs, because this is
261
+ // testing how the app works for a HUMAN: (a) another link on the page
262
+ // to the same destination — click that, exercising the app's own
263
+ // navigation; (b) only then, and only when the destination is fully
264
+ // concrete (params/derived filled, nothing volatile left), navigate
265
+ // there directly. Both are logged as fallthroughs so drift telemetry
266
+ // and post-session repair still see the miss.
267
+ const destPattern = step.expect?.urlPattern;
268
+ const isMove = Boolean(destPattern) && NAV_FALLBACK_TOOLS.has(step.tool) && !tag.includes('.') && !urlMatches(destPattern, page.url(), params);
269
+ if (isMove) {
270
+ const arrived = await navigateToDestination(page, destPattern, params, (tool, a, resolved) => opts.exec(tool, a, resolved, { skill: skill.id, step: failIndex }));
271
+ if (arrived) {
272
+ const miss = res.misses[res.misses.length - 1];
273
+ if (miss && miss.step === tag)
274
+ miss.used = arrived.used;
275
+ res.fallthroughs++;
276
+ res.warnings.push(`step ${tag}: ${resolveError}; ${arrived.note}`);
277
+ res.lines.push(`${head} → target gone; ${arrived.note}`);
278
+ return 'ran';
279
+ }
280
+ }
281
+ res.failedAt = failIndex;
282
+ res.reason = resolveError;
283
+ res.lines.push(`${head} → FAILED: ${resolveError}`);
284
+ return 'stop';
285
+ }
286
+ let outcome;
287
+ // Dispatched, not completed. A step whose action fires and whose
288
+ // EXPECTATION then fails returns 'stop' without incrementing stepsRun, so
289
+ // stepsRun === 0 has never meant "the page was not touched" — and the
290
+ // caller reads it as exactly that before trying another candidate. A
291
+ // second candidate then clicks Create again.
292
+ if (!isRead)
293
+ res.acted = true;
294
+ const urlBefore = page.url();
295
+ // A click that opens a popup (menu, dialog, listbox) is a TOGGLE in most
296
+ // SPAs: the same click on an already-open popup closes it. When the
297
+ // recorded effect is already showing before the click, the click would
298
+ // undo the state the next step depends on — fwgr26's third click on
299
+ // "New" shut the menu its "New dashboard" link lived in, on every
300
+ // replay. Skipped as already in effect. Only popup lines count: a
301
+ // re-usable effect (another row of textboxes) must still be produced.
302
+ const opener = openerLines(step, params);
303
+ if (opener.length && (await presentOnPage(page, opener))) {
304
+ res.warnings.push(`step ${tag}: the recorded effect (${clip(opener[0], 60)}) is already showing — a click would toggle it away; skipped as already in effect`);
305
+ res.lines.push(`${head} → skipped (already in effect)`);
306
+ return 'skipped';
307
+ }
308
+ // A click that produced NO observable change at all (no diff, no url
309
+ // change, no alert) while the recording shows one most likely landed
310
+ // during a repaint — React detaches and re-mounts controls between
311
+ // frames, and Playwright's click can hit the old node. One retry after
312
+ // the DOM settles; a click that changed anything is never repeated.
313
+ for (let attempt = 0;; attempt++) {
314
+ try {
315
+ outcome = await opts.exec(step.tool, args, resolved, { skill: skill.id, step: failIndex });
316
+ }
317
+ catch (err) {
318
+ const message = (err instanceof Error ? err.message : String(err)).split('\nCall log:')[0];
319
+ if (isRead) {
320
+ res.warnings.push(`step ${tag}: read errored — ${clip(message, 120)}`);
321
+ res.lines.push(`${head} → skipped (${clip(message, 120)})`);
322
+ return 'skipped';
323
+ }
324
+ res.failedAt = failIndex;
325
+ res.reason = `${step.tool} failed: ${clip(message, 300)}`;
326
+ res.lines.push(`${head} → FAILED: ${clip(message, 300)}`);
327
+ return 'stop';
328
+ }
329
+ // A navigation renders a route skeleton first; let it hydrate before
330
+ // the effect gates look for the recorded content.
331
+ if (page.url() !== urlBefore)
332
+ await settleDom(page);
333
+ // Bind values this step just minted (derived params) from the live url,
334
+ // BEFORE the expectation check: the minting step's own expectation refers
335
+ // to the value it produced, so it must compare against the replay's own.
336
+ if (skill.derived) {
337
+ for (const [name, d] of Object.entries(skill.derived)) {
338
+ if (d.step !== failIndex)
339
+ continue;
340
+ const v = urlPart(page.url(), d.at);
341
+ if (v !== undefined) {
342
+ params[name] = v;
343
+ res.derivedValues[name] = v;
344
+ }
345
+ }
346
+ }
347
+ // A step declared record-minting has now run: read THIS run's identifier
348
+ // off the live url and keep it. If the replay later stops, recovery is
349
+ // told the record already exists and what it is called, instead of being
350
+ // told only how many steps ran and left to infer the rest — which is how
351
+ // fwod13 came to create a second and third order.
352
+ if (step.mints) {
353
+ // Only a part the step CHANGED: a rejected click leaves the url as it
354
+ // was, and "new" from /tickets/new is not a record this run created.
355
+ const made = urlPart(page.url(), step.mints.at);
356
+ if (made && made !== urlPart(urlBefore, step.mints.at) && !res.created.includes(made))
357
+ res.created.push(made);
358
+ }
359
+ // Effect gates: did the step leave the page as the recording said it
360
+ // would? Each gate's warnings and staged generalisations always apply; a
361
+ // stop ends the replay here, with what ran already in `res`.
362
+ let stop = null;
363
+ const warnings = [];
364
+ for (const gate of STEP_GATES) {
365
+ const verdict = await gate({ page, step, tag, failIndex, args, params, outcome, isRead, positionalResolution });
366
+ if (!verdict)
367
+ continue;
368
+ if (verdict.warnings)
369
+ warnings.push(...verdict.warnings);
370
+ if (verdict.generalise)
371
+ res.generalisations.push(verdict.generalise);
372
+ if (verdict.absentDialog !== undefined)
373
+ absentDialog = verdict.absentDialog;
374
+ if (verdict.stop) {
375
+ stop = verdict;
376
+ break;
377
+ }
378
+ }
379
+ if (!stop) {
380
+ res.warnings.push(...warnings);
381
+ break;
382
+ }
383
+ const noEffect = step.tool === 'click' && !!outcome.diff && !outcome.diff.added.length && !outcome.diff.alerts.length && page.url() === urlBefore;
384
+ if (stop.retryable && noEffect && attempt === 0) {
385
+ res.warnings.push(`step ${tag}: the click changed nothing on the page — retried once after the DOM settled`);
386
+ await settleDom(page);
387
+ continue;
388
+ }
389
+ res.warnings.push(...warnings);
390
+ // The step took the tab off the app (an error page, another origin):
391
+ // whoever picks up from here — recovery, the next segment — needs the
392
+ // app, not the wreck. Go back to where the step started. rpgr13-r2's
393
+ // recovery spent its whole budget on chrome-error://chromewebdata/.
394
+ const landed = page.url();
395
+ if (landed !== urlBefore && (/^chrome-error:|^about:neterror/.test(landed) || (originOf(landed) ?? '') !== (originOf(urlBefore) ?? ''))) {
396
+ try {
397
+ await page.goto(urlBefore, { waitUntil: 'domcontentloaded' });
398
+ warnings.push(`step ${tag}: the browser was returned to ${urlPattern(urlBefore)} from ${urlPattern(landed)}`);
399
+ res.warnings.push(warnings[warnings.length - 1]);
400
+ }
401
+ catch {
402
+ // the tab is truly gone; the stop below says where it ended
403
+ }
404
+ }
405
+ res.failedAt = failIndex;
406
+ res.reason = stop.stop;
407
+ res.lines.push(`${head} → ran, but ${stop.stop}`);
408
+ return 'stop';
409
+ }
410
+ if (isRead) {
411
+ const key = step.label ?? `read${tag}`;
412
+ const value = parseRead(outcome.result);
413
+ res.values[key] = value;
414
+ // An echo read: this value is only what the skill itself set or chose,
415
+ // so it confirms the control's display, not that the app persisted it.
416
+ if (value && value.length >= MIN_ECHO_LEN && interacted.has(looseKey(value))) {
417
+ res.echoedValues.push(key);
418
+ res.warnings.push(`step ${tag}: read '${key}' returned a value the skill itself set/selected ('${clip(value, 60)}') — confirms the control, not persistence; dropped from the report's confident values`);
419
+ }
420
+ res.lines.push(`${head} → ${key} = ${clip(outcome.result, MAX_LINE)}`);
421
+ }
422
+ else {
423
+ res.lines.push(`${head} → ${clip(outcome.result.split('\n')[0], MAX_LINE)}`);
424
+ }
425
+ return 'ran';
426
+ };
427
+ // A folded loop: repeat the body while its guard locator still matches an
428
+ // element, capped at `max`. Counts as ONE top-level step no matter how many
429
+ // times the body runs, so the ok check below stays about top-level progress.
430
+ const runLoop = async (step, n) => {
431
+ const body = step.body ?? [];
432
+ const guard = step.while ?? body[0]?.locators.target ?? [];
433
+ const max = step.max ?? 20;
434
+ const before = res.stepsRun;
435
+ let iter = 0;
436
+ // Progress guard: a folded loop exists because the recording acted on one
437
+ // RECORD after another, so every iteration must either shrink the guard's
438
+ // match count (a delete loop) or resolve different elements (a per-record
439
+ // edit). When neither happens the per-record locators have stopped
440
+ // distinguishing records — fwrd4l-n3's "edit each part's supplier" loop
441
+ // missed its ambiguous role locator, fell through to a positional path
442
+ // pinned to ROW 1, and edited the same part seven times while the replay
443
+ // counted it as progress. Same targets + no shrink = fail to recovery.
444
+ let prevSig = null;
445
+ let prevRemaining = Number.POSITIVE_INFINITY;
446
+ // Cursor over unprocessed records: a delete loop shrinks the guard count,
447
+ // so match 0 is always the next record; an edit-in-place loop leaves the
448
+ // count alone, so the next record is the next match index. The cursor
449
+ // advances exactly when the previous iteration did not consume its record.
450
+ let cursor = 0;
451
+ while (iter < max) {
452
+ // A loop cut short by the budget is NOT a finished loop: breaking out
453
+ // used to count it as 'ran', and a part-cleared list became a success.
454
+ if (opts.signal?.aborted) {
455
+ res.stepsRun = before;
456
+ res.failedAt = n;
457
+ res.reason = `instruction budget exhausted after ${iter} loop iteration(s), before the loop finished`;
458
+ res.lines.push(`${n}. loop → stopped after ×${iter}: ${res.reason}`);
459
+ return 'stop';
460
+ }
461
+ await settleDom(page);
462
+ const chain = fillParamsDeep(guard, params);
463
+ // No waitMs: this asks whether the list still has rows, and a null
464
+ // return is the loop's normal exit, not a failure to find something.
465
+ const hit = await resolveChain(page, chain, { allowMultiple: true });
466
+ const remaining = hit ? await hit.locator.count().catch(() => 0) : 0;
467
+ if (!remaining || cursor >= remaining)
468
+ break;
469
+ const sig = [];
470
+ for (const [k, bstep] of body.entries()) {
471
+ const st = await runOneStep(bstep, `${n}.${iter + 1}.${k + 1}`, n, sig, cursor);
472
+ if (st === 'stop') {
473
+ res.stepsRun = before;
474
+ return 'stop';
475
+ }
476
+ }
477
+ const joined = sig.join('; ');
478
+ if (joined && joined === prevSig && remaining >= prevRemaining) {
479
+ res.stepsRun = before;
480
+ res.failedAt = n;
481
+ res.reason =
482
+ `loop iteration ${iter + 1} resolved the same element(s) as the previous one with the guard count unchanged (${remaining}) — ` +
483
+ `the recorded per-record locators no longer distinguish records, so the loop was re-acting on one record`;
484
+ res.lines.push(`${n}. loop → FAILED after ×${iter + 1}: ${res.reason}`);
485
+ return 'stop';
486
+ }
487
+ prevSig = joined;
488
+ // Did this iteration consume its record (guard shrank) or leave it in
489
+ // place (edit-in-place)? Advance the cursor only in the second case.
490
+ // Settle first: a delete's row removal landing late would otherwise
491
+ // advance the cursor and make the next iteration skip a record.
492
+ await settleDom(page);
493
+ // Recount with the SAME candidate that produced `remaining`. Re-walking
494
+ // the chain can answer from a different rung — the recorded guard
495
+ // `[data-testid="del-1"]` matches 1 before its row goes and 0 after, but
496
+ // the chain then falls through to a generic `button "Remove"` matching
497
+ // the OTHER rows, so a shrink read as growth, advanced the cursor, and
498
+ // left the last row undeleted while the loop reported success.
499
+ const count = async () => makeLocator(page, hit.candidate).count().catch(() => 0);
500
+ // Poll for the shrink rather than reading the count once: a row that
501
+ // leaves the DOM a beat after the click would otherwise look like an
502
+ // edit-in-place, advance the cursor, and make a delete loop skip a
503
+ // record — and then stop early on `cursor >= remaining`, leaving the
504
+ // list part-cleared while reporting success.
505
+ let after = await count();
506
+ for (let waited = 0; after >= remaining && waited < LOOP_SHRINK_WAIT_MS; waited += LOOP_SHRINK_POLL_MS) {
507
+ await page.waitForTimeout(LOOP_SHRINK_POLL_MS);
508
+ after = await count();
509
+ }
510
+ if (after >= remaining)
511
+ cursor++;
512
+ prevRemaining = remaining;
513
+ iter++;
514
+ }
515
+ res.stepsRun = before + 1;
516
+ res.lines.push(`${n}. loop ×${iter} (while ${chain0Desc(guard)} matches)`);
517
+ return 'ran';
518
+ };
519
+ for (const [i, step] of skill.steps.entries()) {
520
+ const n = i + 1;
521
+ if (opts.signal?.aborted) {
522
+ res.failedAt = n;
523
+ res.reason = 'instruction budget exhausted before this step';
524
+ res.lines.push(`${n}. ${step.tool} — not run (budget exhausted)`);
525
+ break;
526
+ }
527
+ if (step.tool === 'loop') {
528
+ if ((await runLoop(step, n)) === 'stop')
529
+ break;
530
+ continue;
531
+ }
532
+ const status = await runOneStep(step, String(n), n);
533
+ if (status === 'stop')
534
+ break;
535
+ res.stepsRun++;
536
+ // The identity check a self-navigating procedure deferred: its goto has
537
+ // now run, so ask whether it landed on THIS run's record before doing any
538
+ // work on it. Refusing here costs a recovery; not refusing costs the work
539
+ // being done to the wrong record and reported as success.
540
+ if (navigatesItself && n === 1 && skill.preconditions.requireText?.length && !(await checkIdentity())) {
541
+ // NOT `refused`. Refused means "nothing ran, free to try the next
542
+ // candidate" — and the goto has already moved the browser, so trying
543
+ // another candidate would run it from a page nobody expects. This is a
544
+ // partial stop: what ran, ran, and the caller hands it to recovery
545
+ // rather than restarting. `wrongRecord` still tells the flow runner to
546
+ // put the browser back on the flow's start url first.
547
+ res.refused = false;
548
+ res.failedAt = n;
549
+ res.url = page.url(); // the goto moved the browser; the caller repositions from here
550
+ return res;
551
+ }
552
+ }
553
+ res.ok = res.stepsRun === skill.steps.length && res.failedAt === undefined;
554
+ res.url = page.url();
555
+ return res;
556
+ }
557
+ /**
558
+ * Hard expectation: where the step was supposed to leave the browser. A
559
+ * same-shape url whose literal segment(s) disagree is treated as volatile
560
+ * (mechanism 2): warn, stage the generalisation, continue.
561
+ */
562
+ const expectedUrl = async ({ step, page, params, tag, failIndex }) => {
563
+ const pattern = step.expect?.urlPattern;
564
+ if (!pattern || urlMatches(pattern, page.url(), params))
565
+ return null;
566
+ // The recorded url may still be on its way: an SPA sign-in answers the
567
+ // click, then routes to the landing page a moment later. fwat2's sign-in
568
+ // step was judged at "/" on every replay and sent to recovery, whose
569
+ // report then lacked the landing-page value every later step referred to.
570
+ // Give a navigation in flight the resolve window before judging.
571
+ for (let waited = 0; waited < resolveWaitMs(); waited += RESOLVE_POLL_MS) {
572
+ await new Promise((r) => setTimeout(r, RESOLVE_POLL_MS));
573
+ if (urlMatches(pattern, page.url(), params))
574
+ return null;
575
+ }
576
+ const soft = softUrlMatch(pattern, page.url(), params);
577
+ if (!soft)
578
+ return { stop: `after step ${tag} expected url ${fillParams(pattern, params)} but browser is at ${urlPattern(page.url())}` };
579
+ return {
580
+ warnings: [`step ${tag}: url segment(s) differ from recorded (${soft.diffs.map((d) => `${d.expected}→${d.actual}`).join(', ')}) — treated as volatile`],
581
+ generalise: { kind: 'expect', step: failIndex, pattern: soft.generalised },
582
+ };
583
+ };
584
+ /**
585
+ * An alert the recording never saw is the app talking back — usually a
586
+ * rejection ("Ticket is not ready…") that leaves the page superficially
587
+ * intact. fwrd4l-n3 clicked into exactly that: the step counted as run, the
588
+ * synthesized report declared the recorded outcome, and only external
589
+ * verification caught that the ticket never reached Ready. So a
590
+ * state-changing step that provokes an UNRECORDED alert fails hard, while a
591
+ * recorded-but-missing alert stays soft (expectedAlert — toasts are volatile).
592
+ */
593
+ const unrecordedAlert = ({ outcome, isRead, step, tag }) => {
594
+ if (!outcome.diff?.alerts.length || isRead || step.expect?.alertContains)
595
+ return null;
596
+ return { stop: `step ${tag} raised an alert the recording never saw: ${clip(outcome.diff.alerts.join(' | '), 200)}` };
597
+ };
598
+ const expectedAlert = ({ outcome, step, params, tag }) => {
599
+ if (!outcome.diff || !step.expect?.alertContains)
600
+ return null;
601
+ const want = fillParams(step.expect.alertContains, params);
602
+ return outcome.diff.alerts.some((a) => a.includes(want)) ? null : { warnings: [`step ${tag}: expected alert containing ${JSON.stringify(want)}`] };
603
+ };
604
+ /**
605
+ * Page-change expectations. Lines that carry a parameter are HARD: they are
606
+ * what distinguishes this run from the recorded one (the new title appearing
607
+ * as a heading), so their absence means the step acted on the wrong thing
608
+ * even though it "worked". Everything else stays soft until data says it is
609
+ * reliable — but a plain change absent from the diff AND the live page means
610
+ * the action did not have its recorded effect, and failing there is what
611
+ * turns a rejected state change into a clean recovery instead of a false
612
+ * success (the fwrd4l-n3 Ready click).
613
+ */
614
+ const expectedChanges = async ({ outcome, step, params, tag, args, page, positionalResolution }) => {
615
+ if (!outcome.diff || !step.expect?.addedContains?.length)
616
+ return null;
617
+ const warnings = [];
618
+ // A line carrying a {{vN}} slot is HARD (below). A {{dN}} derived marker
619
+ // is filled like any other param but stays soft — the app minted it.
620
+ const isParam = (l) => /\{\{v\d+\}\}/.test(l);
621
+ // maskVolatile at replay too, so a store compiled before masking existed
622
+ // (every skill recorded up to set 24) stops failing on the recording's clock.
623
+ // Transient lines (spinners, progress bars) are dropped here too, so a
624
+ // store compiled before TRANSIENT_LINE existed stops failing on them.
625
+ const lines = step.expect.addedContains.filter((l) => !TRANSIENT_LINE.test(l));
626
+ if (!lines.length)
627
+ return null;
628
+ let parameterised = lines.filter(isParam).map((l) => fillParams(maskMinted(maskVolatile(l)), params));
629
+ const plain = lines.filter((l) => !isParam(l)).map((l) => fillParams(maskMinted(maskVolatile(l)), params));
630
+ // A positionally-resolved fill must prove itself with a CONSEQUENTIAL
631
+ // change: its own echo in a same-role element is what the wrong element
632
+ // produces too (see consequentialExpectations). When the echo is all the
633
+ // recording has, the old gate stands and we say so.
634
+ if (positionalResolution && parameterised.length && typeof args.value === 'string' && args.value) {
635
+ const value = args.value;
636
+ const consequential = parameterised.filter((l) => !isEchoLine(l, value));
637
+ if (consequential.length)
638
+ parameterised = consequential;
639
+ else
640
+ warnings.push(`step ${tag}: resolved positionally and its only recorded effect is the fill's own echo — the effect gate cannot tell right element from wrong here`);
641
+ }
642
+ if (parameterised.length && !lineShows(outcome.diff.added, parameterised) && !(await presentOnPage(page, parameterised))) {
643
+ return { warnings, stop: `after step ${tag} the page did not show ${parameterised.map((w) => JSON.stringify(w)).join(' / ')} as it did when recorded — the step ran but probably acted on the wrong element` };
644
+ }
645
+ if (plain.length && !lineShows(outcome.diff.added, plain)) {
646
+ // None of the recorded effects in the step diff — check the live page
647
+ // before judging (a change can land outside the diff window).
648
+ if (!(await presentOnPage(page, plain))) {
649
+ // The recorded effect was a dialog opening. A dialog is conditional
650
+ // UI: fwgr24's create step recorded "Exit edit" → "Discard changes to
651
+ // dashboard?" because the RECORDING had unsaved edits at that moment;
652
+ // a replay whose earlier steps saved cleanly has none, no dialog
653
+ // opens, and that is the app working — not the step failing. The
654
+ // steps that would have acted inside the dialog are skipped instead.
655
+ const dialog = plain.map((l) => /^-\s*dialog\s+"([^"]*)"/.exec(l)?.[1]).find((n) => n !== undefined);
656
+ if (dialog !== undefined) {
657
+ warnings.push(`step ${tag}: the recorded dialog ${JSON.stringify(dialog)} did not open — conditional UI, treated as absent; steps inside it will be skipped`);
658
+ return { warnings, absentDialog: dialog };
659
+ }
660
+ return { warnings, retryable: true, stop: `after step ${tag} none of the ${plain.length} recorded page change(s) appeared (e.g. ${JSON.stringify(plain[0])}) — the step ran but did not have its recorded effect` };
661
+ }
662
+ warnings.push(`step ${tag}: none of the ${plain.length} expected page change(s) appeared in the step diff (found on the page instead)`);
663
+ }
664
+ return warnings.length ? { warnings } : null;
665
+ };
666
+ /** The effect gates a step passes through after its action, in order. */
667
+ /**
668
+ * The tab is on a browser error page (a crashed renderer, a navigation the
669
+ * network refused): nothing recorded can hold there, and every later step
670
+ * would resolve nothing while the replay pressed on. fwgr26-n2 ran eleven
671
+ * more steps on chrome-error://chromewebdata/ before the next segment
672
+ * refused with the unreadable "browser is at null/".
673
+ */
674
+ const errorPage = ({ page, tag }) => {
675
+ const url = page.url();
676
+ return /^chrome-error:|^about:neterror/.test(url) ? { stop: `after step ${tag} the browser is on an error page (${url}) — the tab crashed or a navigation failed` } : null;
677
+ };
678
+ const STEP_GATES = [errorPage, expectedUrl, unrecordedAlert, expectedAlert, expectedChanges];
679
+ /** Short human label for a loop's guard locator. */
680
+ function chain0Desc(chain) {
681
+ return chain[0] ? candidateExpr(chain[0]) : 'element';
682
+ }
683
+ /**
684
+ * Structural: a path through the document, or an index into a set of matches.
685
+ *
686
+ * Note this is NOT "kind === css". An agent-chosen `#modal-save` is a handle
687
+ * — it names one control — while `#view > div > button:nth-of-type(2)` is a
688
+ * route to wherever that shape currently sits. Demoting the first alongside
689
+ * the second would push a deliberate selector below a role guess.
690
+ */
691
+ export function structural(c) {
692
+ if (c.nth !== undefined)
693
+ return true;
694
+ if (c.kind === 'point')
695
+ return true; // where it was, not what it is
696
+ if (c.kind !== 'css')
697
+ return false;
698
+ return /[>+~]|:nth-/.test(c.selector);
699
+ }
700
+ /**
701
+ * The expectation lines that could tell a right-element fill from a
702
+ * wrong-element one. A recorded added-line that merely restates the fill in a
703
+ * same-role element — `textbox "": {{v4}}` — is an ECHO: the WRONG textbox
704
+ * produces it too, so it is no evidence at all. fwgr17-n3's 03-open passed
705
+ * its effect gate on exactly that line after a positional fallback took the
706
+ * step. When consequential lines exist (the heading that renders the typed
707
+ * title, the menu button named after it), only those count; when the echo is
708
+ * all the recording has, it is returned unchanged — a lone search-box fill
709
+ * legitimately shows nothing else, and the caller warns instead.
710
+ */
711
+ export function isEchoLine(line, filledValue) {
712
+ const escaped = filledValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
713
+ return new RegExp(`^-?\\s*(textbox|searchbox|spinbutton|combobox)\\b[^:]*:\\s*${escaped}\\s*$`).test(line.trim());
714
+ }
715
+ export function consequentialExpectations(lines, filledValue) {
716
+ if (!filledValue)
717
+ return lines;
718
+ const rest = lines.filter((l) => !isEchoLine(l, filledValue));
719
+ return rest.length ? rest : lines;
720
+ }
721
+ export function specOf(chain) {
722
+ return {
723
+ identity: chain.filter((c) => c.kind === 'scoped'),
724
+ handles: chain.filter((c) => c.kind !== 'scoped' && !structural(c)),
725
+ // Paths, then where it was: a point is the last resort behind every path.
726
+ path: [...chain.filter((c) => c.kind !== 'scoped' && c.kind !== 'point' && structural(c)), ...chain.filter((c) => c.kind === 'point')],
727
+ };
728
+ }
729
+ export async function resolveChain(page, chain, policy = {}) {
730
+ const { rawTarget = '', allowMultiple = false, ambiguousNth, requireIdentity = [], waitMs = 0, stayOnOrigin } = policy;
731
+ /** Does the resolved element sit inside a link that leaves the recorded origin? */
732
+ const leavesOrigin = async (locator) => {
733
+ if (!stayOnOrigin)
734
+ return false;
735
+ try {
736
+ return await locator.first().evaluate((el, origin) => {
737
+ const a = el.closest('a[href]');
738
+ if (!a)
739
+ return false;
740
+ try {
741
+ const target = new URL(a.href, location.href);
742
+ // file:// pages have an opaque origin; a link there is "home" when
743
+ // it stays on the same scheme.
744
+ if (target.origin === 'null' || location.origin === 'null')
745
+ return target.protocol !== location.protocol;
746
+ return target.origin !== origin;
747
+ }
748
+ catch {
749
+ return false;
750
+ }
751
+ }, stayOnOrigin);
752
+ }
753
+ catch {
754
+ return false;
755
+ }
756
+ };
757
+ const candidates = chain.length || !rawTarget || isRefTarget(rawTarget) ? chain : [{ kind: 'css', selector: rawTarget }];
758
+ // Identity, then handles, then paths — each keeping its recorded order, and
759
+ // each carrying its index in the STORED chain so drift still reports which
760
+ // recorded candidate actually took the step.
761
+ const spec = specOf(candidates);
762
+ // Demonstrated volatile last, whatever kind it is. Evidence outranks the
763
+ // identity/handle/path ordering because that ordering is a prior about what
764
+ // a candidate IS, and this is a measurement of whether it WORKS.
765
+ const byEvidence = (list) => [...list].sort((a, b) => Number(retired(a)) - Number(retired(b)));
766
+ const ordered = [...byEvidence(spec.identity), ...byEvidence(spec.handles), ...byEvidence(spec.path)].map((candidate) => ({
767
+ candidate,
768
+ index: candidates.indexOf(candidate),
769
+ }));
770
+ /** Does this fallback still identify the record the primary named? */
771
+ const keepsIdentity = async (index, candidate, locator) => {
772
+ // The recorded primary is trusted — unless it is itself structural (an
773
+ // agent-typed positional selector at the head), which names no record.
774
+ if (!requireIdentity.length || (index === 0 && !structural(candidate)))
775
+ return true;
776
+ const expr = JSON.stringify(candidate);
777
+ const wanted = requireIdentity.filter((v) => !expr.includes(v));
778
+ if (!wanted.length)
779
+ return true;
780
+ let text;
781
+ try {
782
+ text = ((await locator.first().textContent({ timeout: 1_000 })) ?? '').replace(/\s+/g, ' ');
783
+ }
784
+ catch {
785
+ return false;
786
+ }
787
+ return wanted.every((v) => text.toLowerCase().includes(v.toLowerCase()));
788
+ };
789
+ /**
790
+ * One pass over the chain, best candidate first, reporting which candidates
791
+ * it REJECTED before the winner.
792
+ *
793
+ * Per pass, deliberately. A candidate that missed while the page was still
794
+ * painting and hits on the next poll is not volatile — it was early. Only
795
+ * the pass that actually resolved is evidence about the locators, because
796
+ * only then do we know the element was there to be found.
797
+ */
798
+ // The recorded geometry, when the chain carries it: the yardstick a guess
799
+ // is measured against. A structural fallback that resolves far from where
800
+ // the recorded element sat is a different element — rpgr13's `div > … >
801
+ // button` took a header button for a control in the editor's side pane.
802
+ const recordedBox = candidates.find((c) => c.kind === 'point') ?? null;
803
+ const plausible = async (locator) => {
804
+ if (!recordedBox)
805
+ return true;
806
+ try {
807
+ const box = await locator.first().boundingBox();
808
+ if (!box)
809
+ return true; // nothing to measure — let the other guards judge
810
+ const scroll = await page.evaluate(() => ({ x: window.scrollX, y: window.scrollY }));
811
+ const cx = box.x + box.width / 2 + scroll.x;
812
+ const cy = box.y + box.height / 2 + scroll.y;
813
+ const limit = Math.max(recordedBox.vw, recordedBox.vh) / 3;
814
+ return Math.hypot(cx - recordedBox.x, cy - recordedBox.y) <= limit;
815
+ }
816
+ catch {
817
+ return true;
818
+ }
819
+ };
820
+ const walk = async () => {
821
+ const missed = [];
822
+ for (const { index, candidate } of ordered) {
823
+ try {
824
+ // A point names a place; find what is there (of the recorded kind)
825
+ // before a locator can name it.
826
+ if (candidate.kind === 'point' && !(await markPoint(page, candidate))) {
827
+ missed.push(index);
828
+ continue;
829
+ }
830
+ const locator = makeLocator(page, candidate);
831
+ const count = await locator.count();
832
+ if (count === 1) {
833
+ if (!(await keepsIdentity(index, candidate, locator))) {
834
+ missed.push(index);
835
+ continue;
836
+ }
837
+ if ((index > 0 || structural(candidate)) && candidate.kind !== 'point' && !(await plausible(locator))) {
838
+ missed.push(index);
839
+ continue;
840
+ }
841
+ // The recorded primary is trusted even when it is such a link — the
842
+ // recording clicked it (rpgr12-r2's sign-in skill had a stray click
843
+ // on Grafana's "Support" footer link, and refusing it cost a
844
+ // 19-turn recovery). Only a guess may not leave the origin.
845
+ if ((index > 0 || structural(candidate)) && (await leavesOrigin(locator))) {
846
+ missed.push(index);
847
+ continue;
848
+ }
849
+ return { locator, index, candidate, missed };
850
+ }
851
+ if (count > 1) {
852
+ if (allowMultiple)
853
+ return { locator, index, candidate, missed };
854
+ if (ambiguousNth !== undefined && candidate.nth === undefined && ambiguousNth < count) {
855
+ const picked = locator.nth(ambiguousNth);
856
+ if (!(await keepsIdentity(index, candidate, picked))) {
857
+ missed.push(index);
858
+ continue;
859
+ }
860
+ return { locator: picked, index, candidate: { ...candidate, nth: ambiguousNth }, missed };
861
+ }
862
+ if (candidate.nth === undefined && index === 0) {
863
+ missed.push(index); // was unique; ambiguity is drift, keep looking
864
+ continue;
865
+ }
866
+ }
867
+ missed.push(index);
868
+ }
869
+ catch {
870
+ missed.push(index); // malformed selector or detached page — try the next
871
+ }
872
+ }
873
+ return null;
874
+ };
875
+ // Fast path first: on a page that is ready this returns immediately and the
876
+ // wait below never runs. Re-walking the WHOLE chain each poll (rather than
877
+ // waiting on the primary alone) keeps the preference order intact — the
878
+ // best candidate still wins the moment it appears — and the identity guard
879
+ // stops a positional fallback taking the turn while the anchor is pending.
880
+ //
881
+ // A structural (positional) hit is not taken on the spot when the chain
882
+ // also names the element: a path resolves instantly against whatever sits
883
+ // in that slot while the named control is still rendering. rpgr13 lost
884
+ // both replays that way — the panel editor's `toggle-viz-picker` test id
885
+ // was not there yet, `div > … > button` matched a header button that
886
+ // opens grafana.com, and the tab left the app. The guess is held until the
887
+ // names have had the whole window; it stands only when none of them came.
888
+ const named = ordered.some((o) => !structural(o.candidate));
889
+ const guess = (hit) => !!hit && named && structural(hit.candidate);
890
+ let held = null;
891
+ const first = await walk();
892
+ if (first && !guess(first))
893
+ return first;
894
+ held = first;
895
+ for (let waited = 0; waited < waitMs; waited += RESOLVE_POLL_MS) {
896
+ // A plain timer, not page.waitForTimeout: this path runs precisely when
897
+ // the page is unhappy, and a navigating or detached page makes its own
898
+ // clock throw.
899
+ await new Promise((r) => setTimeout(r, RESOLVE_POLL_MS));
900
+ const hit = await walk();
901
+ if (hit && !guess(hit))
902
+ return hit;
903
+ if (hit)
904
+ held = hit;
905
+ }
906
+ return held;
907
+ }
908
+ /**
909
+ * The identity values the primary locator carried: known ({{known}}) slots
910
+ * whose value the recorded run used to NAME the target by its visible text.
911
+ * Only text-bearing locator kinds count — a slot inside a css selector or a
912
+ * testid is an address, not a name, and holding a fallback to it would break
913
+ * ordinary form fills whose fallbacks are structural by design.
914
+ */
915
+ export function identityOfPrimary(chain, skill, params) {
916
+ // The WHOLE chain, not chain[0]. Identity is a property of the STEP — which
917
+ // record it acts on — not of whichever candidate happens to sit first.
918
+ //
919
+ // fwrd26l is why. The agent's raw target was an XPath,
920
+ // `//tr[contains(., '{{v5}}')]`, stored as `css` because the recorder does
921
+ // not parse selector strings. So the primary advertised no identity, the
922
+ // guard was disarmed, and `#ticket-rows > tr:nth-of-type(1)` took the step —
923
+ // while the scoped anchor sitting right behind it named the record perfectly
924
+ // well. Same shape as the `text="..."` case, different syntax; reading the
925
+ // chain instead of its head fixes both without parsing anything.
926
+ const named = chain
927
+ .flatMap((c) => {
928
+ const f = c;
929
+ return [f.name, f.text, f.label, f.hasText];
930
+ })
931
+ .filter((v) => typeof v === 'string');
932
+ if (!named.length)
933
+ return [];
934
+ const out = new Set();
935
+ for (const field of named) {
936
+ for (const m of field.matchAll(/\{\{(v\d+)\}\}/g)) {
937
+ if (!skill.params[m[1]]?.known)
938
+ continue;
939
+ const value = params[m[1]];
940
+ if (value && value.length >= 3)
941
+ out.add(value);
942
+ }
943
+ }
944
+ return [...out];
945
+ }
946
+ /**
947
+ * The rungs a navigation step falls through when its recorded link is gone,
948
+ * each testing how the app works for a HUMAN before the next is tried:
949
+ * (a) another link on the page to the same destination — click that,
950
+ * exercising the app's own navigation;
951
+ * (b) only then, and only when the destination is fully concrete
952
+ * (params/derived filled, nothing volatile left), navigate there
953
+ * directly — the last resort before model recovery.
954
+ * Returns what got the browser there, or null when neither rung did.
955
+ */
956
+ async function navigateToDestination(page, destPattern, params, exec) {
957
+ // (a) Requires the matching anchors to agree on ONE destination —
958
+ // ambiguity (a wildcard pattern matching many records) skips the rung.
959
+ const link = await linkToDestination(page, destPattern, params);
960
+ if (link) {
961
+ try {
962
+ await exec('click', { target: link.selector }, { target: page.locator(link.selector).first() });
963
+ await settleDom(page);
964
+ if (urlMatches(destPattern, page.url(), params)) {
965
+ return { used: `click ${link.selector}`, note: `clicked another link to the recorded destination (${link.selector})` };
966
+ }
967
+ }
968
+ catch {
969
+ // that link did not work either — try the direct navigation
970
+ }
971
+ }
972
+ // (b)
973
+ const dest = fillParams(destPattern, params);
974
+ const concrete = dest && !dest.includes('{{') && !/[/=#](:id|:var)(?=[/&#]|$)/.test(dest);
975
+ if (concrete && !urlMatches(destPattern, page.url(), params)) {
976
+ try {
977
+ await exec('goto', { url: dest }, {});
978
+ if (urlMatches(destPattern, page.url(), params)) {
979
+ return { used: `goto ${dest}`, note: `navigated to the step's recorded destination instead (${dest})` };
980
+ }
981
+ }
982
+ catch {
983
+ // destination unreachable — the caller reports the original miss
984
+ }
985
+ }
986
+ return null;
987
+ }
988
+ /**
989
+ * A visible anchor on the page whose destination matches the recorded
990
+ * pattern. Used by the navigation fallback's first rung: when the recorded
991
+ * link is gone, another route to the same place may exist (a sidebar entry, a
992
+ * search result, a breadcrumb). Returns null unless every matching anchor
993
+ * agrees on ONE destination — a wildcard-heavy pattern matching several
994
+ * records is ambiguity, not evidence.
995
+ */
996
+ async function linkToDestination(page, pattern, params) {
997
+ let anchors;
998
+ try {
999
+ const raw = await page.evaluate(() => Array.from(document.querySelectorAll('a[href]'))
1000
+ .filter((a) => a.offsetParent !== null)
1001
+ .map((a) => ({ attr: a.getAttribute('href') ?? '', abs: a.href })));
1002
+ anchors = Array.isArray(raw) ? raw : [];
1003
+ }
1004
+ catch {
1005
+ return null;
1006
+ }
1007
+ const hits = anchors.filter((a) => a.abs && urlMatches(pattern, a.abs, params));
1008
+ if (!hits.length || new Set(hits.map((h) => h.abs)).size !== 1)
1009
+ return null;
1010
+ return { selector: `a[href="${hits[0].attr.replace(/(["\\])/g, '\\$1')}"]`, href: hits[0].abs };
1011
+ }
1012
+ /** How long a loop iteration waits for its record to leave the guard's match set. */
1013
+ const LOOP_SHRINK_WAIT_MS = 1_000;
1014
+ const LOOP_SHRINK_POLL_MS = 100;
1015
+ /**
1016
+ * How long a step keeps re-trying its locator chain before calling the target
1017
+ * absent. Overridable so a test exercising a FALLBACK path need not sit
1018
+ * through the wait that precedes it.
1019
+ */
1020
+ /** A wait_for whose recorded condition is that its target is NOT there. */
1021
+ export function waitsForAbsence(step, args) {
1022
+ if (step.tool !== 'wait_for')
1023
+ return false;
1024
+ return args.state === 'hidden' || (args.state === 'count' && Number(args.count) === 0);
1025
+ }
1026
+ function resolveWaitMs() {
1027
+ const raw = Number(process.env.SITELOOPER_RESOLVE_WAIT_MS);
1028
+ return Number.isFinite(raw) && raw >= 0 ? raw : 3_000;
1029
+ }
1030
+ const RESOLVE_POLL_MS = 100;
1031
+ /**
1032
+ * Scroll the page end to end so a virtualised or lazily rendered UI paints
1033
+ * everything it has, then let the DOM settle. Returns false when the page
1034
+ * cannot be scripted (gone, cross-origin frame), in which case the caller
1035
+ * simply does not retry.
1036
+ */
1037
+ async function sweepPage(page) {
1038
+ try {
1039
+ await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
1040
+ await settleDom(page);
1041
+ await page.evaluate(() => window.scrollTo(0, 0));
1042
+ await settleDom(page);
1043
+ return true;
1044
+ }
1045
+ catch {
1046
+ return false;
1047
+ }
1048
+ }
1049
+ const SETTLE_QUIET_MS = 250;
1050
+ const SETTLE_MAX_MS = 2_000;
1051
+ /**
1052
+ * How long a page gets to show it is busy before it is called quiet. The
1053
+ * quiet window used to be the floor too — 250ms per step even on a static
1054
+ * page, ~20s across an 80-step replay that was otherwise at the engine's
1055
+ * floor. Now the full quiet window is demanded only once a mutation shows.
1056
+ */
1057
+ const SETTLE_PROBE_MS = 60;
1058
+ /** Resolve once no DOM mutation has happened for SETTLE_QUIET_MS, or after SETTLE_MAX_MS. */
1059
+ async function settleDom(page) {
1060
+ try {
1061
+ await page.evaluate(({ probe, quiet, max }) => new Promise((resolve) => {
1062
+ let timer = setTimeout(resolve, probe);
1063
+ const stop = setTimeout(() => {
1064
+ observer.disconnect();
1065
+ resolve();
1066
+ }, max);
1067
+ const observer = new MutationObserver(() => {
1068
+ clearTimeout(timer);
1069
+ timer = setTimeout(() => {
1070
+ observer.disconnect();
1071
+ clearTimeout(stop);
1072
+ resolve();
1073
+ }, quiet);
1074
+ });
1075
+ observer.observe(document, { childList: true, subtree: true, attributes: true, characterData: true });
1076
+ }), { probe: SETTLE_PROBE_MS, quiet: SETTLE_QUIET_MS, max: SETTLE_MAX_MS });
1077
+ }
1078
+ catch {
1079
+ // navigating / detached — the locator resolution will report it
1080
+ }
1081
+ }
1082
+ /** A snapshot line that names a popup: the thing a toggle opens and closes. */
1083
+ export const OPENER_LINE = /^-?\s*(dialog|alertdialog|menu|menubar|listbox|tooltip)\b/;
1084
+ /**
1085
+ * The popup lines a click was recorded to open, with params filled — empty
1086
+ * for anything but a click whose plain (unparameterised) effects include a
1087
+ * popup. See runOneStep's already-in-effect skip.
1088
+ */
1089
+ export function openerLines(step, params) {
1090
+ if (step.tool !== 'click' || !step.expect?.addedContains?.length)
1091
+ return [];
1092
+ const plain = step.expect.addedContains.filter((l) => !TRANSIENT_LINE.test(l) && !/\{\{v\d+\}\}/.test(l));
1093
+ // Only the POPUP lines decide. A click recorded to open a dialog also
1094
+ // records whatever else changed around it — the row it was about to fill,
1095
+ // the combobox it typed into — and those are on the page BEFORE the click
1096
+ // too. fwod34's 03-open picked "Conference Chair" from a product
1097
+ // autocomplete: the recorded effect listed `row "£ 0.00"` (the empty line
1098
+ // added one step earlier) beside `dialog ""`/`heading "Configure your
1099
+ // product"`, lineShows is any-of, so the option click was skipped as
1100
+ // "already in effect" on every replay and the dialog's Confirm at the next
1101
+ // step had nothing to click.
1102
+ const popup = plain.filter((l) => OPENER_LINE.test(l));
1103
+ if (!popup.length)
1104
+ return [];
1105
+ return popup.map((l) => fillParams(maskMinted(maskVolatile(l)), params));
1106
+ }
1107
+ /**
1108
+ * Is this step's work already done on the record it names?
1109
+ *
1110
+ * Two halves, and both are load-bearing. `preconditions.requireText` says the
1111
+ * page is showing THIS record (the url pattern and fingerprint only ever say
1112
+ * "a page of this template"); `goal.requireText` says that record is in the
1113
+ * state the procedure exists to produce. Identity without goal would skip a
1114
+ * step because the right record is open; goal without identity would skip it
1115
+ * because some OTHER record happens to read "Cancelled".
1116
+ *
1117
+ * Conservative by construction: a skill with no goal, or no identity, is never
1118
+ * satisfied, and an unbound marker (one that still reads `{{v1}}`) proves
1119
+ * nothing so it fails the check rather than passing it. Being wrong the other
1120
+ * way costs one replay; being wrong this way skips work that never happened.
1121
+ */
1122
+ export async function goalSatisfied(page, skill, params) {
1123
+ const fill = (markers) => (markers ?? []).map((m) => fillParams(m, params));
1124
+ const identity = fill(skill.preconditions.requireText);
1125
+ const goal = fill(skill.goal?.requireText);
1126
+ if (!identity.length || !goal.length)
1127
+ return { satisfied: false, shown: [] };
1128
+ if ([...identity, ...goal].some((t) => !t || /\{\{/.test(t)))
1129
+ return { satisfied: false, shown: [] };
1130
+ // The goal was read on the page template the procedure ends on (single
1131
+ // segment, so also where it starts): on any other template the same words
1132
+ // mean nothing — a list row can show "Cancelled" for a different order.
1133
+ if (!urlMatches(skill.preconditions.urlPattern, page.url(), params))
1134
+ return { satisfied: false, shown: [] };
1135
+ const sig = await captureSignature(page);
1136
+ if (!sig)
1137
+ return { satisfied: false, shown: [] };
1138
+ for (const want of [...identity, ...goal]) {
1139
+ if (!lineShows(sig.lines, [want]))
1140
+ return { satisfied: false, shown: [] };
1141
+ }
1142
+ return { satisfied: true, shown: goal };
1143
+ }
1144
+ /**
1145
+ * A parameterised line that did not *appear* may still be *there*: filling a
1146
+ * field with the value it already held produces no diff. One extra capture on
1147
+ * the miss path settles it.
1148
+ */
1149
+ async function presentOnPage(page, lines) {
1150
+ const sig = await captureSignature(page);
1151
+ if (!sig)
1152
+ return false;
1153
+ return lineShows(sig.lines, lines);
1154
+ }
1155
+ const normWs = (s) => s.replace(/\s+/g, ' ').trim();
1156
+ /**
1157
+ * Does any of `wants` appear in `haystack` (recorded page lines, or a diff's
1158
+ * added lines)? Whitespace-insensitive on both sides — a marker copied with
1159
+ * a trailing space is the same word — and a `{{*}}` wildcard (see
1160
+ * maskVolatile) matches anything within one line.
1161
+ */
1162
+ export function lineShows(haystack, wants) {
1163
+ const all = haystack.map(normWs).join('\n');
1164
+ return wants.some((raw) => {
1165
+ const want = normWs(raw);
1166
+ if (!want)
1167
+ return false;
1168
+ if (!want.includes(WILDCARD))
1169
+ return all.includes(want);
1170
+ const re = new RegExp(want
1171
+ .split(WILDCARD)
1172
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
1173
+ .join('[^\\n]*?'));
1174
+ return re.test(all);
1175
+ });
1176
+ }
1177
+ function parseRead(result) {
1178
+ try {
1179
+ const v = JSON.parse(result);
1180
+ return Array.isArray(v) ? v.map(String).join(' | ') : String(v);
1181
+ }
1182
+ catch {
1183
+ return result;
1184
+ }
1185
+ }
1186
+ function describeArgs(tool, args) {
1187
+ const parts = [];
1188
+ for (const [k, v] of Object.entries(args)) {
1189
+ if (k === 'target' || k === 'source')
1190
+ continue;
1191
+ if (typeof v === 'string')
1192
+ parts.push(`${k}=${JSON.stringify(clip(v, 60))}`);
1193
+ else if (typeof v === 'number' || typeof v === 'boolean')
1194
+ parts.push(`${k}=${v}`);
1195
+ }
1196
+ void tool;
1197
+ return parts.join(' ');
1198
+ }
1199
+ /** Text rendering of a replay result for the agent's tool output. */
1200
+ export function renderReplay(skill, res) {
1201
+ const lines = [];
1202
+ if (res.refused)
1203
+ return `ERROR: could not replay ${skill.id}: ${res.reason}`;
1204
+ lines.push(res.ok
1205
+ ? `replayed ${skill.id}: ${res.stepsRun}/${res.stepsTotal} steps ok`
1206
+ : `replayed ${skill.id}: ${res.stepsRun}/${res.stepsTotal} steps ok, FAILED at step ${res.failedAt}`);
1207
+ lines.push(...res.lines.map((l) => ' ' + l));
1208
+ if (!res.ok && res.failedAt !== undefined && res.failedAt < res.stepsTotal) {
1209
+ lines.push(` not run: steps ${res.failedAt + 1}-${res.stepsTotal}`);
1210
+ }
1211
+ if (!res.ok) {
1212
+ lines.push(`Steps 1-${res.stepsRun} HAVE run and changed the page — do not repeat them. Observe the current page and continue from here to finish the instruction yourself.`);
1213
+ // Naming the steps is not enough when the steps CREATED something. fwod13
1214
+ // replayed 02-create part-way, stopped, and recovery created a second
1215
+ // order: run n2 finished with 2 orders for its customer and n3 with 3,
1216
+ // which is why every later objective scored "no single order to check".
1217
+ // The model was told which steps ran; it was not told that a record it is
1218
+ // about to create may already exist.
1219
+ if (res.created.length) {
1220
+ // Evidence, not persuasion: these were read off the live url as the
1221
+ // minting steps ran, so they are THIS run's records, not the
1222
+ // recording's.
1223
+ lines.push(`ALREADY CREATED by those steps: ${res.created.map((c) => JSON.stringify(c)).join(', ')}. ` +
1224
+ `Continue with ${res.created.length === 1 ? 'it' : 'them'} — creating another is a silent duplicate, not a recovery.`);
1225
+ }
1226
+ else if (res.stepsRun > 0) {
1227
+ lines.push(`If this instruction CREATES a record, one may already exist from those steps — search for it first and continue with it. Creating a second one is a silent duplicate, not a recovery.`);
1228
+ }
1229
+ }
1230
+ const values = Object.entries(res.values);
1231
+ if (values.length)
1232
+ lines.push(`values read from the live page: ${values.map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(', ')}`);
1233
+ if (res.warnings.length)
1234
+ lines.push(`notes: ${res.warnings.join('; ')}`);
1235
+ return lines.join('\n');
1236
+ }
1237
+ /** Which stored skills could apply on this page, best first. */
1238
+ export function candidatesFor(skills, url, limit = 5) {
1239
+ return skills
1240
+ .filter((s) => s.status !== 'demoted' && !(s.seq && s.seq.index > 0) && urlMatches(s.preconditions.urlPattern, url))
1241
+ .sort((a, b) => {
1242
+ const rank = (s) => (s.status === 'validated' ? 1 : 0);
1243
+ const rate = (s) => (s.stats.uses ? s.stats.successes / s.stats.uses : 0);
1244
+ return rank(b) - rank(a) || rate(b) - rate(a) || (b.stats.lastUsed ?? '').localeCompare(a.stats.lastUsed ?? '');
1245
+ })
1246
+ .slice(0, limit);
1247
+ }
1248
+ /** Values a skill will type verbatim because they were not parameterised. */
1249
+ export function literalInputs(s) {
1250
+ const out = [];
1251
+ for (const st of s.steps) {
1252
+ for (const key of ['value', 'text', 'option']) {
1253
+ const v = st.args[key];
1254
+ if (typeof v === 'string' && v.trim() && !/\{\{v\d+\}\}/.test(v) && !out.includes(JSON.stringify(clip(v, 40)))) {
1255
+ out.push(JSON.stringify(clip(v, 40)));
1256
+ }
1257
+ }
1258
+ }
1259
+ return out.slice(0, 6);
1260
+ }
1261
+ /** The `[skills]` block appended to an instruction's user message. */
1262
+ export function renderCandidates(skills) {
1263
+ if (!skills.length)
1264
+ return '';
1265
+ const lines = ['[skills] stored procedures that have worked on this page before — if one matches the instruction, call run_skill with it FIRST instead of rediscovering the steps:'];
1266
+ for (const s of skills) {
1267
+ const params = Object.entries(s.params)
1268
+ .map(([k, p]) => `${k} e.g. ${JSON.stringify(clip(p.example, 40))}`)
1269
+ .join(', ');
1270
+ const reads = s.steps.filter((st) => st.label).map((st) => st.label);
1271
+ const status = s.status === 'validated' ? `validated ${s.stats.successes}/${s.stats.uses}` : `unverified, ${s.stats.successes}/${s.stats.uses} run(s)`;
1272
+ lines.push(` ${s.id} ${JSON.stringify(s.template)}`);
1273
+ lines.push(` ${s.steps.length} steps · ${status}${params ? ` · params: ${params}` : ' · no params'}${reads.length ? ` · reads back: ${reads.join(', ')}` : ''}`);
1274
+ const literals = literalInputs(s);
1275
+ if (literals.length) {
1276
+ lines.push(` types these FIXED values (not parameters — do not use this procedure if the instruction wants different ones): ${literals.join(', ')}`);
1277
+ }
1278
+ }
1279
+ return lines.join('\n');
1280
+ }
1281
+ //# sourceMappingURL=replay.js.map