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,1420 @@
1
+ import { newSkillId, originOf } from './store.js';
2
+ import { identifierLike } from './ledger.js';
3
+ import { WILDCARD, maskVolatile } from '../shared/text.js';
4
+ /** Args whose string values are candidates for parameter slots. */
5
+ const VALUE_ARGS = new Set(['value', 'text', 'option', 'url', 'prompt_text']);
6
+ const MAX_ADDED_LINES = 5;
7
+ const MAX_SLOT_VALUES = 12;
8
+ /**
9
+ * Turn one successful instruction's recording into a skill.
10
+ *
11
+ * Parameterisation is deliberately deterministic: any literal the agent typed
12
+ * (fill/type/select/goto values) that also occurs as a whole token in the
13
+ * instruction text becomes a slot, substituted everywhere it appears — step
14
+ * args, locator names, expectations, the report. Values that do not occur in
15
+ * the instruction stay literal: they are defaults the agent invented, which is
16
+ * the right thing to replay and worth being able to see in `skills show`.
17
+ *
18
+ * Returns null when there is nothing replayable (no steps, or no origin).
19
+ */
20
+ export function compileSkill(input) {
21
+ return compileSkills(input)[0] ?? null;
22
+ }
23
+ /**
24
+ * Like compileSkill, but the recording is first split at page-template seams
25
+ * — a step whose url PATTERN changed navigated to a different template — and
26
+ * each segment compiles into its own skill, scoped to the page it runs on
27
+ * (its own startUrl precondition and, when the recorder captured one, its own
28
+ * fingerprint). The segments share one template and one slot set and are
29
+ * linked by `seq`, so a caller binds params once and replay composes them;
30
+ * each segment is independently replayable, recoverable, and promotable, so a
31
+ * drift in one cannot cascade — the next segment refuses unless the page
32
+ * matches its template. A recording that never changes template compiles to
33
+ * exactly one skill, as before.
34
+ */
35
+ export function compileSkills(input) {
36
+ const head = input.entries.find((e) => e.k === 'instruction');
37
+ const steps = input.entries.filter((e) => e.k === 'step');
38
+ if (!steps.length)
39
+ return [];
40
+ const startUrl = head?.url ?? firstUrl(steps);
41
+ const origin = startUrl ? originOf(startUrl) : null;
42
+ if (!origin || !startUrl)
43
+ return [];
44
+ const slots = discoverSlots(input.instruction, steps, input.knownValues);
45
+ const sub = (s) => substitute(s, slots);
46
+ /** Slots whose value the ledger banked from a `q.id` url position — the only
47
+ * slots substituteUrlId may write into a navigation arg's `id=`. */
48
+ const idOriginValues = new Set(Object.entries(input.knownValues ?? {})
49
+ .filter(([k]) => /:q\.id$/.test(k))
50
+ .map(([, v]) => String(v ?? '').trim()));
51
+ const urlIdSlots = new Map([...slots].filter(([, v]) => idOriginValues.has(v)));
52
+ /** The caller's values for THIS run — a runid, a record it vouched for. */
53
+ const runValues = Object.values(input.knownValues ?? {})
54
+ .map((v) => String(v ?? '').trim())
55
+ .filter((v) => v.length >= 3);
56
+ const reportValues = input.report.evidence?.values ?? {};
57
+ // Inspection-only actions the agent used to ORIENT itself — probe the DOM with
58
+ // `eval`, grab a `screenshot`, read a value it never reported — are not part of
59
+ // the reproducible procedure. An `eval` is worse than noise: it assumes the
60
+ // record-time DOM and, unlike a read (which replay skips on failure), it is
61
+ // fatal, which is exactly what sent the sign-in and archive steps to recovery
62
+ // on every replay. Keep the actions, the synthetic read-backs, and any read
63
+ // whose value the run actually reported; drop the rest.
64
+ const replayable = steps.filter((step) => {
65
+ if (step.tool === 'screenshot' || step.tool === 'eval')
66
+ return false;
67
+ if (step.tool === 'read' || step.tool === 'read_all') {
68
+ return step.args.target === '(read-back)' || Boolean(readLabel(step, reportValues));
69
+ }
70
+ return true;
71
+ });
72
+ let kept = replayable.length ? replayable : steps;
73
+ // A variant covers only the territory it repaired: steps that were replayed
74
+ // via a DIFFERENT stored skill (an earlier segment completing cleanly) are
75
+ // that skill's procedure, not this variant's.
76
+ if (input.variantOf)
77
+ kept = kept.filter((s) => !s.via || s.via.skill === input.variantOf);
78
+ if (!kept.length)
79
+ return [];
80
+ // Split at page-template seams. A step that navigated (diff.url) to a url
81
+ // with a DIFFERENT pattern ends its segment; the recorder's fingerprintAfter
82
+ // (when captured) becomes the next segment's precondition.
83
+ const segments = [];
84
+ let seg = {
85
+ steps: [],
86
+ startUrl,
87
+ ...(head?.fingerprint ? { fingerprint: head.fingerprint } : {}),
88
+ ...(head?.startText ? { startText: head.startText } : {}),
89
+ };
90
+ let currentUrl = startUrl;
91
+ for (const step of kept) {
92
+ seg.steps.push(step);
93
+ if (step.diff?.url && step.diff.url !== currentUrl) {
94
+ const crossed = urlPattern(step.diff.url, slots) !== urlPattern(currentUrl, slots);
95
+ currentUrl = step.diff.url;
96
+ if (crossed) {
97
+ segments.push(seg);
98
+ seg = {
99
+ steps: [],
100
+ startUrl: currentUrl,
101
+ ...(step.fingerprintAfter ? { fingerprint: step.fingerprintAfter } : {}),
102
+ ...(step.diff?.added?.length ? { startText: step.diff.added.join('\n') } : {}),
103
+ };
104
+ }
105
+ }
106
+ }
107
+ if (seg.steps.length)
108
+ segments.push(seg);
109
+ // Provenance slots: values this run minted (first surfaced in a step's
110
+ // post-nav url). Kept only where they can pay: a later step or a later
111
+ // segment's start url mentions the value — otherwise the marker would just
112
+ // blunt the minting step's own expectation for nothing.
113
+ const mintedAll = discoverMinted(kept, startUrl, slots);
114
+ const minted = mintedAll.filter((m) => JSON.stringify(kept.slice(m.keptIndex + 1).map((s) => [s.args, s.locators, s.diff ?? null])).includes(m.value) ||
115
+ segments.some((sg, si) => si > 0 && urlParts(sg.startUrl).some((p) => p.value === m.value)));
116
+ const mintedMap = (pred) => new Map(minted.filter(pred).map((m) => [m.name, m.value]));
117
+ // Build every segment's steps first: slot retention is decided across the
118
+ // WHOLE chain (a slot used only by segment 2 must stay in the shared
119
+ // template, or binding an instruction to segment 1 would fail).
120
+ let segOffset = 0;
121
+ const built = segments.map((sg) => {
122
+ const base = segOffset;
123
+ segOffset += sg.steps.length;
124
+ const segParams = {};
125
+ for (const [name, value] of slots)
126
+ segParams[name] = { example: value, usedIn: [] };
127
+ const skillSteps = sg.steps.map((step, i) => {
128
+ const g = base + i;
129
+ // A minted value is a reference only DOWNSTREAM of its mint: in this
130
+ // step's args/locators when minted strictly earlier, and in this step's
131
+ // expectation when minted here or earlier (the minting step's own
132
+ // post-nav url is the first downstream occurrence).
133
+ const mintedBefore = mintedMap((m) => m.keptIndex < g);
134
+ const mintedHere = mintedMap((m) => m.keptIndex <= g);
135
+ const args = substituteDeep(substituteDeep(step.args, slots), mintedBefore);
136
+ // substitute() deliberately refuses to rewrite a number after `=` (the
137
+ // nth=25 guard), which is exactly where a navigation url carries its
138
+ // record id. Rewrite `id=<value>` structurally, and only for slots whose
139
+ // value the ledger banked from a q.id url position — a cost of "21"
140
+ // coinciding with a record id must not bind the url to the cost.
141
+ if (typeof args.url === 'string' && urlIdSlots.size)
142
+ args.url = substituteUrlId(args.url, urlIdSlots);
143
+ // The same `=` guard hides a MINTED value in a navigation url: fwod32's
144
+ // sign-in recorded `goto #action=135&menu_id=120` right after the step
145
+ // that minted action=135 and menu_id=120, so every replay navigated to
146
+ // the RECORDING run's action id. A minted value is rewritten at the
147
+ // url position it was minted from, and nowhere else.
148
+ if (typeof args.url === 'string')
149
+ args.url = substituteUrlParts(args.url, minted.filter((m) => m.keptIndex < g));
150
+ const locators = {};
151
+ for (const [key, loc] of Object.entries(step.locators)) {
152
+ const filled = (loc.chain ?? []).map((c) => substituteDeep(substituteDeep(c, slots), mintedBefore));
153
+ // An identity anchor still carrying THIS RUN's known value after
154
+ // slotting (the recorded runid, because the value was typed in an
155
+ // earlier instruction and so is not a slot here) can never match
156
+ // again — and worse, with no {{marker}} it carries no identity, so
157
+ // replay stops holding its fallbacks to the record and follows a
158
+ // positional one onto whatever sorted into that row (fwrd12l 04-add,
159
+ // 06-set). An anchor that cannot parameterise is not an anchor.
160
+ // Never strand the whole chain: a step with no way at all to find its
161
+ // element is worse than one carrying a candidate that will miss.
162
+ // `stranded` DELETES: the ledger knows the run made that value, so the
163
+ // candidate demonstrably cannot match another run. That is provenance,
164
+ // not a guess.
165
+ //
166
+ // `bookmarked` only DEMOTES. It reads an id's shape, and shape is a
167
+ // weak signal: grafana's ephemeral `_r8b_` matches none of our
168
+ // id patterns while odoo's stable `o_form_view` hooks trip several. A
169
+ // wrong deletion costs a working locator permanently; a wrong demotion
170
+ // costs one failed count(), and two replays of evidence put it right
171
+ // either way. So the shape rule sets the starting order and the
172
+ // running tally decides — see recordCandidateEvidence.
173
+ const usable = filled.filter((c) => !stranded(c, runValues));
174
+ const ranked = [...usable].sort((a, b) => Number(bookmarked(a)) - Number(bookmarked(b)));
175
+ const kept = stableFirst(ranked.length ? ranked : filled);
176
+ // A READ that lost its anchor and can now only be found BY POSITION
177
+ // must not publish. fwrd16-n3 is the cost of the alternative: the
178
+ // read fell back to `tbody > tr:nth-of-type(1) > td`, resolved
179
+ // instantly on a list whose first row was a seed ticket, and the step
180
+ // published `ref: RD-1014` at tier A with zero turns — a confidently
181
+ // wrong identity, which every later step then carried. Emptying the
182
+ // chain makes replay SKIP the read (reads are observations; a missing
183
+ // one is recoverable), so the value comes back absent, not wrong.
184
+ const lostAnchor = filled.length !== kept.length && filled.some((c) => c.kind === 'scoped');
185
+ const isRead = step.tool === 'read' || step.tool === 'read_all';
186
+ locators[key] = isRead && lostAnchor && kept.every(positional) ? [] : kept;
187
+ }
188
+ const out = { tool: step.tool, args, locators };
189
+ // Record-minting, from the evidence discoverMinted already gathered:
190
+ // this step's post-nav url carried an identifier the run had not seen
191
+ // before. Stored per step because a replay that stops needs to know
192
+ // whether it is past the point of creation, not merely how far it got.
193
+ const mintedHereOnly = mintedAll.find((m) => m.keptIndex === g);
194
+ if (mintedHereOnly)
195
+ out.mints = { at: mintedHereOnly.at };
196
+ // Minted values go into the url-pattern reduction as slots: an id-like
197
+ // one (odoo's "44", repair-desk's "t15") is otherwise reduced to `:id`
198
+ // before the {{dN}} marker can land, and the minting step then carries
199
+ // no reference to what it minted — so `derived` could not find it.
200
+ const expect = expectationFor(step, new Map([...slots, ...mintedHere]));
201
+ if (expect)
202
+ out.expect = substituteDeep(expect, mintedHere);
203
+ const label = readLabel(step, reportValues);
204
+ // A read with no way to find its element again publishes nothing, so it
205
+ // must not advertise the value either — publishedOutputs reads `label`,
206
+ // and a promised output that never arrives sends later steps to
207
+ // recovery with the reference blank.
208
+ const targetless = step.tool === 'read' && step.args.what === 'url';
209
+ if (label && (targetless || Object.values(locators).some((chain) => chain.length)))
210
+ out.label = label;
211
+ if (step.via)
212
+ out.via = step.via;
213
+ // Args and locators are use; expectations are NOT. Counting them (f24bdf9)
214
+ // kept a slot the procedure never types — a price the recording saw in
215
+ // a row, a uid in a post-save url — as a param bound by ORIGIN, and
216
+ // bindSkill then refused the whole skill whenever that origin had not
217
+ // been published (fwgr23 05-open, fwkb3-n3 03-create: tier null, 14-44
218
+ // model turns). The orphan-marker hazard it was fixing is handled below
219
+ // by re-inlining every dropped slot into the steps, expectations included.
220
+ for (const name of slotsUsed(JSON.stringify({ args, locators })))
221
+ segParams[name]?.usedIn.push(i + 1);
222
+ return out;
223
+ });
224
+ const mintedForStart = mintedMap((m) => m.keptIndex < base);
225
+ return { sg, segParams, mintedForStart, folded: foldLoops(coalesceControls(dropDismissedDialogs(dropSupersededNavigation(skillSteps)))) };
226
+ });
227
+ // Derived-param metadata lands on the MINTING segment: which post-fold step
228
+ // to bind from, and which url part to read there. Replay binds the value
229
+ // from the live run's own url right after that step executes.
230
+ const segDerived = {};
231
+ for (const m of minted) {
232
+ const si = segments.findIndex((sg, k) => {
233
+ const start = segments.slice(0, k).reduce((a, s) => a + s.steps.length, 0);
234
+ return m.keptIndex >= start && m.keptIndex < start + sg.steps.length;
235
+ });
236
+ if (si < 0)
237
+ continue;
238
+ const marker = `{{${m.name}}}`;
239
+ const stepIdx = built[si].folded.findIndex((st) => JSON.stringify(st).includes(marker));
240
+ if (stepIdx < 0)
241
+ continue;
242
+ (segDerived[si] ??= {})[m.name] = { step: stepIdx + 1, at: m.at, example: m.value };
243
+ }
244
+ // Drop slots no segment uses: instruction-only words (e.g. an id the
245
+ // orchestrator mentioned for context) would only make matching harder.
246
+ // EXCEPT known run values: a runid or threaded ref that appears only in the
247
+ // wording still changes every run, so leaving it literal would make the
248
+ // template single-run — bindSkill could never match run n+1's instruction.
249
+ const knownVals = new Set(Object.values(input.knownValues ?? {}).map((v) => String(v ?? '').trim()));
250
+ const usedNames = new Set();
251
+ for (const b of built)
252
+ for (const [name, p] of Object.entries(b.segParams))
253
+ if (p.usedIn.length)
254
+ usedNames.add(name);
255
+ // A known value can be wholly swallowed by a longer slot (the bare runid
256
+ // inside the ticket-title slot): its marker then appears nowhere, and a
257
+ // param that can never bind makes bindSkill refuse the skill's own source
258
+ // instruction. Keep a known-value slot only when its marker survives.
259
+ const tentative = sub(input.instruction);
260
+ const keptSlots = new Map([...slots].filter(([n, v]) => usedNames.has(n) || (knownVals.has(v) && tentative.includes(`{{${n}}}`))));
261
+ const finalTemplate = keptSlots.size === slots.size ? sub(input.instruction) : substitute(input.instruction, keptSlots);
262
+ // The mirror hazard: a slot whose marker survives only in STEPS (its every
263
+ // instruction occurrence was swallowed by a longer slot, or the value came
264
+ // from an EARLIER instruction and this one never names it) can never bind
265
+ // from the template — bindSkill derives values from the template alone,
266
+ // then requires every param to have one.
267
+ //
268
+ // If the run banked the value, the param binds to its ORIGIN instead: a
269
+ // later run resolves its own from the same place, and the marker stays. Only
270
+ // when the origin is unknown do we fall back to re-inlining the recorded
271
+ // literal, which is the compromise that put the recording run's runid inside
272
+ // an anchor's hasText and cost every replay a positional fallback
273
+ // (fwrd19l 04-edit, on both replays, deterministically).
274
+ const originOfValue = new Map(Object.entries(input.knownValues ?? {}).map(([key, v]) => [String(v ?? '').trim(), key]));
275
+ const bindings = new Map();
276
+ const inTemplate = new Set(Array.from(finalTemplate.matchAll(/\{\{(v\d+)\}\}/g), (m) => m[1]));
277
+ for (const [name, value] of [...keptSlots]) {
278
+ // Every slot whose origin is known records it — the ones the template
279
+ // can supply too. A re-pin onto this skill from another run rebinds its
280
+ // slots by origin (remapParams); without the origin it could only guess
281
+ // by value, and rpat2 guessed an earlier run's literal into a live run.
282
+ const origin = originOfValue.get(value);
283
+ if (origin)
284
+ bindings.set(name, origin);
285
+ if (inTemplate.has(name) || origin)
286
+ continue;
287
+ keptSlots.delete(name);
288
+ }
289
+ // Every slot that did not survive is re-inlined as its recorded literal —
290
+ // in args, locators AND expectations. A dropped slot whose marker lingered
291
+ // only in addedContains was an orphan {{vN}} that replay treated as a HARD,
292
+ // unfillable line.
293
+ for (const [name, value] of slots) {
294
+ if (keptSlots.has(name))
295
+ continue;
296
+ for (const b of built)
297
+ b.folded = fillParamsDeep(b.folded, { [name]: value });
298
+ }
299
+ const now = input.now ?? new Date().toISOString();
300
+ const reportTemplate = {
301
+ summary: sub(input.report.summary),
302
+ values: Object.fromEntries(Object.entries(reportValues).map(([k, v]) => [k, sub(String(v))])),
303
+ };
304
+ // What the page shows once this procedure's work is done. Derived from the
305
+ // recording's own before/after pair: report text that was NOT on the page
306
+ // when the instruction began. See deriveGoal.
307
+ const goal = deriveGoal({
308
+ startText: head?.startText,
309
+ reportValues,
310
+ sub,
311
+ // Single-segment only: startText is the page the instruction BEGAN on,
312
+ // and only when no page-template seam was crossed is that the same page
313
+ // the report read its values from. A confirm that started on the list
314
+ // would otherwise take "Sales Order" — listed in Odoo's status bar before
315
+ // the confirm as well — for its goal, and skip the step on a form.
316
+ mutating: built.length === 1 && built.some((b) => mutatesSteps(b.folded)),
317
+ // Identities, not states: anything the caller vouched for, anything the
318
+ // run minted, and every slot's recorded value. "S00021" appearing in the
319
+ // report is the record's NAME — it was equally true before the work.
320
+ identities: new Set([...knownVals, ...minted.map((m) => m.value), ...slots.values()].map((v) => String(v ?? '').replace(/\s+/g, ' ').trim()).filter(Boolean)),
321
+ });
322
+ const of = built.length;
323
+ const chain = of > 1 ? newSkillId(origin, finalTemplate, now) : null;
324
+ return built.map((b, k) => {
325
+ const params = {};
326
+ for (const name of keptSlots.keys()) {
327
+ const value = keptSlots.get(name) ?? '';
328
+ params[name] = {
329
+ ...b.segParams[name],
330
+ ...(derivesFromKnown(value, knownVals) ? { known: true } : {}),
331
+ ...(bindings.has(name) ? { binding: bindings.get(name) } : {}),
332
+ };
333
+ }
334
+ return {
335
+ id: newSkillId(origin, of > 1 ? `${finalTemplate}#${k}` : finalTemplate, now),
336
+ origin,
337
+ template: finalTemplate,
338
+ params,
339
+ preconditions: {
340
+ // keptSlots, not slots: a dropped slot has no param to bind, and its
341
+ // marker in the pattern would read as a wildcard segment.
342
+ urlPattern: urlPattern(b.sg.startUrl, new Map([...keptSlots, ...b.mintedForStart])),
343
+ ...(b.sg.fingerprint ? { fingerprint: b.sg.fingerprint } : {}),
344
+ ...(identityOf(b.sg.startText, keptSlots, knownVals).length
345
+ ? { requireText: identityOf(b.sg.startText, keptSlots, knownVals) }
346
+ : {}),
347
+ },
348
+ // Only the LAST segment finishes the work, so only it can vouch for the
349
+ // end state — an earlier segment carrying the goal would let a chain be
350
+ // skipped from its head on evidence its tail produced.
351
+ ...(k === of - 1 && goal ? { goal } : {}),
352
+ steps: b.folded,
353
+ ...(segDerived[k] ? { derived: segDerived[k] } : {}),
354
+ // Only the last segment can vouch for the instruction's end state.
355
+ ...(k === of - 1 ? { reportTemplate } : {}),
356
+ stats: { uses: 1, successes: 1, partial: 0, created: now, failedAtStep: {}, fallthroughs: 0 },
357
+ status: 'provisional',
358
+ ...(chain ? { seq: { chain, index: k, of } } : {}),
359
+ ...(input.variantOf ? { variantOf: input.variantOf } : {}),
360
+ provenance: { session: input.session, instruction: input.instruction, ...(input.model ? { model: input.model } : {}), created: now },
361
+ };
362
+ });
363
+ }
364
+ const MIN_GOAL_LEN = 3;
365
+ const MAX_GOAL = 4;
366
+ /** Does this procedure CHANGE anything? (learn.ts's `mutates`, at compile time.) */
367
+ const MUTATING_TOOLS = new Set(['click', 'dblclick', 'right_click', 'modifier_click', 'fill', 'type', 'press', 'select', 'check', 'drag', 'upload']);
368
+ function mutatesSteps(steps) {
369
+ return steps.some((s) => MUTATING_TOOLS.has(s.tool) || (s.body ? mutatesSteps(s.body) : false));
370
+ }
371
+ /**
372
+ * The GOAL: what the page shows once this procedure's work is done.
373
+ *
374
+ * The evidence is the recording's own before/after pair — `startText` (what
375
+ * the page showed when the instruction began) against the report's read-back
376
+ * values (what it showed when the work was finished). A report line that was
377
+ * NOT in `startText` is text the procedure BROUGHT INTO EXISTENCE, which is
378
+ * exactly the signal "already done" needs: Odoo's status bar lists every
379
+ * reachable state, so "Sales Order" is showing whether or not the order was
380
+ * cancelled, while "Cancelled" is listed only once it was.
381
+ *
382
+ * Everything here is a filter against FALSE POSITIVES, because a false
383
+ * positive skips work that never happened while a false negative merely runs
384
+ * the step as before:
385
+ * - text already in `startText` is not evidence of anything (it was true
386
+ * before);
387
+ * - an identity — a caller-vouched value, a minted id, a slot's recorded
388
+ * literal — names the RECORD, not its state, and is equally true before
389
+ * and after;
390
+ * - a line that still carries a `{{slot}}` after substitution cannot be
391
+ * checked against a live page without guessing what fills it;
392
+ * - and no startText, no report values, or a read-only procedure means no
393
+ * goal at all. Never guess one.
394
+ */
395
+ function deriveGoal(opts) {
396
+ if (!opts.mutating || !opts.startText)
397
+ return null;
398
+ const before = opts.startText.replace(/\s+/g, ' ').toLowerCase();
399
+ const out = [];
400
+ const seen = new Set();
401
+ for (const raw of Object.values(opts.reportValues)) {
402
+ for (const rawLine of String(raw ?? '').split('\n')) {
403
+ const line = rawLine.replace(/\s+/g, ' ').trim();
404
+ if (line.length < MIN_GOAL_LEN)
405
+ continue;
406
+ if (!/[A-Za-z]/.test(line))
407
+ continue; // digits and punctuation are ids and counts, not states
408
+ if (before.includes(line.toLowerCase()))
409
+ continue;
410
+ if (opts.identities.has(line))
411
+ continue;
412
+ const subbed = opts.sub(line);
413
+ if (subbed.includes('{{'))
414
+ continue;
415
+ if (seen.has(subbed.toLowerCase()))
416
+ continue;
417
+ seen.add(subbed.toLowerCase());
418
+ out.push(subbed);
419
+ if (out.length >= MAX_GOAL)
420
+ return { requireText: out };
421
+ }
422
+ }
423
+ return out.length ? { requireText: out } : null;
424
+ }
425
+ const MIN_IDENTITY_LEN = 4;
426
+ const MAX_IDENTITY = 2;
427
+ /**
428
+ * Which caller-vouched values the page ALREADY showed where this segment
429
+ * starts — the segment's identity precondition, as slot markers so replay
430
+ * checks the live run's own values.
431
+ *
432
+ * Only known values qualify (the runid, a threaded ref, url provenance): they
433
+ * are the ones that name the record the caller means, and a value the
434
+ * compiler merely inferred from repeated text could easily be page furniture.
435
+ * A value the segment is about to TYPE is not on the page yet, so it never
436
+ * qualifies either — which is what keeps this from refusing a create step.
437
+ */
438
+ /**
439
+ * Caller-vouched, or built out of something the caller vouched for: the
440
+ * ticket TITLE ("r9-n2 RD Bench Ticket") is as run-scoped as the runid inside
441
+ * it, and it is usually the title — not the bare runid — that survives as a
442
+ * slot, because the longer value swallows the shorter one. Treating only the
443
+ * exact known value as identity would therefore lose identity on exactly the
444
+ * skills that need it.
445
+ */
446
+ function derivesFromKnown(value, known) {
447
+ if (known.has(value))
448
+ return true;
449
+ for (const k of known) {
450
+ if (k.length < 3 || k.length === value.length)
451
+ continue;
452
+ if (new RegExp(`(?<![A-Za-z0-9])${escapeRe(k)}(?![A-Za-z0-9])`).test(value))
453
+ return true;
454
+ }
455
+ return false;
456
+ }
457
+ function identityOf(startText, slots, known) {
458
+ if (!startText)
459
+ return [];
460
+ const out = [];
461
+ for (const [name, raw] of slots) {
462
+ if (out.length >= MAX_IDENTITY)
463
+ break;
464
+ // Whitespace is not identity. fwkb3 published a column name as "Backlog "
465
+ // (trailing space, copied from the header's text), the slot became a
466
+ // requireText marker, and every replay refused the create step because
467
+ // the live page showed "Backlog" — the same word.
468
+ const value = raw.replace(/\s+/g, ' ').trim();
469
+ if (!derivesFromKnown(raw, known) || value.length < MIN_IDENTITY_LEN || /^https?:/i.test(value))
470
+ continue;
471
+ if (!startText.replace(/\s+/g, ' ').includes(value))
472
+ continue;
473
+ out.push(`{{${name}}}`);
474
+ }
475
+ return out;
476
+ }
477
+ const MAX_MINTED = 8;
478
+ /**
479
+ * Mechanism-1 provenance (PLAN-replay-v2): values the run itself minted. A
480
+ * url part that first appears in a step's post-navigation url — absent from
481
+ * the start url, every earlier url, the caller's slot values and everything
482
+ * the agent typed — was created by this run (a fresh record id, a generated
483
+ * uid). Every later occurrence is downstream of that step's outcome, so it
484
+ * becomes a {{dN}} reference bound at replay time from where the browser
485
+ * actually lands — the same mechanism as discoverSlots, with a new value
486
+ * source, and the same guards: id-shaped, whole-value match, first
487
+ * appearance wins.
488
+ */
489
+ function discoverMinted(kept, startUrl, slots) {
490
+ const seen = new Set(urlParts(startUrl).map((p) => p.value));
491
+ const slotVals = new Set(slots.values());
492
+ const out = [];
493
+ kept.forEach((step, i) => {
494
+ // Values the agent TYPED are inputs, not mints, wherever they surface later.
495
+ for (const v of Object.values(step.args))
496
+ if (typeof v === 'string')
497
+ seen.add(v);
498
+ if (!step.diff?.url)
499
+ return;
500
+ for (const part of urlParts(step.diff.url)) {
501
+ const v = part.value;
502
+ const fresh = !seen.has(v);
503
+ seen.add(v);
504
+ // Position is evidence. A bare "44" free in prose means nothing, which is
505
+ // why the ledger keeps a length floor — but "44" sitting in a url part
506
+ // is a record id, and isIdLike already says so for url-pattern
507
+ // generalisation. Requiring 4 characters here contradicted that: odoo's
508
+ // record ids are two-digit integers, so fwod15 compiled ZERO minting
509
+ // steps and the whole `mints` mechanism was inert on that target.
510
+ //
511
+ // Same shape as the two floor mismatches already fixed today ("t15"
512
+ // below identifierLike's floor; url parts published at >= 4 while
513
+ // buildFlow minted refs at >= 3). Three separate thresholds asking one
514
+ // question, disagreeing three times. This one now asks the question the
515
+ // url code already answers.
516
+ if (!fresh || !(isIdLike(v) || identifierLike(v)) || slotVals.has(v) || /\{\{/.test(v))
517
+ continue;
518
+ // A stable route word ("tickets", "dashboards") also first appears in a
519
+ // post-nav url once; claiming it would wildcard preconditions that
520
+ // should stay exact. Requiring a digit is a heuristic, but one whose
521
+ // being wrong costs a soft-match comparison (mechanism 2 still catches
522
+ // a digitless minted id), not a dead flow.
523
+ if (!/\d/.test(v))
524
+ continue;
525
+ if (out.length >= MAX_MINTED)
526
+ continue;
527
+ out.push({ name: `d${out.length + 1}`, value: v, keptIndex: i, at: part.label });
528
+ }
529
+ });
530
+ return out;
531
+ }
532
+ /**
533
+ * Literal values the agent used that also appear as whole tokens in the
534
+ * instruction. Ordered by first occurrence in the instruction, longest match
535
+ * first when values nest ("x7 RD Part A" before "x7").
536
+ *
537
+ * `known` are run-scoped values the CALLER vouches for — declared flow vars
538
+ * (the runid) and minted url-provenance parts. They are slotted by policy,
539
+ * not heuristics: every occurrence in the instruction, args, and locators is
540
+ * the same value playing the same role by construction, so the
541
+ * single-occurrence guard below does not apply to them (unlike admin/admin,
542
+ * where one string served two different roles). This is what keeps a run
543
+ * identifier out of a compiled skill: fwrd3 baked "fwrd3-n1"/"RD-1015" into
544
+ * templates and steps, so every skill was single-run poison — tier-A replay
545
+ * died at the first stale literal on every later run, and repairs minted a
546
+ * fresh single-run corpse each time instead of converging.
547
+ */
548
+ export function discoverSlots(instruction, steps, known = {}) {
549
+ const values = new Set();
550
+ const locatorCandidates = new Set();
551
+ for (const step of steps) {
552
+ if (step.tool === 'read' || step.tool === 'read_all' || step.tool === 'eval')
553
+ continue;
554
+ for (const [key, v] of Object.entries(step.args)) {
555
+ if (!VALUE_ARGS.has(key) || typeof v !== 'string')
556
+ continue;
557
+ const value = v.trim();
558
+ if (value.length < 2 || value.length > 200)
559
+ continue;
560
+ if (!occursAsToken(instruction, value))
561
+ continue;
562
+ values.add(value);
563
+ }
564
+ // wait_for text is a check, but a check on a parameter is still parameterised
565
+ if (step.tool === 'wait_for' && typeof step.args.text === 'string' && occursAsToken(instruction, step.args.text.trim())) {
566
+ values.add(step.args.text.trim());
567
+ }
568
+ // A locator that IDENTIFIES a record — clicking the row for ticket
569
+ // "RD-1015", a link named after the value — hard-codes that record unless
570
+ // its identifying string is parameterised too. Collect candidates now;
571
+ // add them below only if they look record-specific, so a plain UI label
572
+ // ("Save") that happens to appear in the instruction is not parameterised.
573
+ for (const loc of Object.values(step.locators)) {
574
+ for (const value of locatorValues(loc.chain ?? [])) {
575
+ const v = value.trim();
576
+ if (v.length >= 2 && v.length <= 200 && occursAsToken(instruction, v))
577
+ locatorCandidates.add(v);
578
+ }
579
+ }
580
+ }
581
+ for (const v of locatorCandidates) {
582
+ // Record-specific = already a value the caller typed (an arg slot), or
583
+ // carries an identifier (a digit / id-like token). Excludes stable UI text.
584
+ if (values.has(v) || /\d/.test(v) || v.split(/\s+/).some(isIdLike))
585
+ values.add(v);
586
+ }
587
+ const knownVals = [];
588
+ for (const raw of Object.values(known)) {
589
+ const v = String(raw ?? '').trim();
590
+ if (v.length < 2 || v.length > 200)
591
+ continue;
592
+ if (!occursAsToken(instruction, v) || knownVals.includes(v))
593
+ continue;
594
+ knownVals.push(v);
595
+ }
596
+ knownVals.sort((a, b) => instruction.indexOf(a) - instruction.indexOf(b) || b.length - a.length);
597
+ const ordered = [...values]
598
+ .filter((v) => !knownVals.includes(v))
599
+ // A value appearing twice in the instruction cannot be given a slot: one
600
+ // slot name would stand for two roles. "sign in with email admin and
601
+ // password admin" compiled to "email {{v1}} and password {{v1}}", and
602
+ // bindSkill emits a capture group per OCCURRENCE, so replaying it with
603
+ // "email alice@example.com and password hunter2" bound v1 to the last
604
+ // group and typed the password into the email field — silently, and with
605
+ // a credential. Leaving such a value literal costs generality (the skill
606
+ // only replays for the values it was recorded with) and keeps
607
+ // correctness, which is the right way round. Distinct positional slots
608
+ // per occurrence would recover the generality, but they also need the
609
+ // step-to-occurrence mapping that plain textual substitution cannot
610
+ // recover, so that is a separate change.
611
+ .filter((v) => countTokenOccurrences(instruction, v) === 1)
612
+ .map((v) => ({ v, at: instruction.indexOf(v) }))
613
+ .sort((a, b) => a.at - b.at || b.v.length - a.v.length)
614
+ .slice(0, Math.max(0, MAX_SLOT_VALUES - knownVals.length));
615
+ // Third slot source, exempt from instruction anchoring: a navigation arg's
616
+ // `id=` value that the ledger already banked from an EARLIER instruction's
617
+ // url. The armdoc rightly forbids instructions naming database ids, so this
618
+ // value can never anchor in prose — but its ORIGIN is known (a `url:iN:q.id`
619
+ // binding), and slots bind by origin when the template cannot supply them.
620
+ // fwod29 is the cost of the gap: three downstream skills carried
621
+ // `...&id=21` literally, every replay navigated to the recording run's
622
+ // deleted order, saw an empty page, and paid ~20 recovery turns.
623
+ const urlIdVals = [];
624
+ const knownIdOrigins = new Set(Object.entries(known)
625
+ .filter(([k]) => /:q\.id$/.test(k))
626
+ .map(([, v]) => String(v ?? '').trim()));
627
+ for (const step of steps) {
628
+ if (typeof step.args.url !== 'string')
629
+ continue;
630
+ for (const part of urlParts(step.args.url)) {
631
+ if (part.label !== 'q.id' || !/^\d{1,10}$/.test(part.value))
632
+ continue;
633
+ if (!knownIdOrigins.has(part.value) || urlIdVals.includes(part.value))
634
+ continue;
635
+ if (knownVals.includes(part.value) || values.has(part.value))
636
+ continue;
637
+ urlIdVals.push(part.value);
638
+ }
639
+ }
640
+ const slots = new Map();
641
+ // Known values first so a cap can never cut them: they are the slots that
642
+ // decide whether the skill survives past the run that recorded it.
643
+ [...knownVals, ...ordered.map(({ v }) => v), ...urlIdVals].forEach((v, i) => slots.set(`v${i + 1}`, v));
644
+ return slots;
645
+ }
646
+ /** Rewrite `id=<value>` url params to slot markers — see the call site. */
647
+ /**
648
+ * Rewrite minted url parts inside a navigation url at the position each was
649
+ * minted from: a value minted at `q.action` replaces `action=<value>` (query
650
+ * or hash state) with `action={{dN}}`, and nothing else — a "135" elsewhere
651
+ * in the url is left alone.
652
+ */
653
+ export function substituteUrlParts(url, minted) {
654
+ let out = url;
655
+ for (const m of minted) {
656
+ if (!m.value || !m.at.startsWith('q.'))
657
+ continue;
658
+ const key = m.at.slice(2);
659
+ out = out.replace(new RegExp(`([?&#]${escapeRe(key)}=)${escapeRe(m.value)}(?=[&#]|$)`, 'g'), `$1{{${m.name}}}`);
660
+ }
661
+ return out;
662
+ }
663
+ export function substituteUrlId(url, slots) {
664
+ let out = url;
665
+ for (const [name, value] of slots) {
666
+ if (!/^\d{1,10}$/.test(value))
667
+ continue;
668
+ out = out.replace(new RegExp(`([?&#]id=)${escapeRe(value)}(?=[&#]|$)`, 'g'), `$1{{${name}}}`);
669
+ }
670
+ return out;
671
+ }
672
+ /** How many times `value` stands as a whole token in `text`. */
673
+ export function countTokenOccurrences(text, value) {
674
+ if (!value)
675
+ return 0;
676
+ const re = new RegExp(`(^|[^A-Za-z0-9])${escapeRe(value)}(?=$|[^A-Za-z0-9])`, 'g');
677
+ let n = 0;
678
+ while (re.exec(text) !== null)
679
+ n++;
680
+ return n;
681
+ }
682
+ function occursAsToken(text, value) {
683
+ if (!value)
684
+ return false;
685
+ const re = new RegExp(`(^|[^A-Za-z0-9])${escapeRe(value)}(?=$|[^A-Za-z0-9])`);
686
+ return re.test(text);
687
+ }
688
+ /**
689
+ * The human-meaningful identifying strings in a locator chain — the ones that
690
+ * can carry a record identifier (a role/link name, visible text, a label). Id
691
+ * and css selectors are excluded: their embedded ids are already handled by
692
+ * stableFirst (demoted) and are not values a caller would supply.
693
+ */
694
+ /**
695
+ * An identity anchor left naming the recorded run's record: its text still
696
+ * contains a value the caller vouched for THIS run, with no slot to swap.
697
+ * Only anchors qualify — a role/text locator that survives un-slotted is
698
+ * ordinary UI text, and dropping it would cost a working fallback.
699
+ */
700
+ /**
701
+ * Positional: this candidate finds an element by WHERE it sits (a structural
702
+ * path, or an index into a set of matches), not by what it is. Fine as a
703
+ * fallback for an action whose target is otherwise pinned; never sufficient
704
+ * on its own for a read that names a record.
705
+ */
706
+ function positional(c) {
707
+ return c.kind === 'css' || c.kind === 'point' || c.nth !== undefined;
708
+ }
709
+ export function stranded(c, runValues) {
710
+ const fields = [];
711
+ if (c.kind === 'scoped')
712
+ fields.push(c.hasText);
713
+ // A NAME that is really a record reference. The rule used to stop at
714
+ // anchors, reasoning that "a role/text locator that survives un-slotted is
715
+ // ordinary UI text, and dropping it would cost a working fallback". True of
716
+ // ordinary UI text — false of a link whose accessible name IS the ticket
717
+ // ref. fwrd22l shipped six of these, `getByText('RD-1015')` and
718
+ // `getByRole('link', { name: 'RD-1015' })`, every one of them pinned to the
719
+ // record the RECORDING run created. A value that changes every run is not a
720
+ // working fallback, it is a fallback that has already stopped working.
721
+ else if (c.kind === 'role')
722
+ fields.push(c.name);
723
+ else if (c.kind === 'text')
724
+ fields.push(c.text);
725
+ else if (c.kind === 'label')
726
+ fields.push(c.label);
727
+ else if (c.kind === 'placeholder')
728
+ fields.push(c.placeholder);
729
+ // An ADDRESS welded out of a value this run minted. `ticket-link-t15` is
730
+ // the record's own id inside a test hook, and it survived every fix so far
731
+ // because this check only ever looked at anchors: fwrd20l and fwrd21l both
732
+ // shipped it. stableFirst demotes it to the tail, so it is not usually
733
+ // reached — but if the anchor and the structural path both miss, it
734
+ // resolves against whatever wears that id NEXT run, which on an app that
735
+ // reuses ids is a different record.
736
+ else if (c.kind === 'testid')
737
+ fields.push(c.value);
738
+ else if (c.kind === 'id' || c.kind === 'css')
739
+ fields.push(c.selector);
740
+ return fields.some((f) => runValues.some((v) => f.includes(v)));
741
+ }
742
+ /**
743
+ * An ADDRESS that is really a bookmark: a test hook or an id whose text
744
+ * carries a minted identifier, like `ticket-link-t15`. Next run the record is
745
+ * t16 and it matches nothing — or worse, on an app that reuses ids, it
746
+ * matches a DIFFERENT record.
747
+ *
748
+ * Distinct from `stranded`, which needs the value to be one the run
749
+ * demonstrably made. That is not enough here: the id an instruction MINTS is
750
+ * unknown while that instruction is compiling — repair-desk's create step
751
+ * never visits a t15 url, so nothing banks it — yet the testid recorded on
752
+ * that very step already has it welded in. fwrd19l shipped three, fwrd20l and
753
+ * fwrd21l two each, all of them below `stranded`'s reach.
754
+ *
755
+ * Structural, so it needs no provenance: an id-like token that is not a slot
756
+ * marker. Bare one- and two-digit numbers are excluded by `isIdLike`'s
757
+ * callers here, so ordinary hooks (`del-1`, `row-2`) are untouched.
758
+ */
759
+ function bookmarked(c) {
760
+ if (c.kind !== 'testid' && c.kind !== 'id')
761
+ return false;
762
+ const text = c.kind === 'testid' ? c.value : c.selector;
763
+ return text
764
+ .split(/[^A-Za-z0-9{}]+/)
765
+ .filter(Boolean)
766
+ .some((tok) => !tok.includes('{{') && isIdLike(tok) && !/^\d{1,2}$/.test(tok));
767
+ }
768
+ function locatorValues(chain) {
769
+ const out = [];
770
+ for (const c of chain) {
771
+ if (c.kind === 'scoped')
772
+ out.push(c.hasText);
773
+ else if (c.kind === 'role')
774
+ out.push(c.name);
775
+ else if (c.kind === 'text')
776
+ out.push(c.text);
777
+ else if (c.kind === 'label')
778
+ out.push(c.label);
779
+ else if (c.kind === 'placeholder')
780
+ out.push(c.placeholder);
781
+ }
782
+ return out.filter(Boolean);
783
+ }
784
+ export function escapeRe(s) {
785
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
786
+ }
787
+ /** Replace every slot value in `text` by its "{{vN}}" marker, longest values first. */
788
+ export function substitute(text, slots) {
789
+ let out = text;
790
+ const byLength = [...slots].sort((a, b) => b[1].length - a[1].length);
791
+ for (const [name, value] of byLength) {
792
+ if (!value)
793
+ continue;
794
+ // Whole-token only, and a bare number never rewrites a selector index:
795
+ // a cost of "25" must not touch the 25 in `:nth-of-type(25)` or `nth=25`.
796
+ const numeric = /^\d+$/.test(value);
797
+ // A number inside a dotted run of numbers (127.0.0.1, 1.2.3) is part of
798
+ // that address or version, never a slot: fwod31 compiled the odoo start
799
+ // url as `http://127.0.0.{{d1}}:8069/...` after `cids=1` minted d1 = "1".
800
+ const re = numeric
801
+ ? new RegExp(`(?<![A-Za-z0-9(=]|\\d\\.)${escapeRe(value)}(?![A-Za-z0-9)]|\\.\\d)`, 'g')
802
+ : new RegExp(`(?<![A-Za-z0-9])${escapeRe(value)}(?![A-Za-z0-9])`, 'g');
803
+ out = out.replace(re, `{{${name}}}`);
804
+ }
805
+ return out;
806
+ }
807
+ /** Inverse of substitute(): fill "{{vN}}" (caller param) and "{{dN}}" (derived,
808
+ * bound from the live run's own urls) markers from a param map. */
809
+ export function fillParams(text, params) {
810
+ return text.replace(/\{\{([vd]\d+)\}\}/g, (m, name) => (name in params ? params[name] : m));
811
+ }
812
+ export function substituteDeep(value, slots) {
813
+ if (typeof value === 'string')
814
+ return substitute(value, slots);
815
+ if (Array.isArray(value))
816
+ return value.map((v) => substituteDeep(v, slots));
817
+ if (value && typeof value === 'object') {
818
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, substituteDeep(v, slots)]));
819
+ }
820
+ return value;
821
+ }
822
+ export function fillParamsDeep(value, params) {
823
+ if (typeof value === 'string')
824
+ return fillParams(value, params);
825
+ if (Array.isArray(value))
826
+ return value.map((v) => fillParamsDeep(v, params));
827
+ if (value && typeof value === 'object') {
828
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, fillParamsDeep(v, params)]));
829
+ }
830
+ return value;
831
+ }
832
+ export function slotsUsed(text) {
833
+ return [...new Set([...text.matchAll(/\{\{(v\d+)\}\}/g)].map((m) => m[1]))];
834
+ }
835
+ /**
836
+ * A url reduced to the shape that identifies its page: origin + path + hash
837
+ * route, with id-like segments replaced by `:id` and the query dropped.
838
+ * Slot values in the path become their markers, so a skill recorded on
839
+ * `/tickets/x7` matches `/tickets/{{v1}}` on the next run.
840
+ */
841
+ export function urlPattern(url, slots = new Map()) {
842
+ let u;
843
+ try {
844
+ u = new URL(url);
845
+ }
846
+ catch {
847
+ return url;
848
+ }
849
+ const norm = (p) => p
850
+ .split('/')
851
+ .map((seg) => {
852
+ if (!seg)
853
+ return seg;
854
+ const filled = substitute(safeDecode(seg), slots);
855
+ if (filled !== seg && filled.includes('{{'))
856
+ return filled;
857
+ return isIdLike(seg) ? ':id' : seg;
858
+ })
859
+ .join('/');
860
+ /**
861
+ * A hash-routed app puts its route in the fragment, in one of two shapes: a
862
+ * path ("#/orders/123") or a query-like state string
863
+ * ("#action=123&cids=1&menu_id=81", which is Odoo). Only the path shape was
864
+ * being reduced, because norm() splits on "/" and a query-shaped fragment
865
+ * has none — so the whole fragment survived verbatim, volatile ids and all.
866
+ *
867
+ * That made a segment's precondition unmatchable by anything but the run
868
+ * that recorded it: Odoo hands out a fresh action id per session, so a
869
+ * chain's second segment refused every replay and the work fell back to the
870
+ * model, run after run, with the store looking perfectly healthy.
871
+ *
872
+ * Keys are kept (they are what distinguishes one template from another) and
873
+ * id-like values reduced. Pairs are sorted because the app is free to emit
874
+ * them in any order between runs, and two orderings of the same state are
875
+ * the same page.
876
+ */
877
+ const normHash = (raw) => {
878
+ const body = raw.split('?')[0];
879
+ if (!body)
880
+ return '';
881
+ if (body.startsWith('/') || !body.includes('='))
882
+ return '#' + norm(body);
883
+ const pairs = body
884
+ .split('&')
885
+ .filter(Boolean)
886
+ .map((pair) => {
887
+ const eq = pair.indexOf('=');
888
+ if (eq < 0)
889
+ return pair;
890
+ const key = pair.slice(0, eq);
891
+ const value = pair.slice(eq + 1);
892
+ const filled = substitute(safeDecode(value), slots);
893
+ if (filled !== value && filled.includes('{{'))
894
+ return `${key}=${filled}`;
895
+ return `${key}=${isIdLike(value) ? ':id' : value}`;
896
+ })
897
+ .sort();
898
+ return '#' + pairs.join('&');
899
+ };
900
+ const hash = u.hash && u.hash.length > 1 ? normHash(u.hash.slice(1)) : '';
901
+ // An opaque origin (chrome-error://, about:) prints as "null"; keep the
902
+ // url itself so a message says what the browser was actually showing.
903
+ if (u.origin === 'null')
904
+ return url;
905
+ const origin = u.protocol === 'file:' ? 'file://' : u.origin;
906
+ return `${origin}${norm(u.pathname)}${hash}`;
907
+ }
908
+ export function isIdLike(seg) {
909
+ if (/^\d+$/.test(seg))
910
+ return true;
911
+ if (/^[0-9a-f]{8,}$/i.test(seg))
912
+ return true;
913
+ if (/^[0-9a-f-]{32,}$/i.test(seg))
914
+ return true; // uuid
915
+ if (/^[A-Za-z]{1,4}[-_]?\d+$/.test(seg))
916
+ return true; // t15, RD-1015
917
+ if (/^[A-Za-z0-9_-]{16,}$/.test(seg) && /\d/.test(seg))
918
+ return true; // opaque tokens
919
+ return false;
920
+ }
921
+ function safeDecode(s) {
922
+ try {
923
+ return decodeURIComponent(s);
924
+ }
925
+ catch {
926
+ return s;
927
+ }
928
+ }
929
+ function urlShapeOf(s) {
930
+ let u;
931
+ try {
932
+ u = new URL(s);
933
+ }
934
+ catch {
935
+ return null;
936
+ }
937
+ const origin = u.protocol === 'file:' ? 'file://' : u.origin;
938
+ const shape = {
939
+ origin,
940
+ path: u.pathname.split('/').filter(Boolean).map(safeDecode),
941
+ hashKind: 'none',
942
+ hashPath: [],
943
+ hashState: new Map(),
944
+ hashSlash: false,
945
+ };
946
+ const body = u.hash && u.hash.length > 1 ? u.hash.slice(1).split('?')[0] : '';
947
+ if (!body)
948
+ return shape;
949
+ if (body.startsWith('/') || !body.includes('=')) {
950
+ shape.hashKind = 'path';
951
+ shape.hashSlash = body.startsWith('/');
952
+ shape.hashPath = body.split('/').filter(Boolean).map(safeDecode);
953
+ return shape;
954
+ }
955
+ shape.hashKind = 'state';
956
+ for (const pair of body.split('&').filter(Boolean)) {
957
+ const eq = pair.indexOf('=');
958
+ if (eq < 0)
959
+ shape.hashState.set(pair, '');
960
+ else
961
+ shape.hashState.set(pair.slice(0, eq), safeDecode(pair.slice(eq + 1)));
962
+ }
963
+ return shape;
964
+ }
965
+ function serializeShape(s) {
966
+ let hash = '';
967
+ if (s.hashKind === 'path')
968
+ hash = '#' + (s.hashSlash ? '/' : '') + s.hashPath.join('/');
969
+ else if (s.hashKind === 'state') {
970
+ hash = '#' + [...s.hashState].map(([k, v]) => `${k}=${v}`).sort().join('&');
971
+ }
972
+ return `${s.origin}/${s.path.join('/')}${hash}`;
973
+ }
974
+ /** A pattern segment that stands for "any value here". */
975
+ function isWildcardSeg(seg) {
976
+ return seg === ':id' || seg === ':var' || /\{\{[\w.-]+\}\}/.test(seg);
977
+ }
978
+ /**
979
+ * Structural comparison of a stored pattern against a live url: `null` when
980
+ * the two are not even the same page shape (different origin, path length,
981
+ * route, or a required state key missing), otherwise the list of segments
982
+ * where a literal in the pattern disagrees with the live value — empty list
983
+ * means a match. Wildcard segments (`:id`, `:var`, unfilled `{{…}}`) match
984
+ * anything: matching consults the pattern's own markers, never a shape
985
+ * heuristic on the live value (that is what made isIdLike load-bearing).
986
+ *
987
+ * A query-shaped fragment is application STATE, and state accumulates (Odoo
988
+ * lands on "#cids=1" and has grown "#action=…&menu_id=…" by the next
989
+ * segment) — so it is a necessary condition: every pair the pattern names
990
+ * must be present, extra live pairs are allowed, and a pattern with no hash
991
+ * requires nothing of a live state fragment. A path-shaped fragment is a
992
+ * route and must match segment for segment.
993
+ */
994
+ export function urlDiff(pattern, url) {
995
+ const p = urlShapeOf(pattern);
996
+ const l = urlShapeOf(url);
997
+ if (!p || !l)
998
+ return pattern === url ? [] : null;
999
+ if (p.origin !== l.origin || p.path.length !== l.path.length)
1000
+ return null;
1001
+ const diffs = [];
1002
+ p.path.forEach((seg, i) => {
1003
+ if (!isWildcardSeg(seg) && seg !== l.path[i])
1004
+ diffs.push({ where: 'path', index: i, expected: seg, actual: l.path[i] });
1005
+ });
1006
+ if (p.hashKind === 'path') {
1007
+ if (l.hashKind !== 'path' || p.hashPath.length !== l.hashPath.length)
1008
+ return null;
1009
+ p.hashPath.forEach((seg, i) => {
1010
+ if (!isWildcardSeg(seg) && seg !== l.hashPath[i])
1011
+ diffs.push({ where: 'hashPath', index: i, expected: seg, actual: l.hashPath[i] });
1012
+ });
1013
+ }
1014
+ else if (p.hashKind === 'state') {
1015
+ if (l.hashKind !== 'state')
1016
+ return null;
1017
+ for (const [key, val] of p.hashState) {
1018
+ // A key the pattern only knows as a wildcard (`:id`, a {{dN}} it never
1019
+ // learned a value for) is app-minted state, not identity: odoo adds
1020
+ // `cids=1` to a url on one run and not the next, and fwod32's sign-in
1021
+ // stopped on every replay because the live url lacked it. A missing
1022
+ // key with a LITERAL value is still a different page.
1023
+ if (!l.hashState.has(key)) {
1024
+ if (isWildcardSeg(val))
1025
+ continue;
1026
+ return null;
1027
+ }
1028
+ const lv = l.hashState.get(key);
1029
+ if (!isWildcardSeg(val) && val !== lv)
1030
+ diffs.push({ where: 'hashState', key, expected: val, actual: lv });
1031
+ }
1032
+ }
1033
+ else if (l.hashKind === 'path' && l.hashPath.length) {
1034
+ return null; // pattern names no route; the live url is on one
1035
+ }
1036
+ return diffs;
1037
+ }
1038
+ /** Whether a live url matches a stored pattern exactly (wildcards aside). */
1039
+ export function urlMatches(pattern, url, params = {}) {
1040
+ return urlDiff(fillParams(pattern, params), url)?.length === 0;
1041
+ }
1042
+ const MAX_SOFT_DIFFS = 2;
1043
+ /**
1044
+ * Mechanism-2 tolerance (PLAN-replay-v2): the live url is the same page
1045
+ * SHAPE as the pattern but 1–2 literal segments disagree — the signature of
1046
+ * an environment-minted identifier (a Grafana uid, an Odoo action id) that
1047
+ * this run minted differently. Returns the pattern with exactly the
1048
+ * disagreeing segments generalised to `:var`, for the caller to proceed
1049
+ * optimistically and PERSIST only once the run past this point succeeds —
1050
+ * the segment has then demonstrated volatility. Null when the urls differ in
1051
+ * shape, everything matched already, or a slot value broke segmentation.
1052
+ */
1053
+ export function softUrlMatch(pattern, url, params = {}) {
1054
+ const filled = fillParams(pattern, params);
1055
+ const diffs = urlDiff(filled, url);
1056
+ if (!diffs || !diffs.length || diffs.length > MAX_SOFT_DIFFS)
1057
+ return null;
1058
+ // Generalise in the ORIGINAL pattern (markers intact). A param value
1059
+ // containing '/' would shift segment positions between the two — bail.
1060
+ const orig = urlShapeOf(pattern);
1061
+ const fld = urlShapeOf(filled);
1062
+ if (!orig || !fld || orig.path.length !== fld.path.length || orig.hashPath.length !== fld.hashPath.length)
1063
+ return null;
1064
+ for (const d of diffs) {
1065
+ if (d.where === 'path')
1066
+ orig.path[d.index] = ':var';
1067
+ else if (d.where === 'hashPath')
1068
+ orig.hashPath[d.index] = ':var';
1069
+ else
1070
+ orig.hashState.set(d.key, ':var');
1071
+ }
1072
+ return { generalised: serializeShape(orig), diffs };
1073
+ }
1074
+ /**
1075
+ * The addressable parts of a url, labelled stably so a value observed at
1076
+ * record time can be re-extracted from the live run's url at the same
1077
+ * position: path segments `p<i>`, hash-route segments `h<i>`, hash-state
1078
+ * values `q.<key>`.
1079
+ */
1080
+ export function urlParts(url) {
1081
+ const s = urlShapeOf(url);
1082
+ if (!s)
1083
+ return [];
1084
+ const out = [];
1085
+ s.path.forEach((value, i) => out.push({ label: `p${i}`, value }));
1086
+ s.hashPath.forEach((value, i) => out.push({ label: `h${i}`, value }));
1087
+ for (const [k, value] of s.hashState)
1088
+ out.push({ label: `q.${k}`, value });
1089
+ return out;
1090
+ }
1091
+ export function urlPart(url, label) {
1092
+ return urlParts(url).find((p) => p.label === label)?.value;
1093
+ }
1094
+ /**
1095
+ * Page lines that describe the page in transit — spinners, progress bars,
1096
+ * and toasts (an alert that happened to be on screen, such as fwgr25's
1097
+ * "Error loading RSS feed", is not what the step did; a step's own alert is
1098
+ * carried by alertContains, which stays soft). Never a lasting effect.
1099
+ */
1100
+ export const TRANSIENT_LINE = /^-?\s*(status|progressbar|alert)\b/;
1101
+ function expectationFor(step, slots) {
1102
+ if (!step.diff)
1103
+ return undefined;
1104
+ const out = {};
1105
+ if (step.diff.url)
1106
+ out.urlPattern = urlPattern(step.diff.url, slots);
1107
+ if (step.diff.alerts[0])
1108
+ out.alertContains = substitute(step.diff.alerts[0], slots).slice(0, 120);
1109
+ if (step.diff.added.length) {
1110
+ // A status or progress indicator is the page in transit, not where the
1111
+ // step left it: fwgr25's sign-in recorded `- status "Loading"` as its
1112
+ // click's only page change, and every replay — which caught the page
1113
+ // after the spinner — stopped there and recovered for 24 turns.
1114
+ const lasting = step.diff.added.filter((l) => !TRANSIENT_LINE.test(l));
1115
+ if (lasting.length)
1116
+ out.addedContains = lasting.slice(0, MAX_ADDED_LINES).map((l) => maskMinted(maskVolatile(substitute(l, slots))).slice(0, 120));
1117
+ }
1118
+ return Object.keys(out).length ? out : undefined;
1119
+ }
1120
+ /**
1121
+ * An effect expectation asserts what the PROCEDURE put on the page, and the
1122
+ * procedure only ever puts values there through its own fills and choices —
1123
+ * which are slots (`{{vN}}`, or a `{{dN}}` the app minted and a url showed)
1124
+ * by the time this runs, because substitute() went first. A control's
1125
+ * displayed value that is NOT a slot is therefore the app's: a default, a
1126
+ * computed figure, the id of the record the recording happened to make.
1127
+ * atelyr's project picker recorded `- combobox "…": 13f9pv52yozr` — the
1128
+ * recording's own project id — and every replay's project had another, so
1129
+ * both add-item steps stopped at "did not show … as it did when recorded".
1130
+ *
1131
+ * Provenance, not shape: no attempt is made to recognise an identifier by
1132
+ * how it looks, which breaks on the next app. The role and name still have
1133
+ * to match; only the value after the colon is wildcarded.
1134
+ */
1135
+ export function maskMinted(line) {
1136
+ // `- role "name" [state]: value` — the value colon is the one after the
1137
+ // (quoted) name and any state markers, never one inside the name.
1138
+ return line.replace(/^(-?\s*\S+(?:\s+"(?:[^"\\]|\\.)*")?(?:\s+\[[^\]]*\])*)(:\s*)(\S.*?)\s*$/, (whole, head, sep, value) => value.includes('{{') ? whole : `${head}${sep}${WILDCARD}`);
1139
+ }
1140
+ /**
1141
+ * Clock and calendar tokens in a recorded page line are the RECORDING's
1142
+ * moment, not the procedure's effect: kanboard names its due-date textbox
1143
+ * after the current minute ("09/03/2026 07:22"), so the fill's expectation
1144
+ * `- textbox "09/03/2026 07:22": {{v3}}` — HARD, because it carries the slot
1145
+ * — could never match a replay nine minutes later, and fwkb3 sent every
1146
+ * due-date step to recovery. The caller's own date is already a slot by the
1147
+ * time this runs (substitute() went first), so what is left is volatile and
1148
+ * becomes a `{{*}}` wildcard that lineShows() matches against anything short
1149
+ * of a line break.
1150
+ */
1151
+ export { WILDCARD, maskVolatile } from '../shared/text.js';
1152
+ /** If a read's result equals one of the report's evidence values, label it with that key. */
1153
+ function readLabel(step, values) {
1154
+ if (step.tool !== 'read' && step.tool !== 'read_all')
1155
+ return undefined;
1156
+ // Carried from the report, where the name came from. Exact beats matching.
1157
+ if (step.label && step.label in values)
1158
+ return step.label;
1159
+ if (step.result === undefined)
1160
+ return undefined;
1161
+ let observed;
1162
+ try {
1163
+ observed = JSON.parse(step.result);
1164
+ }
1165
+ catch {
1166
+ observed = step.result;
1167
+ }
1168
+ const flat = Array.isArray(observed) ? observed.map(String) : [String(observed)];
1169
+ // A list read is the LIST first: a read_all of three panel headings whose
1170
+ // first element happens to equal a per-item value was labelled with that
1171
+ // item (fwgr23: `read_all h2` → panel_title_request_rate) and the value the
1172
+ // report joined from the whole list — panel_titles — was never published.
1173
+ if (flat.length > 1) {
1174
+ const joined = new Set([', ', ' | ', '; ', ' ', '\n', ','].map((sep) => flat.map((f) => f.trim()).join(sep)));
1175
+ for (const [key, v] of Object.entries(values))
1176
+ if (joined.has(String(v).trim()))
1177
+ return key;
1178
+ }
1179
+ for (const [key, v] of Object.entries(values)) {
1180
+ const s = String(v).trim();
1181
+ if (s && flat.some((f) => f.trim() === s))
1182
+ return key;
1183
+ }
1184
+ return undefined;
1185
+ }
1186
+ function firstUrl(steps) {
1187
+ const goto = steps.find((s) => s.tool === 'goto' && typeof s.args.url === 'string');
1188
+ return goto ? String(goto.args.url) : undefined;
1189
+ }
1190
+ /**
1191
+ * Candidates whose selector text embeds something id-like (`ticket-link-t15`,
1192
+ * `#row-1042`) were unique on the recorded page but will name a *different*
1193
+ * element on the next run. They stay in the chain as a last resort; the
1194
+ * semantic candidates (role+name, label, text — now parameterised) go first.
1195
+ */
1196
+ export function stableFirst(chain) {
1197
+ const volatile = (c) => {
1198
+ // Where it was is the last resort by definition: behind every name and
1199
+ // every path. fwgr27's store had it second, ahead of the anchored path.
1200
+ if (c.kind === 'point')
1201
+ return true;
1202
+ if (c.kind === 'testid' || c.kind === 'id' || c.kind === 'css') {
1203
+ const text = c.kind === 'testid' ? c.value : c.selector;
1204
+ return text
1205
+ .split(/[^A-Za-z0-9{}]+/)
1206
+ .filter(Boolean)
1207
+ .some((tok) => !tok.includes('{{') && isIdLike(tok) && !/^\d{1,2}$/.test(tok));
1208
+ }
1209
+ // A name that is nothing but an id ("RD-1017") names a record, not a
1210
+ // control: the same element next run will carry a different one.
1211
+ const name = c.kind === 'role' ? c.name : c.kind === 'text' ? c.text : '';
1212
+ return Boolean(name) && !name.includes('{{') && isIdLike(name.trim());
1213
+ };
1214
+ const stable = chain.filter((c) => !volatile(c));
1215
+ const points = chain.filter((c) => c.kind === 'point');
1216
+ const rest = chain.filter((c) => volatile(c) && c.kind !== 'point');
1217
+ return stable.length || points.length ? [...stable, ...rest, ...points] : chain;
1218
+ }
1219
+ /** Steps are structurally the same procedure: same tools, same primary locator shapes. */
1220
+ export function sameProcedure(a, b) {
1221
+ if (a.steps.length !== b.steps.length)
1222
+ return false;
1223
+ return a.steps.every((s, i) => {
1224
+ const t = b.steps[i];
1225
+ if (s.tool !== t.tool)
1226
+ return false;
1227
+ // Same procedure = same tools driven by the same KIND of primary locator,
1228
+ // regardless of the literal value (a label of 'Name' vs 'Name *', a role
1229
+ // name that is a parameter or a record id). This is what lets two runs'
1230
+ // "add a part" skills merge instead of fragmenting the store; the literal
1231
+ // differences are exactly the parameters the skills already carry.
1232
+ return locatorShape(s.locators.target?.[0]) === locatorShape(t.locators.target?.[0]);
1233
+ });
1234
+ }
1235
+ /** A locator's structural shape for merge comparison: its kind, plus the stable
1236
+ * part of a css/id selector (tag/structure, not any embedded id). */
1237
+ function locatorShape(c) {
1238
+ if (!c)
1239
+ return 'none';
1240
+ if (c.kind === 'css' || c.kind === 'id') {
1241
+ // Drop id-like and numeric tokens so `#row-1042 > a` and `#row-77 > a` match.
1242
+ const skeleton = c.selector.replace(/[A-Za-z0-9_-]+/g, (tok) => (isIdLike(tok) ? '*' : tok));
1243
+ return `${c.kind}:${skeleton}`;
1244
+ }
1245
+ return c.kind;
1246
+ }
1247
+ const MAX_GROUP_LEN = 3;
1248
+ const LOOP_MAX_ITER_CAP = 50;
1249
+ // A loop iterates an ACTION over records (delete each part, ...). Its anchor
1250
+ // must be a click; a group may carry connector controls (dialog_expect) but
1251
+ // never an observation — folding consecutive read-backs into a loop, which they
1252
+ // superficially resemble (same shape, per-record ids), is a bug: reads observe,
1253
+ // they do not iterate.
1254
+ const LOOP_ANCHOR_TOOLS = new Set(['click', 'dblclick', 'modifier_click', 'right_click']);
1255
+ const NON_LOOP_TOOLS = new Set(['read', 'read_all', 'eval', 'screenshot']);
1256
+ /**
1257
+ * Collapse a run of consecutive, identical control steps that carry no target
1258
+ * (chiefly `dialog_expect`, which the agent often re-arms redundantly) into
1259
+ * one. Arming the same handler twice is a no-op, but the extra copies land
1260
+ * unevenly between otherwise-identical action groups and stop foldLoops from
1261
+ * seeing the repetition. Only no-locator steps with byte-identical args are
1262
+ * touched, so real actions are never merged.
1263
+ */
1264
+ export function coalesceControls(steps) {
1265
+ const out = [];
1266
+ for (const step of steps) {
1267
+ const prev = out[out.length - 1];
1268
+ const noTarget = !step.locators.target?.length && !step.locators.source?.length;
1269
+ if (prev && noTarget && prev.tool === step.tool && !prev.locators.target?.length && JSON.stringify(prev.args) === JSON.stringify(step.args)) {
1270
+ continue;
1271
+ }
1272
+ out.push(step);
1273
+ }
1274
+ return out;
1275
+ }
1276
+ /**
1277
+ * Drop a navigation whose destination another navigation immediately
1278
+ * replaces. The agent explores — fwod6's step-01 skill recorded `goto /web`,
1279
+ * a hand-built `#action=&…&menu_id=` url, then `goto /web?cids=1` — and a
1280
+ * procedure that re-walks the search is not the procedure, just its history.
1281
+ * Only strictly adjacent navigations qualify: once anything else ran, the
1282
+ * intermediate page may have been load-bearing (a session bootstrap, a
1283
+ * redirect that set a cookie), and this cannot tell from the outside.
1284
+ */
1285
+ export function dropSupersededNavigation(steps) {
1286
+ return steps.filter((step, i) => !(step.tool === 'goto' && steps[i + 1]?.tool === 'goto'));
1287
+ }
1288
+ /** Button names that dismiss a dialog without acting — UI convention, not app knowledge. */
1289
+ const DISMISSAL = /^(cancel|close|dismiss|no|not now|back|keep editing)$/i;
1290
+ /**
1291
+ * Drop a dialog the recording opened and immediately dismissed. fwgr25's
1292
+ * create step recorded Exit edit → "Discard changes to dashboard?" → Cancel,
1293
+ * because the RECORDING had unsaved edits when the model clicked Exit edit
1294
+ * and then thought better of it. The pair did nothing to the app, but a
1295
+ * replay with nothing unsaved has no dialog to cancel: Exit edit simply
1296
+ * exits, and every step that expected to still be in edit mode fails (5/18
1297
+ * on both replays, 23–49 model turns). Evidence-based: step N's recorded
1298
+ * effect includes a dialog, step N+1 clicks a button that dialog listed,
1299
+ * that button is named as a dismissal, and step N+1 recorded no page change
1300
+ * of its own. A confirm ("Discard", "Delete", "Save") never matches.
1301
+ */
1302
+ export function dropDismissedDialogs(steps) {
1303
+ const out = [];
1304
+ for (let i = 0; i < steps.length; i++) {
1305
+ const opener = steps[i];
1306
+ const closer = steps[i + 1];
1307
+ const added = opener.expect?.addedContains ?? [];
1308
+ const opensDialog = added.some((l) => /^-\s*dialog\b/.test(l));
1309
+ if (opensDialog && closer?.tool === 'click' && !closer.expect?.addedContains?.length) {
1310
+ const primary = (closer.locators.target ?? [])[0];
1311
+ const name = primary?.kind === 'role' && primary.role === 'button' ? primary.name : primary?.kind === 'text' ? primary.text : undefined;
1312
+ const listed = name !== undefined && added.some((l) => l.includes(`button "${name}"`));
1313
+ if (name && listed && DISMISSAL.test(name.trim())) {
1314
+ i += 1; // skip the closer too
1315
+ continue;
1316
+ }
1317
+ }
1318
+ out.push(opener);
1319
+ }
1320
+ return out;
1321
+ }
1322
+ /** Replace id-like whole tokens in a string with `*`, so per-record ids collapse. */
1323
+ function stripIds(text) {
1324
+ return text
1325
+ .split(/([^A-Za-z0-9]+)/)
1326
+ .map((tok) => (/^[A-Za-z0-9]+$/.test(tok) && isIdLike(tok) ? '*' : tok))
1327
+ .join('');
1328
+ }
1329
+ /** A candidate's identity with per-record ids blanked — its shape AND its name/value. */
1330
+ function candSkeleton(c) {
1331
+ switch (c.kind) {
1332
+ case 'role':
1333
+ return `role:${c.role}:${stripIds(c.name ?? '')}`;
1334
+ case 'text':
1335
+ return `text:${stripIds(c.text ?? '')}`;
1336
+ case 'label':
1337
+ return `label:${stripIds(c.label ?? '')}`;
1338
+ case 'placeholder':
1339
+ return `placeholder:${stripIds(c.placeholder ?? '')}`;
1340
+ case 'testid':
1341
+ return `testid:${stripIds(c.value)}`;
1342
+ default:
1343
+ return locatorShape(c);
1344
+ }
1345
+ }
1346
+ function chainSkeleton(chain) {
1347
+ return (chain ?? []).map(candSkeleton).join('|');
1348
+ }
1349
+ /** Two steps are the same procedure applied to (possibly) a different record. */
1350
+ function loopEquivalent(a, b) {
1351
+ if (a.tool !== b.tool || a.tool === 'loop')
1352
+ return false;
1353
+ // Same procedure means the same TYPED values too: two edits that set
1354
+ // different quantities are two steps, not one loop replaying the first
1355
+ // group's value on every record. Targets are per-record by design.
1356
+ const typed = (s) => JSON.stringify(Object.fromEntries(Object.entries(s.args).filter(([k]) => k !== 'target' && k !== 'source')));
1357
+ return typed(a) === typed(b) && chainSkeleton(a.locators.target) === chainSkeleton(b.locators.target) && chainSkeleton(a.locators.source) === chainSkeleton(b.locators.source);
1358
+ }
1359
+ /** True when two groups differ in a *raw* id somewhere — proof they act on distinct records, not an accidental repeat. */
1360
+ function differsInRawId(a, b) {
1361
+ const raw = (g) => JSON.stringify(g.map((s) => [s.locators.target ?? [], s.locators.source ?? []]));
1362
+ return raw(a) !== raw(b);
1363
+ }
1364
+ /**
1365
+ * Collapse a run of ≥2 consecutive, structurally-identical action groups that
1366
+ * differ only in a per-record id — the signature of iterating over a list (e.g.
1367
+ * deleting each part in turn) — into a single `loop` step. The loop repeats its
1368
+ * body while the body's first target still matches an element, so a replay on a
1369
+ * list of a different length still clears it, instead of hard-coding the count
1370
+ * seen when recording. Conservative by construction: distinct fields (a title
1371
+ * vs a customer box) have different skeletons and never fold, and an accidental
1372
+ * identical repeat (no id difference) is left alone.
1373
+ */
1374
+ export function foldLoops(steps) {
1375
+ const out = [];
1376
+ let i = 0;
1377
+ while (i < steps.length) {
1378
+ let folded = false;
1379
+ // Prefer the smallest group length so [del, confirm] folds before [del]×2.
1380
+ for (let len = 1; len <= MAX_GROUP_LEN && i + 2 * len <= steps.length; len++) {
1381
+ const group = steps.slice(i, i + len);
1382
+ if (group.some((s) => s.tool === 'loop' || NON_LOOP_TOOLS.has(s.tool)))
1383
+ continue;
1384
+ // The body must anchor on a repeatable, locate-able ACTION (a click on a
1385
+ // record's control), never an observation.
1386
+ if (!LOOP_ANCHOR_TOOLS.has(group[0].tool) || !group[0].locators.target?.length)
1387
+ continue;
1388
+ let count = 1;
1389
+ const groups = [group];
1390
+ while (i + (count + 1) * len <= steps.length) {
1391
+ const next = steps.slice(i + count * len, i + (count + 1) * len);
1392
+ if (!group.every((s, k) => loopEquivalent(s, next[k])))
1393
+ break;
1394
+ groups.push(next);
1395
+ count++;
1396
+ }
1397
+ if (count < 2)
1398
+ continue;
1399
+ // Require a real per-record id difference across at least one pair, so we
1400
+ // only fold genuine iteration, never a control legitimately hit twice.
1401
+ if (!groups.slice(1).some((g) => differsInRawId(group, g)))
1402
+ continue;
1403
+ out.push({
1404
+ tool: 'loop',
1405
+ args: {},
1406
+ locators: {},
1407
+ body: group,
1408
+ while: group[0].locators.target,
1409
+ max: Math.min(count * 2 + 3, LOOP_MAX_ITER_CAP),
1410
+ });
1411
+ i += count * len;
1412
+ folded = true;
1413
+ break;
1414
+ }
1415
+ if (!folded)
1416
+ out.push(steps[i++]);
1417
+ }
1418
+ return out;
1419
+ }
1420
+ //# sourceMappingURL=compile.js.map