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,1724 @@
1
+ import fs from 'node:fs';
2
+ import net from 'node:net';
3
+ import path from 'node:path';
4
+ import { AnthropicProvider, OpenAICompatProvider, resolveProviderConfig } from '../agent/llm.js';
5
+ import { runEscalatingInstruction } from '../agent/loop.js';
6
+ import { executeTool } from '../agent/tools.js';
7
+ import { urlPattern as compiledUrlPattern, fillParams, stranded, urlParts } from '../skills/compile.js';
8
+ import { agentGesturesOutsideReplay, bindSkill, canAdoptPin, decideRepin, learnFromInstruction, matchTemplate, publishedOutputs, selectCandidates, synthesizeReport } from '../skills/learn.js';
9
+ import { buildFlow, consumedUrlOutputs, ignorableRefs, lintFlowRefs, listFlows, loadFlow, loadFlowFile, lookupOutput, mutatingIntent, noteOutputEvidence, recoveryRoute, remapParams, resolveInstruction, resolveStepParams, softResolveInstruction, saveFlow, saveRejectedFlow, stableOutputs, staleInstructionIds, unbankedMutations, urlOutputs } from '../skills/flow.js';
10
+ import { applyRelabelToEntries, applyRelabelToSkills, relabelCases, requestRelabelPlan } from '../skills/relabel.js';
11
+ import { goalSatisfied, renderReplay } from '../skills/replay.js';
12
+ import { drainDrift, llmProposer, recordCandidateEvidence } from '../skills/repair.js';
13
+ import { RunLedger, bindingKey, describeLeaks, fatal, scanForLeaks } from '../skills/ledger.js';
14
+ import { originOf } from '../skills/store.js';
15
+ import { generateScript } from './codegen.js';
16
+ import { snapshot, waitForContent } from './refs.js';
17
+ import { ScriptRecorder } from './recorder.js';
18
+ import { encodeFrame, LineDecoder } from '../shared/protocol.js';
19
+ import { aliasLegacyEnv, ensureSessionDir, socketPath, validateSessionName } from '../shared/paths.js';
20
+ import { BrowserSession } from './browser.js';
21
+ import { SessionState } from './state.js';
22
+ /**
23
+ * Served immediately instead of queued behind the command in flight. These are
24
+ * exactly the commands an operator needs *while* a `do` is misbehaving — if
25
+ * they queue, observing and killing a stuck run is impossible precisely when
26
+ * it matters. All of them are read-only w.r.t. the agent's history; `screenshot`
27
+ * touches the page, which Playwright already serialises internally.
28
+ */
29
+ const UNQUEUED_COMMANDS = new Set(['ping', 'config', 'screenshot', 'stop']);
30
+ /** How long `stop` lets an aborted instruction unwind before tearing down. */
31
+ const STOP_DRAIN_MS = 3_000;
32
+ /**
33
+ * How many stored candidates a flow step may actually replay (attempts that
34
+ * ran at least one step) before giving up and recovering on the model.
35
+ * Refusals (wrong page, unbindable params) are free and do not count.
36
+ */
37
+ const MAX_CANDIDATE_ATTEMPTS = 3;
38
+ export class Daemon {
39
+ opts;
40
+ browser;
41
+ state;
42
+ server = null;
43
+ /** Serialise commands: the browser and the history are single-threaded resources. */
44
+ queue = Promise.resolve();
45
+ /** Aborts the instruction currently running, so `stop` can preempt it. */
46
+ inflight = null;
47
+ /**
48
+ * Everything THIS RUN made — caller vars, ids minted in a url, values a
49
+ * step reported — each with a binding saying how a later run re-derives its
50
+ * own. A later instruction naming one of these is naming a value of this
51
+ * run, not of the app, so compile must slot it; fwgr6 shipped n1's uid 62
52
+ * times inside skill templates because recording had no such registry.
53
+ * See PLAN-provenance.md.
54
+ */
55
+ ledger = new RunLedger();
56
+ /** Instruction counter, so a ledger entry can say where it first appeared. */
57
+ instructionIndex = 0;
58
+ constructor(opts) {
59
+ this.opts = opts;
60
+ this.browser = new BrowserSession({
61
+ session: opts.session,
62
+ headed: opts.headed,
63
+ record: opts.record,
64
+ script: opts.script,
65
+ learn: opts.learn,
66
+ });
67
+ this.state = new SessionState(opts.session);
68
+ }
69
+ /** Bank what this instruction minted: url ids first, then reported values. */
70
+ noteMintedIds(entries, stepId) {
71
+ for (const e of entries) {
72
+ const url = e.k === 'step' ? e.diff?.url : e.k === 'instruction' ? e.url : undefined;
73
+ if (url)
74
+ this.ledger.addUrlIds(url, stepId, urlParts(url));
75
+ if (e.k === 'report') {
76
+ for (const [name, value] of Object.entries(e.values ?? {})) {
77
+ this.ledger.add(String(value), { from: 'output', step: stepId, name }, { known: true });
78
+ }
79
+ }
80
+ }
81
+ }
82
+ /** Caller vars seeded once, so every producer sees the same run values. */
83
+ seedLedger() {
84
+ // Vouched: a declared var is a run value whatever its length (`runid=k7`
85
+ // is the documented example, and the length floor was dropping it).
86
+ for (const [name, value] of Object.entries(this.state.vars ?? {}))
87
+ this.ledger.add(value, { from: 'var', name }, { vouched: true });
88
+ }
89
+ /**
90
+ * Scan a freshly compiled skill for values this run made. The flow export
91
+ * scan (below) covers what a flow carries; this covers the STORE, which is
92
+ * where the damage actually lands — an anchor holding the recording run's
93
+ * runid still resolves on the run that recorded it, so the sweep passes and
94
+ * the defect only shows up as a drift ticket two runs later.
95
+ *
96
+ * WARN, not ERROR: what is being measured is the ledger's coverage.
97
+ */
98
+ reportSkillLeaks(learned, progress) {
99
+ const store = this.browser.learn;
100
+ if (!store)
101
+ return;
102
+ const ids = learned.compiledAll ?? (learned.compiled ? [learned.compiled] : []);
103
+ for (const id of ids) {
104
+ const skill = store.get(id);
105
+ if (!skill)
106
+ continue;
107
+ const leaks = scanForLeaks(skill, this.ledger, id);
108
+ if (leaks.length)
109
+ progress(`[learn] warning: ${leaks.length} run value(s) survived into ${id}:
110
+ ${describeLeaks(leaks.slice(0, 6))}`);
111
+ }
112
+ }
113
+ /**
114
+ * Drop locator candidates carrying a value the ledger knows this run made.
115
+ * Never empties a chain — a step with no way to find its element is worse
116
+ * than one carrying a candidate that will miss. Returns how many went.
117
+ */
118
+ stripLeakedCandidates(flow, store) {
119
+ // Identifiers only, as `fatal` already insists: a reported status word
120
+ // ("Ready") is banked as text, and stripping every candidate whose name
121
+ // contains it ("Mark Ready") weakened chains permanently in the store.
122
+ const runValues = this.ledger
123
+ .all()
124
+ .filter((e) => e.kind === 'identifier' && e.value.length >= 3)
125
+ .map((e) => e.value);
126
+ if (!runValues.length)
127
+ return 0;
128
+ let removed = 0;
129
+ for (const skill of this.sessionSkills(flow, store)) {
130
+ let touched = false;
131
+ const walk = (steps) => {
132
+ for (const step of steps) {
133
+ for (const [key, chain] of Object.entries(step.locators ?? {})) {
134
+ const kept = chain.filter((c) => !stranded(c, runValues));
135
+ if (!kept.length || kept.length === chain.length)
136
+ continue;
137
+ removed += chain.length - kept.length;
138
+ step.locators[key] = kept;
139
+ touched = true;
140
+ }
141
+ if (step.body)
142
+ walk(step.body);
143
+ }
144
+ };
145
+ walk(skill.steps);
146
+ if (touched)
147
+ store.put(skill);
148
+ }
149
+ return removed;
150
+ }
151
+ /**
152
+ * Everything of this run's that survived into what the export will publish.
153
+ * The SKILLS as well as the flow: a flow has no locators of its own, and a
154
+ * locator is where a leak does its damage silently.
155
+ */
156
+ leaksIn(flow, store) {
157
+ const leaks = scanForLeaks(flow, this.ledger, 'flow');
158
+ for (const sk of this.sessionSkills(flow, store))
159
+ leaks.push(...scanForLeaks(sk, this.ledger, sk.id));
160
+ return leaks;
161
+ }
162
+ /**
163
+ * The skills this run's ledger has any business rewriting: everything THIS
164
+ * SESSION compiled, plus whatever the flow pinned.
165
+ *
166
+ * Pinned-only was too narrow. fwrd26l exported clean while two skills it had
167
+ * just compiled still carried `RD-1015` and a creation date in a row-text
168
+ * locator — unpinned, so unscanned, and selectCandidates will happily pick
169
+ * one at replay because it binds the instruction. Session-scoped and not
170
+ * store-wide, because a skill some EARLIER run made is not ours to rewrite:
171
+ * a literal that looks like this run's value may have been legitimate in its.
172
+ */
173
+ sessionSkills(flow, store) {
174
+ const out = new Map();
175
+ for (const sk of store.list(flow.origin)) {
176
+ if (sk.provenance?.session === this.opts.session)
177
+ out.set(sk.id, sk);
178
+ }
179
+ for (const st of flow.steps) {
180
+ const sk = st.skill ? store.get(st.skill) : null;
181
+ if (sk)
182
+ out.set(sk.id, sk);
183
+ }
184
+ return [...out.values()];
185
+ }
186
+ /** The run's values keyed by their ORIGIN, so a param can bind to where a value comes from. */
187
+ knownValues() {
188
+ const out = {};
189
+ for (const e of this.ledger.all())
190
+ out[bindingKey(e.binding)] = e.value;
191
+ return out;
192
+ }
193
+ provider(overrides = {}) {
194
+ return build(resolveProviderConfig(overrides));
195
+ }
196
+ /**
197
+ * The escalation tier for a `do`, or null when disabled or when it would
198
+ * resolve to the same model as the routine one (retrying a blocked
199
+ * instruction on the model that just blocked buys nothing).
200
+ */
201
+ fallbackProvider(overrides = {}, primary) {
202
+ const config = resolveProviderConfig(overrides);
203
+ if (!config.fallbackModel || config.fallbackModel === primary?.model)
204
+ return null;
205
+ // A different model must not inherit the main model's extraBody: routing
206
+ // pins are per-model calibration (see ProviderConfig.extraBody).
207
+ return build({ ...config, model: config.fallbackModel, extraBody: config.fallbackExtraBody });
208
+ }
209
+ /**
210
+ * The model flow recovery uses. Unlike the escalation fallback this is NOT
211
+ * gated by whether per-step escalation is enabled — recovering a drifted
212
+ * flow step is a deliberate, hard task, so it goes to the strongest model
213
+ * available: an explicit override, else the configured fallback model, else
214
+ * the routine model (when none is configured).
215
+ */
216
+ recoveryProvider(overrideModel) {
217
+ const config = resolveProviderConfig();
218
+ const model = overrideModel || (config.fallbackModel && config.fallbackModel !== 'none' ? config.fallbackModel : config.model);
219
+ // Same rule as fallbackProvider: extraBody is main-model calibration, so
220
+ // a recovery built for a different model takes fallbackExtraBody. This is
221
+ // what aborted relabel on 3 of 4 live runs — the bench's Baidu pin
222
+ // (chosen for deepseek-v4-flash) forced glm-5.3 through a slow upstream:
223
+ // 25.5s measured with the pin vs 3.9s without, against a 75s timebox.
224
+ return build({ ...config, model, ...(model !== config.model ? { extraBody: config.fallbackExtraBody } : {}) });
225
+ }
226
+ async listen() {
227
+ const sock = socketPath(this.opts.session);
228
+ if (process.platform !== 'win32' && fs.existsSync(sock))
229
+ fs.unlinkSync(sock);
230
+ this.server = net.createServer((conn) => this.handleConnection(conn));
231
+ await new Promise((resolve, reject) => {
232
+ this.server.once('error', reject);
233
+ this.server.listen(sock, resolve);
234
+ });
235
+ }
236
+ handleConnection(conn) {
237
+ const decoder = new LineDecoder();
238
+ conn.on('data', (chunk) => {
239
+ let requests;
240
+ try {
241
+ requests = decoder.push(chunk);
242
+ }
243
+ catch (err) {
244
+ conn.write(encodeFrame({ id: -1, type: 'result', ok: false, errorKind: 'infra', error: `bad request: ${err}` }));
245
+ return;
246
+ }
247
+ for (const req of requests) {
248
+ if (UNQUEUED_COMMANDS.has(req.command))
249
+ void this.serve(conn, req).catch(() => { });
250
+ else
251
+ this.queue = this.queue.then(() => this.serve(conn, req)).catch(() => { });
252
+ }
253
+ });
254
+ conn.on('error', () => { });
255
+ }
256
+ send(conn, frame) {
257
+ if (!conn.destroyed)
258
+ conn.write(encodeFrame(frame));
259
+ }
260
+ async serve(conn, req) {
261
+ try {
262
+ const data = await this.execute(req, (message) => this.send(conn, { id: req.id, type: 'progress', message }));
263
+ this.send(conn, { id: req.id, type: 'result', ok: true, data });
264
+ if (req.command === 'stop') {
265
+ conn.end();
266
+ await this.shutdown();
267
+ }
268
+ }
269
+ catch (err) {
270
+ const message = err instanceof Error ? err.message : String(err);
271
+ const infra = /no API key|could not launch|LLM HTTP|LLM request failed/.test(message);
272
+ this.send(conn, {
273
+ id: req.id,
274
+ type: 'result',
275
+ ok: false,
276
+ error: message,
277
+ errorKind: infra ? 'infra' : 'command',
278
+ });
279
+ }
280
+ }
281
+ async execute(req, progress) {
282
+ const a = req.args ?? {};
283
+ switch (req.command) {
284
+ case 'ping':
285
+ return { pid: process.pid, session: this.opts.session };
286
+ case 'open': {
287
+ const page = await this.browser.getPage();
288
+ await page.goto(String(a.url), { waitUntil: 'load', timeout: 30_000 });
289
+ // `load` fires before a client-rendered app has painted, and the very
290
+ // next thing anyone does with an opened page is snapshot it.
291
+ await waitForContent(page);
292
+ // `open` drives the page directly rather than through a tool call, so
293
+ // it has to record its own navigation or a recorded script would start
294
+ // wherever the first instruction happened to find the browser.
295
+ const recorder = this.browser.script;
296
+ if (recorder)
297
+ recorder.commit(await recorder.prepare(page, 'goto', { url: String(a.url) }), 'ok');
298
+ return { url: page.url(), title: await page.title() };
299
+ }
300
+ case 'peek': {
301
+ const page = await this.browser.getPage();
302
+ return {
303
+ url: page.url(),
304
+ title: await page.title(),
305
+ snapshot: await snapshot(page, {
306
+ selector: a.selector ? String(a.selector) : undefined,
307
+ interactiveOnly: Boolean(a.interactiveOnly),
308
+ }),
309
+ };
310
+ }
311
+ case 'screenshot': {
312
+ const page = await this.browser.getPage();
313
+ const file = a.path
314
+ ? path.resolve(String(a.path))
315
+ : path.join(ensureSessionDir(this.opts.session), 'screenshots', `shot-${Date.now()}.jpg`);
316
+ fs.mkdirSync(path.dirname(file), { recursive: true });
317
+ await page.screenshot({ path: file, type: 'jpeg', fullPage: Boolean(a.fullPage) });
318
+ return { path: file };
319
+ }
320
+ case 'brief':
321
+ this.state.setBriefing(String(a.text ?? ''), Boolean(a.append));
322
+ return { briefingChars: this.state.briefing.length };
323
+ case 'note':
324
+ this.state.addNote(String(a.text ?? ''));
325
+ return { notes: this.state.notes.length };
326
+ case 'reset': {
327
+ // Clears the LLM conversation only — browser page, cookies, briefing,
328
+ // and notes are untouched. Lets a caller that tracks its own compact
329
+ // progress summary (e.g. via `note`) avoid resending the full raw
330
+ // tool-call history on every subsequent `do`, without losing login
331
+ // state or restarting the browser.
332
+ const before = this.state.messages.length;
333
+ this.state.messages = [];
334
+ return { clearedMessages: before };
335
+ }
336
+ case 'do': {
337
+ const overrides = {
338
+ provider: a.provider ? String(a.provider) : undefined,
339
+ model: a.model ? String(a.model) : undefined,
340
+ baseUrl: a.baseUrl ? String(a.baseUrl) : undefined,
341
+ fallbackModel: a.fallbackModel ? String(a.fallbackModel) : undefined,
342
+ };
343
+ const provider = this.provider(overrides);
344
+ const fallback = a.escalate === false ? null : this.fallbackProvider(overrides, provider);
345
+ const controller = new AbortController();
346
+ this.inflight = controller;
347
+ const instruction = String(a.instruction);
348
+ const screenshotDir = path.join(ensureSessionDir(this.opts.session), 'screenshots');
349
+ const loopOpts = {
350
+ maxTurns: typeof a.maxTurns === 'number' ? a.maxTurns : 30,
351
+ timeoutMs: (typeof a.timeoutS === 'number' ? a.timeoutS : 300) * 1000,
352
+ ...(typeof a.turnTimeoutS === 'number' ? { turnTimeoutMs: a.turnTimeoutS * 1000 } : {}),
353
+ screenshotDir,
354
+ signal: controller.signal,
355
+ onProgress: progress,
356
+ };
357
+ // Where this instruction's recording starts, so learning can read back
358
+ // exactly what it did (and nothing from earlier instructions).
359
+ const mark = this.browser.script?.mark() ?? 0;
360
+ try {
361
+ // Zero-model path: a validated skill whose template matches this
362
+ // instruction word for word replays without any LLM call. If it
363
+ // stops part-way the agent takes over with the partial result in
364
+ // hand, exactly as it would after calling run_skill itself.
365
+ const direct = await this.replayDirect(instruction, screenshotDir, controller.signal, progress);
366
+ let result;
367
+ if (direct.done) {
368
+ result = direct.done;
369
+ }
370
+ else {
371
+ result = await runEscalatingInstruction(provider, fallback, this.browser, this.state, direct.prelude ? `${instruction}\n\n${direct.prelude}` : instruction,
372
+ // After a part-way replay the recording already holds this
373
+ // instruction's group (opened by replayDirect); the model's
374
+ // continuation is filed into it as a resume, not a new step.
375
+ { ...loopOpts, ...(direct.prelude ? { recordAs: { text: instruction, resume: true } } : {}) });
376
+ if (direct.partial && result.skill)
377
+ result.skill = { ...result.skill, ...direct.partial, listed: result.skill.listed };
378
+ }
379
+ // Bank the ids this instruction minted BEFORE compiling it: a value
380
+ // first seen in this instruction's own url is already known to the
381
+ // caller by the time the next instruction names it, and compile
382
+ // must treat it as a run value rather than app furniture.
383
+ const entriesSince = this.browser.script?.entriesSince(mark) ?? [];
384
+ this.instructionIndex += 1;
385
+ this.ledger.beginInstruction(this.instructionIndex);
386
+ this.seedLedger();
387
+ const learned = this.browser.learn
388
+ ? learnFromInstruction(this.browser.learn, {
389
+ result,
390
+ instruction,
391
+ entries: entriesSince,
392
+ session: this.opts.session,
393
+ model: provider.model,
394
+ vars: this.knownValues(),
395
+ })
396
+ : null;
397
+ this.noteMintedIds(entriesSince, `i${this.instructionIndex}`);
398
+ if (learned)
399
+ progress(`[learn] ${describeLearned(learned)}`);
400
+ if (learned)
401
+ this.reportSkillLeaks(learned, progress);
402
+ const pinned = learned?.compiled ?? learned?.merged ?? result.skill?.invoked;
403
+ if (pinned)
404
+ this.browser.script?.pinSkill(pinned);
405
+ if (result.skill)
406
+ this.state.recordSkill(result.skill, learned);
407
+ return {
408
+ ...result,
409
+ model: provider.model,
410
+ ...(fallback ? { fallbackModel: fallback.model } : {}),
411
+ ...(learned ? { learned } : {}),
412
+ };
413
+ }
414
+ finally {
415
+ if (this.inflight === controller)
416
+ this.inflight = null;
417
+ }
418
+ }
419
+ case 'script': {
420
+ // Fall back to a disk-backed recorder so a session that recorded and
421
+ // then restarted (or that is being read by a second CLI call) can still
422
+ // generate — script.jsonl is the source of truth, not process memory.
423
+ const recorder = this.browser.script ?? new ScriptRecorder(this.opts.session);
424
+ const steps = recorder.entries.filter((e) => e.k === 'step').length;
425
+ if (a.clear && !a.path) {
426
+ recorder.clear();
427
+ return { cleared: true, steps };
428
+ }
429
+ if (!steps) {
430
+ throw new Error('nothing recorded for this session — start it with --script (or SITELOOPER_SCRIPT=1) before running instructions');
431
+ }
432
+ const file = a.path
433
+ ? path.resolve(String(a.path))
434
+ : path.join(ensureSessionDir(this.opts.session), 'recorded.spec.ts');
435
+ const source = generateScript(recorder.entries, {
436
+ session: this.opts.session,
437
+ title: a.title ? String(a.title) : undefined,
438
+ });
439
+ fs.mkdirSync(path.dirname(file), { recursive: true });
440
+ fs.writeFileSync(file, source);
441
+ if (a.clear)
442
+ recorder.clear();
443
+ return {
444
+ path: file,
445
+ steps,
446
+ instructions: recorder.entries.filter((e) => e.k === 'instruction').length,
447
+ recording: Boolean(this.browser.script),
448
+ cleared: Boolean(a.clear),
449
+ };
450
+ }
451
+ case 'config': {
452
+ const cfg = resolveProviderConfig();
453
+ return {
454
+ session: this.opts.session,
455
+ pid: process.pid,
456
+ provider: cfg.provider,
457
+ model: cfg.model,
458
+ fallbackModel: cfg.fallbackModel ?? null,
459
+ baseUrl: cfg.baseUrl,
460
+ apiKeySet: Boolean(cfg.apiKey),
461
+ apiKeyEnvVars: cfg.keyEnvVars,
462
+ sessionDir: ensureSessionDir(this.opts.session),
463
+ briefingChars: this.state.briefing.length,
464
+ recording: this.browser.recording,
465
+ scriptRecording: Boolean(this.browser.script),
466
+ scriptSteps: this.browser.script?.entries.filter((e) => e.k === 'step').length ?? 0,
467
+ learning: Boolean(this.browser.learn),
468
+ skillsDir: this.browser.learn?.dir ?? null,
469
+ skills: this.browser.learn ? this.state.skills : null,
470
+ notes: this.state.notes,
471
+ usage: this.state.usage,
472
+ usageByModel: this.state.usageByModel,
473
+ historyMessages: this.state.messages.length,
474
+ };
475
+ }
476
+ case 'var': {
477
+ const name = String(a.name ?? '').trim();
478
+ if (!name)
479
+ throw new Error('var requires a name (e.g. `var runid=k7`)');
480
+ this.state.setVar(name, String(a.value ?? ''));
481
+ return { vars: this.state.vars };
482
+ }
483
+ case 'flow': {
484
+ // Read-only flow inspection served from disk; the daemon holds no flow state.
485
+ if (a.op === 'list')
486
+ return { flows: listFlowsSummary() };
487
+ if (a.op === 'show') {
488
+ const flow = loadFlow(String(a.name ?? ''));
489
+ if (!flow)
490
+ throw new Error(`no flow "${a.name}"`);
491
+ return { flow };
492
+ }
493
+ throw new Error(`unknown flow op ${JSON.stringify(a.op)}`);
494
+ }
495
+ case 'run': {
496
+ const controller = new AbortController();
497
+ this.inflight = controller;
498
+ try {
499
+ return await this.runFlow(String(a.name ?? ''), a.vars ?? {}, {
500
+ maxTurns: typeof a.maxTurns === 'number' ? a.maxTurns : 30,
501
+ // Flow recovery goes straight to the strong model on a step that
502
+ // is by definition no longer straightforward — give it double the
503
+ // interactive default (swg2-n3 step 05 died mid-recovery at 300s).
504
+ timeoutMs: (typeof a.timeoutS === 'number' ? a.timeoutS : 600) * 1000,
505
+ ...(typeof a.turnTimeoutS === 'number' ? { turnTimeoutMs: a.turnTimeoutS * 1000 } : {}),
506
+ provider: this.provider(),
507
+ fallback: a.escalate === false ? null : this.fallbackProvider({}, this.provider()),
508
+ // Recovery goes STRAIGHT to the strong model: a step that failed to
509
+ // replay is, by definition, no longer the straightforward case the
510
+ // cheap model handled at record time. Resolves to the configured
511
+ // fallback model (even when per-step escalation is off), or an
512
+ // explicit --recovery-model, falling back to the routine model.
513
+ recovery: this.recoveryProvider(a.recoveryModel ? String(a.recoveryModel) : undefined),
514
+ signal: controller.signal,
515
+ progress,
516
+ });
517
+ }
518
+ finally {
519
+ if (this.inflight === controller)
520
+ this.inflight = null;
521
+ }
522
+ }
523
+ /**
524
+ * Drain a run's drift tickets IN SESSION, before the browser closes.
525
+ *
526
+ * This is the whole difference between a repair that works and one that
527
+ * reports "no-proposal": patch-segment has to look at the drifted
528
+ * control on a page that is signed in and reachable, and the only
529
+ * process holding such a page is this one. A cold pass in a fresh
530
+ * browser gets the login screen for every authenticated url, and cannot
531
+ * reach a page whose url carries an id this run minted at all.
532
+ *
533
+ * The store is the daemon's own (`SITELOOPER_SKILLS_DIR`-honouring, so
534
+ * `sitelooper repair` points it at a throwaway temp store), and the
535
+ * proposer runs on the recovery model — the same tier flow recovery
536
+ * uses, for the same reason: a dead chain is not the easy case.
537
+ */
538
+ case 'patch': {
539
+ const store = this.browser.learn;
540
+ if (!store)
541
+ throw new Error('this session has no skill store (start it with --learn or SITELOOPER_SKILLS=1)');
542
+ const tickets = a.tickets ?? [];
543
+ const dryRun = a.dryRun === true;
544
+ const model = a.model ? String(a.model) : undefined;
545
+ const provider = this.recoveryProvider(model);
546
+ const summary = await drainDrift(store, tickets, {
547
+ dryRun,
548
+ model: provider.model,
549
+ propose: llmProposer(provider),
550
+ openPage: async (url) => {
551
+ const page = await this.browser.getPage();
552
+ if (page.url() !== url) {
553
+ progress(`[patch] revisiting ${url}`);
554
+ await page.goto(url, { waitUntil: 'load', timeout: 30_000 }).catch(() => { });
555
+ await waitForContent(page).catch(() => { });
556
+ }
557
+ return page;
558
+ },
559
+ });
560
+ progress(`[patch] ${tickets.length} ticket(s) → ${summary.promoted.length} promoted, ${summary.patched.length} patched, ${summary.reRecord.length} re-record, ${summary.skipped.length} skipped`);
561
+ return { tickets: tickets.length, ...summary };
562
+ }
563
+ case 'stop': {
564
+ // Preempt rather than wait: an operator reaching for `stop` wants the
565
+ // run dead now. The aborted instruction still returns a blocked report
566
+ // (with its actions log) to whoever asked for it.
567
+ const preempted = Boolean(this.inflight);
568
+ this.inflight?.abort();
569
+ if (preempted) {
570
+ await Promise.race([this.queue.catch(() => { }), delay(STOP_DRAIN_MS)]);
571
+ }
572
+ // Close the browser here rather than leaving it to shutdown(): a
573
+ // recorded video is only written out when the context closes, so the
574
+ // files must exist before this result frame goes out. close() is
575
+ // idempotent, so shutdown()'s call becomes a no-op.
576
+ // Export the session as a replayable flow before the context closes.
577
+ let savedFlow;
578
+ if (a.saveFlow) {
579
+ try {
580
+ savedFlow = await this.exportFlow(String(a.saveFlow));
581
+ }
582
+ catch (err) {
583
+ const message = err instanceof Error ? err.message : String(err);
584
+ // Swallowing this into a result field is how a refused export went
585
+ // unnoticed through a whole sweep: nothing prints it.
586
+ console.error(`[flow] export failed: ${message}`);
587
+ savedFlow = { error: message };
588
+ }
589
+ }
590
+ const videos = await this.browser.close();
591
+ return { stopping: true, preempted, videos, ...(savedFlow ? { flow: savedFlow } : {}) };
592
+ }
593
+ default:
594
+ throw new Error(`unknown command: ${req.command}`);
595
+ }
596
+ }
597
+ /**
598
+ * Export the current learning session as a flow: the instructions it issued,
599
+ * in order, each pinned to the skill it used and to the values it read back,
600
+ * with declared run variables turned into references. Requires learning mode
601
+ * (the recording is the source) and a session that ran at least one step.
602
+ */
603
+ async exportFlow(name) {
604
+ if (!this.browser.learn || !this.browser.script) {
605
+ throw new Error('not a learning session — start it with --learn to record a flow');
606
+ }
607
+ // THIS take only — a session dir that survived a crash or a container
608
+ // restart must not blend the killed take into the exported flow.
609
+ const entries = this.browser.script.entriesThisTake();
610
+ const prior = this.browser.script.priorEntries;
611
+ const firstGoto = entries.find((e) => e.k === 'step' && e.tool === 'goto');
612
+ const startUrl = (firstGoto && 'args' in firstGoto ? String(firstGoto.args.url ?? '') : '') ||
613
+ entries.find((e) => e.k === 'instruction' && Boolean(e.url))?.url ||
614
+ '';
615
+ const origin = startUrl ? originOf(startUrl) : null;
616
+ if (!origin || !startUrl)
617
+ throw new Error('could not determine the session start url — was anything opened?');
618
+ const store = this.browser.learn;
619
+ // ONE definition of "what a zero-model replay republishes", shared by the
620
+ // flow builder (which decides what may become a reference) and the linter
621
+ // (which reports what still did). A step's pin may be the HEAD of a
622
+ // segment chain whose later segment does the reading, so the whole chain
623
+ // counts.
624
+ const publishedOutputsOf = (id) => {
625
+ const sk = store.get(id);
626
+ if (!sk)
627
+ return null;
628
+ const chain = sk.seq ? store.list(sk.origin).filter((s) => s.seq?.chain === sk.seq.chain) : [sk];
629
+ return chain.flatMap(publishedOutputs);
630
+ };
631
+ // Post-session relabel: one smart-model pass over the finished session's
632
+ // value names, BEFORE buildFlow mints any {{step.name}} reference. Session
633
+ // end is the only moment naming can use hindsight — which values later
634
+ // instructions actually consumed — and renaming is value-keyed, so nothing
635
+ // banked can be lost; see relabel.ts. Best-effort: a failed call exports
636
+ // the flow with the names it already has. Instruction order in this take
637
+ // matches the ledger's i<N> because a take begins with the daemon.
638
+ try {
639
+ const cases = relabelCases(entries);
640
+ if (cases.length) {
641
+ // Time-boxed: this runs inside `stop`, whose caller is waiting. A
642
+ // slow model costs a bounded wait and the flow exports with the names
643
+ // it has; it must never cost the export (see the CLI's stop timeout,
644
+ // 150s with --save-flow). 30s proved too tight (fwod28 aborted on
645
+ // both outings), and 75s still lost fwod29-n1 — the un-pinned call
646
+ // itself takes ~4s, so what 75s cannot absorb is ONE 429 whose
647
+ // Retry-After hint runs to 65s. 100s covers a full rate-limit wait
648
+ // plus the retry.
649
+ const relabelStarted = Date.now();
650
+ const { plan, dropped } = await requestRelabelPlan(this.recoveryProvider(), cases, {
651
+ signal: AbortSignal.timeout(100_000),
652
+ });
653
+ // Instrumented after fwgr19-n1 aborted with the call still pending:
654
+ // the duration says whether the fix (effort: 'low' in relabel.ts)
655
+ // holds or the pass is drifting back toward the timebox.
656
+ console.error(`[relabel] plan returned in ${Date.now() - relabelStarted}ms (${cases.length} case(s))`);
657
+ if (dropped.length)
658
+ console.error(`[relabel] dropped ${dropped.length} unsafe rename(s): ${dropped.join('; ')}`);
659
+ // Leave a trace even when nothing is renamed: fwod27's script showed
660
+ // zero `relabel` fields and could not say whether the pass proposed
661
+ // nothing or never ran — this daemon's stderr goes nowhere.
662
+ const emptyTrace = () => {
663
+ const last = [...entries].reverse().find((e) => e.k === 'report' && e.status === 'success');
664
+ if (last && last.k === 'report') {
665
+ last.relabel = {};
666
+ this.browser.script?.persist();
667
+ }
668
+ };
669
+ if (!plan.size)
670
+ emptyTrace();
671
+ if (plan.size) {
672
+ const applied = applyRelabelToEntries(entries, plan);
673
+ // A skill may be the head of a segment chain whose LATER segment
674
+ // holds the labelled read, so the whole chain takes the rename.
675
+ const skillIndex = new Map();
676
+ for (const c of cases) {
677
+ if (!c.skill)
678
+ continue;
679
+ const sk = store.get(c.skill);
680
+ if (!sk)
681
+ continue;
682
+ const chain = sk.seq ? store.list(sk.origin).filter((s) => s.seq?.chain === sk.seq.chain) : [sk];
683
+ for (const s of chain)
684
+ skillIndex.set(s.id, c.index);
685
+ }
686
+ const skills = [...skillIndex.keys()].map((id) => store.get(id)).filter((s) => Boolean(s));
687
+ // Persist the objects applyRelabelToSkills MUTATED. The first cut
688
+ // re-fetched with store.get(id) here — but get() re-reads from disk
689
+ // every call, so it handed back pristine copies and the put was a
690
+ // no-op. The skill-side rename silently never persisted on ANY run:
691
+ // fwkb1's flow said {{01-open.column_3}} while its skill kept
692
+ // publishing table_tr_first_child_th_2, both replays hit unresolved
693
+ // refs, and n3's recovery — destination blanked — invented "Ready"
694
+ // and reported success. One dead-reference bug in a new costume,
695
+ // exactly as relabel.ts's doc comment warned.
696
+ const byId = new Map(skills.map((s) => [s.id, s]));
697
+ for (const id of applyRelabelToSkills(skills, plan, skillIndex)) {
698
+ const sk = byId.get(id);
699
+ if (sk)
700
+ store.put(sk);
701
+ }
702
+ this.browser.script.persist();
703
+ console.error(`[relabel] renamed ${applied} value(s) across ${plan.size} instruction(s)`);
704
+ // A plan whose every rename missed its report (validator survivors
705
+ // that matched no existing key) writes no per-report trace at all —
706
+ // fwrd38-n1's silence. Ran-but-changed-nothing must still say so.
707
+ if (applied === 0)
708
+ emptyTrace();
709
+ }
710
+ }
711
+ }
712
+ catch (err) {
713
+ const message = err instanceof Error ? err.message : String(err);
714
+ console.error(`[relabel] skipped: ${message}`);
715
+ const last = [...entries].reverse().find((e) => e.k === 'report' && e.status === 'success');
716
+ if (last && last.k === 'report') {
717
+ last.relabel = { '(error)': message.slice(0, 120) };
718
+ this.browser.script.persist();
719
+ }
720
+ }
721
+ const flow = buildFlow(entries, {
722
+ name,
723
+ origin,
724
+ startUrl,
725
+ vars: this.state.vars,
726
+ session: this.opts.session,
727
+ model: this.provider().model,
728
+ bind: (id, instr) => {
729
+ const sk = store.get(id);
730
+ return sk ? bindSkill(sk, instr, this.knownValues()) : null;
731
+ },
732
+ });
733
+ if (!flow || !flow.steps.length)
734
+ throw new Error('nothing to export — no successful instruction was recorded');
735
+ // Before anything is written. The first cut of this ran after saveFlow,
736
+ // so a "refused" export still left a usable flow on disk and the next run
737
+ // replayed it regardless — a gate that refuses to REPORT is not a gate.
738
+ // Export knows more than compile did. `ticket-link-t15` is welded out of
739
+ // a value the ticket-CREATING instruction minted, so while that
740
+ // instruction compiled, nothing had banked t15 and `stranded` could not
741
+ // see it — the ledger only learns it when a later instruction lands on
742
+ // that url. By export it is known, so apply the same provenance rule with
743
+ // the knowledge that arrived late, and refuse only what survives it.
744
+ //
745
+ // Deleting HERE and not at compile is the whole distinction: this is
746
+ // provenance (the ledger knows the run made the value), never a guess
747
+ // from the token's shape. A shape guess only ever demotes — see
748
+ // `bookmarked` — and observation settles it.
749
+ const stripped = this.stripLeakedCandidates(flow, store);
750
+ const fatalLeaks = this.leaksIn(flow, store).filter(fatal);
751
+ if (fatalLeaks.length) {
752
+ const detail = `${fatalLeaks.length} value(s) this run made survived into a locator, ` +
753
+ `where they would silently move a step onto another record:
754
+ ${describeLeaks(fatalLeaks.slice(0, 10))}`;
755
+ // Kept, but somewhere nothing will replay it: the recording cost real
756
+ // time and money, and the fix is usually obvious from the leak list.
757
+ const kept = saveRejectedFlow(flow, detail);
758
+ throw new Error(`refusing to export: ${detail}
759
+
760
+ the flow was written to ${kept} for inspection (it will not be replayed)`);
761
+ }
762
+ const file = saveFlow(flow);
763
+ // Reference lint (case 4a): warn now, while re-recording is still cheap,
764
+ // about any {{step.output}} only model recovery could re-observe. A step's
765
+ // pin may be one segment of a chain whose LATER segment does the read, so
766
+ // publishes() unions the whole chain.
767
+ const warnings = lintFlowRefs(flow, publishedOutputsOf);
768
+ // Phase 2 of PLAN-provenance: report anything of this run's that survived
769
+ // into the flow. WARN for now — the ledger's coverage is what is being
770
+ // measured, and a false alarm must not block an export.
771
+ const leaks = this.leaksIn(flow, store);
772
+ if (leaks.length) {
773
+ warnings.unshift(`warning: ${leaks.length} run value(s) survived unslotted (non-fatal — a stale urlPattern fails loudly, a stale reportTemplate is caught by synthesizeReport):
774
+ ${describeLeaks(leaks.slice(0, 10))}`);
775
+ }
776
+ // Work the recording did that the flow does not contain. Loud, because a
777
+ // flow missing its create step is unusable and looks fine until a replay
778
+ // runs against a clean app.
779
+ for (const w of staleInstructionIds(entries, flow).reverse())
780
+ warnings.unshift(`warning: ${w}`);
781
+ for (const m of unbankedMutations(entries).reverse())
782
+ warnings.unshift(`warning: ${m}`);
783
+ // Steps whose instruction asked for a change the recording never made
784
+ // (buildFlow's noop-step check). These ride ON the flow file as well, so
785
+ // compile can raise them again long after this session is gone — the
786
+ // saveFlow above already wrote them. Printing them here is what makes
787
+ // re-recording cheap: it costs one instruction now and a failed replay
788
+ // plus a repair sweep later (fwod34 08-open).
789
+ for (const w of (flow.warnings ?? []).slice().reverse())
790
+ warnings.unshift(`warning: ${w}`);
791
+ for (const w of flow.warnings ?? [])
792
+ console.error(`[flow] ${w}`);
793
+ const adopted = flow.steps.filter((s) => s.adopted);
794
+ if (adopted.length) {
795
+ warnings.unshift(`note: ${adopted.length} step(s) adopted from non-success instruction(s) whose work the session continued from ` +
796
+ `(${adopted.map((s) => s.id).join(', ')}) — they replay model-first with doubled budget, and a non-success there does not halt the flow`);
797
+ }
798
+ if (stripped)
799
+ warnings.unshift(`note: dropped ${stripped} locator candidate(s) carrying a value this run minted (known only by export time)`);
800
+ if (prior)
801
+ warnings.unshift(`warning: ignored ${prior} entr${prior === 1 ? 'y' : 'ies'} from an earlier take in session '${this.opts.session}' — this flow covers only what this daemon recorded`);
802
+ return { path: file, name: flow.name, steps: flow.steps.length, vars: flow.vars, ...(warnings.length ? { warnings } : {}) };
803
+ }
804
+ /**
805
+ * Replay a saved flow with no caller in the loop. Each step resolves its
806
+ * {{var}}/{{step.output}} references, then runs through the normal escalating
807
+ * instruction path — which itself tries the pinned skill first (Tier A/B),
808
+ * repairs on the cheap model if the page drifted, and escalates on blocked.
809
+ * The flow halts at the first step that ends non-success, returning the
810
+ * per-step report so a caller can be brought back in to continue.
811
+ */
812
+ async runFlow(name, varsIn, opts) {
813
+ const loaded = loadFlowFile(name);
814
+ if (!loaded)
815
+ throw new Error(`no flow "${name}" (looked in the flows dir and as a path)`);
816
+ // Written back to the file it came from — never to flowsDir()/<flow.name>,
817
+ // which for a flow run by path or under a copied filename is a DIFFERENT
818
+ // flow whose pins and evidence this run would overwrite.
819
+ const { flow, file: flowFile } = loaded;
820
+ const missingVars = flow.vars.filter((v) => !(v in varsIn));
821
+ if (missingVars.length)
822
+ throw new Error(`flow "${flow.name}" needs --var for: ${missingVars.join(', ')}`);
823
+ if (this.browser.learn) {
824
+ // A run's own repairs should be learned, but not re-pin from a fresh
825
+ // store elsewhere; the flow's pinned skills come from its own file.
826
+ }
827
+ const page = await this.browser.getPage();
828
+ await page.goto(flow.startUrl, { waitUntil: 'load', timeout: 30_000 }).catch(() => { });
829
+ // `load` fires before a client-rendered app has painted, and the first
830
+ // step's precondition (fingerprint, identity text) is judged right after.
831
+ await waitForContent(page).catch(() => { });
832
+ this.browser.script?.commit(await this.browser.script.prepare(page, 'goto', { url: flow.startUrl }).catch(() => null), 'ok');
833
+ const screenshotDir = path.join(ensureSessionDir(this.opts.session), 'screenshots');
834
+ const outputs = {};
835
+ // Which url.* outputs of each step a LATER step consumes — the capture
836
+ // below waits (bounded) for those to appear in the URL. See
837
+ // consumedUrlOutputs for the SPA-updates-the-url-late failure this closes.
838
+ const wantedUrlOuts = consumedUrlOutputs(flow.steps);
839
+ const stepResults = [];
840
+ const driftTickets = [];
841
+ const started = Date.now();
842
+ // Inner-model spend across the whole flow run. A pure tier-A replay is
843
+ // genuinely zero; recovery steps are not, and reporting them as free
844
+ // overstated the record-once/replay-many economics.
845
+ const usage = { promptTokens: 0, completionTokens: 0, cachedTokens: 0 };
846
+ // Per-model split of the same spend, via SessionState's ledger: cheap-first
847
+ // recoveries (route-by-cause) must not be priced at the strong model's
848
+ // rate, or the routing win is invisible in the bench.
849
+ const usageBefore = JSON.parse(JSON.stringify(this.state.usageByModel));
850
+ let halted = false;
851
+ /** Adoptions decided this run, before the write-back — see the repin gate. */
852
+ const pendingPins = new Map();
853
+ /** Adopted steps that recovered cleanly this run and should shed `adopted`. */
854
+ const graduated = new Set();
855
+ /**
856
+ * Outputs an earlier run demonstrated are the app's, not this run's, so
857
+ * their recorded literal resolves instead of sending the step to recovery.
858
+ * Read once: a verdict reached mid-run applies from the NEXT run, so every
859
+ * step of one run sees the same evidence.
860
+ */
861
+ const stable = stableOutputs(flow);
862
+ if (Object.keys(stable).length) {
863
+ opts.progress(`[flow ${flow.name}] ${Object.keys(stable).length} output(s) demonstrated stable by an earlier run: ${Object.keys(stable).join(', ')}`);
864
+ }
865
+ /** Steps whose output evidence this run changed, for the write-back below. */
866
+ let evidenceChanged = 0;
867
+ // Set by a step that recovered on the model: a recovery can end
868
+ // "successfully" yet leave a blocking dialog open (rpod1-r2: an earlier
869
+ // recovery left an email composer and an Edit dialog behind, and 06-open
870
+ // paid 56 turns clearing debris its predecessor left). A clean tier-A
871
+ // replay ends where the recording ended — at rest — so only a recovery
872
+ // needs the boundary swept.
873
+ let prevRecovered = false;
874
+ for (const step of flow.steps) {
875
+ if (opts.signal.aborted) {
876
+ stepResults.push({ id: step.id, status: 'blocked', reason: 'run stopped', recovered: false });
877
+ halted = true;
878
+ break;
879
+ }
880
+ // Between-step hygiene: at a step boundary the page should be at rest, so
881
+ // a still-open modal is debris from the previous step's recovery. Clear
882
+ // it before this step's own skill replay so the debris is not charged to
883
+ // this step. Gated on prevRecovered (nothing to sweep after a clean
884
+ // replay) and bounded; uses the ARIA-standard modal signal, never an
885
+ // app selector. In-skill dialogs are unaffected — this runs only at the
886
+ // boundary, never mid-skill.
887
+ if (prevRecovered) {
888
+ try {
889
+ if (!(await dismissBlockingDialogs(await this.browser.getPage()))) {
890
+ opts.progress(`[flow ${flow.name}] ${step.id}: a blocking dialog from the previous step's recovery would not dismiss — proceeding, the step's own recovery will handle it`);
891
+ }
892
+ }
893
+ catch {
894
+ /* browser gone or overlay check failed — the step runs anyway */
895
+ }
896
+ }
897
+ prevRecovered = false;
898
+ const { text, missing } = resolveInstruction(step, varsIn, outputs, stable);
899
+ const bound = resolveStepParams(step, varsIn, outputs, stable);
900
+ // A reference that could not be threaded (an output an earlier step did
901
+ // not read back live) does NOT halt the flow: the zero-model replay is
902
+ // skipped and the step goes to recovery on the strong model, built from
903
+ // what IS known (softResolve keeps the resolved title even when the id is
904
+ // missing). Only a genuine failure there halts.
905
+ // ...unless the pinned skill cannot be affected by the reference at all
906
+ // (see ignorableRefs): then the zero-model replay runs as pinned.
907
+ const pinned = step.skill ? (this.browser.learn?.get(step.skill) ?? null) : null;
908
+ const allMissing = [...missing, ...(bound?.missing ?? [])];
909
+ const ignorable = ignorableRefs(allMissing, step, pinned);
910
+ const blocking = allMissing.filter((r) => !ignorable.includes(r));
911
+ if (allMissing.length && !blocking.length)
912
+ opts.progress(`[flow ${flow.name}] ${step.id}: reference(s) ${ignorable.join(', ')} unresolved but unused by the pinned procedure — replaying as pinned`);
913
+ const unresolved = blocking.length > 0;
914
+ const recoveryText = unresolved ? softResolveInstruction(step, varsIn, outputs, stable) : text;
915
+ opts.progress(`[flow ${flow.name}] ${step.id}: ${(unresolved ? recoveryText : text).slice(0, 80)}`);
916
+ // Already satisfied? Before anything runs — before the zero-model replay
917
+ // and long before the model — ask whether this record is ALREADY in the
918
+ // state this step exists to produce. fwod34 is why: 06-open's recording
919
+ // struggled to cancel an order, so the orchestrator added 08-open to
920
+ // cancel it again; on replay 06's skill cancels cleanly and 08 then
921
+ // failed looking for a Cancel button that no longer exists. A retry step
922
+ // should be harmless, not fatal.
923
+ //
924
+ // Deliberately NOT gated on the skill's status: a demoted pin is exactly
925
+ // the skill this rescues. The gate that matters is the evidence —
926
+ // identity AND goal both visible (see goalSatisfied) — and bound params,
927
+ // since an unbound marker proves nothing about this run's record.
928
+ // The goal lives on the LAST segment of a chain (the one that finishes
929
+ // the work); identity is the head's, which is where a replay would start.
930
+ const tail = pinned?.seq && this.browser.learn
931
+ ? (this.browser.learn.list(pinned.origin).filter((s) => s.seq?.chain === pinned.seq.chain).sort((a, b) => (a.seq.index - b.seq.index)).pop() ?? pinned)
932
+ : pinned;
933
+ if (pinned && tail?.goal?.requireText?.length && bound && !bound.missing.length) {
934
+ let done = { satisfied: false, shown: [] };
935
+ try {
936
+ done = await goalSatisfied(await this.browser.getPage(), { preconditions: pinned.preconditions, goal: tail.goal }, bound.params);
937
+ }
938
+ catch {
939
+ /* browser gone or capture failed — fall through to the normal path */
940
+ }
941
+ if (done.satisfied) {
942
+ const idTexts = (pinned.preconditions.requireText ?? []).map((m) => fillParams(m, bound.params));
943
+ const values = {};
944
+ for (const [k, v] of Object.entries(tail.reportTemplate?.values ?? {})) {
945
+ const filled = fillParams(v, bound.params);
946
+ if (!/\{\{/.test(filled))
947
+ values[k] = filled;
948
+ }
949
+ const shown = done.shown.map((s) => JSON.stringify(s)).join(', ');
950
+ opts.progress(`[flow ${flow.name}] ${step.id}: already satisfied — page shows ${shown} for ${idTexts.join(', ')}; nothing to do`);
951
+ // Bank outputs exactly as a replayed step does, so a later step's
952
+ // {{step.output}} reference threads through a step that ran nothing.
953
+ const stepOutputs = { ...values };
954
+ try {
955
+ const urlOuts = await captureUrlOutputs(await this.browser.getPage(), wantedUrlOuts.get(step.id), step.id);
956
+ for (const [key, value] of Object.entries(urlOuts))
957
+ if (!(key in stepOutputs))
958
+ stepOutputs[key] = value;
959
+ }
960
+ catch {
961
+ /* browser gone — nothing to bind */
962
+ }
963
+ outputs[step.id] = stepOutputs;
964
+ stepResults.push({
965
+ id: step.id,
966
+ status: 'success',
967
+ summary: `already satisfied: page shows ${done.shown.join(', ')}`,
968
+ values,
969
+ tier: 'A',
970
+ satisfied: true,
971
+ replayed: null,
972
+ repaired: false,
973
+ turns: 0,
974
+ recovered: false,
975
+ });
976
+ // No learning call, no re-pin, no drift ticket: nothing happened, so
977
+ // there is nothing to learn from and no evidence about the pin.
978
+ continue;
979
+ }
980
+ }
981
+ const mark = this.browser.script?.mark() ?? 0;
982
+ // Zero-model first: replay the step's pinned skill directly, binding its
983
+ // params from the flow's stored bindings (robust to reworded steps)
984
+ // rather than re-deriving them from the instruction text.
985
+ // A throw here (the browser died mid-replay) must not discard the steps
986
+ // that DID complete: it becomes a fallback reason, and the recovery's
987
+ // own guard below turns a dead browser into a halted flowrun.
988
+ // `Daemon['replayDirect']`, not `typeof this.replayDirect`: a type query
989
+ // on `this` inside a loop body that can `continue` (the already-satisfied
990
+ // path below) stops resolving.
991
+ const direct = step.skill && !unresolved
992
+ ? await this.replayDirect(text, screenshotDir, opts.signal, opts.progress, { id: step.skill, params: bound?.params }).catch((err) => ({
993
+ why: `replay threw before completing: ${(err instanceof Error ? err.message : String(err)).slice(0, 200)}`,
994
+ }))
995
+ : {};
996
+ let result;
997
+ let recovered = false;
998
+ // Why this step could not run without the model, in the step result and
999
+ // the drift ticket. Without it every fallback looks the same from the
1000
+ // outside and the cause has to be guessed from the store.
1001
+ const fellBack = unresolved
1002
+ ? `unresolved reference(s): ${[...missing, ...(bound?.missing ?? [])].join(', ')}`
1003
+ : !step.skill
1004
+ ? 'the flow step has no pinned skill'
1005
+ : (direct.why ?? 'no reason recorded');
1006
+ if (direct.done) {
1007
+ result = direct.done;
1008
+ }
1009
+ else {
1010
+ opts.progress(`[flow ${flow.name}] ${step.id}: falling back to the model — ${fellBack}`);
1011
+ // All recovery causes run cheap-first with the strong model as
1012
+ // escalation-on-blocked (see recoveryRoute): the fwrd4l sweep showed
1013
+ // the session model rescuing replay-failed steps too, at a fraction
1014
+ // of the strong tier's rate; the cause label still names why.
1015
+ recovered = true;
1016
+ // Wrong record, not wrong procedure: every skill refused because the
1017
+ // open page belongs to a different record of the same template. A
1018
+ // model handed that page repairs the step where it stands — fwrd8-n2
1019
+ // added both parts, edited and archived a SEED ticket and reported
1020
+ // success. Put the browser back on the flow's start page so recovery
1021
+ // has to navigate to the record the instruction names.
1022
+ let resetNote = '';
1023
+ if (direct.wrongRecord) {
1024
+ opts.progress(`[flow ${flow.name}] ${step.id}: ${direct.wrongRecord}`);
1025
+ try {
1026
+ const page = await this.browser.getPage();
1027
+ await page.goto(flow.startUrl);
1028
+ resetNote = `\n\n[replay] The browser was showing a different record than this step needs (${direct.wrongRecord}). It has been returned to ${flow.startUrl} — navigate to the record this instruction names before doing anything else.`;
1029
+ }
1030
+ catch {
1031
+ resetNote = `\n\n[replay] The browser is showing a different record than this step needs (${direct.wrongRecord}). Navigate to the record this instruction names before doing anything else.`;
1032
+ }
1033
+ }
1034
+ // A soft-resolved instruction has BLANKS where its references were.
1035
+ // fwkb1-n3 is what an unguarded blank costs: "move the task into the
1036
+ // '' column" left the destination to the model's imagination, it
1037
+ // picked "Ready", moved the card there, verified ITS OWN choice and
1038
+ // reported success — a wrong outcome delivered confidently. Guessing
1039
+ // a detail (which button opens a form) is recovery working; guessing
1040
+ // the GOAL is not.
1041
+ const blankNote = unresolved
1042
+ ? `\n\n[replay] One or more details in this instruction could not be resolved and appear blank or missing. ` +
1043
+ `Work them out from the page when the goal itself is clear — but if a blank leaves the goal ambiguous ` +
1044
+ `(a destination, a target record, a value to set), STOP and report blocked instead of guessing.`
1045
+ : '';
1046
+ const route = recoveryRoute(step, unresolved);
1047
+ const primary = route.easy ? opts.provider : opts.recovery;
1048
+ const escalation = route.easy && opts.recovery.model !== opts.provider.model ? opts.recovery : null;
1049
+ opts.progress(`[flow ${flow.name}] ${step.id}: ${route.cause} — recovering on ${primary.model}${escalation ? ` (escalates to ${escalation.model})` : ''}`);
1050
+ // An adopted step is known-hard: at record time it exhausted one full
1051
+ // budget on the cheap model AND one on the escalation. One budget will
1052
+ // not do it now either, so double it rather than replay the recorded
1053
+ // stall.
1054
+ const budget = step.adopted
1055
+ ? { maxTurns: opts.maxTurns * 2, timeoutMs: opts.timeoutMs * 2 }
1056
+ : { maxTurns: opts.maxTurns, timeoutMs: opts.timeoutMs };
1057
+ try {
1058
+ result = await runEscalatingInstruction(primary, escalation, this.browser, this.state, (direct.prelude ? `${recoveryText}
1059
+
1060
+ ${direct.prelude}` : recoveryText) + blankNote + resetNote, {
1061
+ maxTurns: budget.maxTurns,
1062
+ timeoutMs: budget.timeoutMs,
1063
+ ...(opts.turnTimeoutMs ? { turnTimeoutMs: opts.turnTimeoutMs } : {}),
1064
+ screenshotDir,
1065
+ signal: opts.signal,
1066
+ onProgress: opts.progress,
1067
+ // A part-way replay opened this step's recording group; the
1068
+ // recovery continues it rather than opening a second one.
1069
+ ...(direct.prelude ? { recordAs: { text, resume: true } } : {}),
1070
+ });
1071
+ }
1072
+ catch (err) {
1073
+ // A hard infrastructure failure (LLM retries exhausted, browser
1074
+ // gone) must not throw away the whole flowrun: the runs that DID
1075
+ // complete, the priced usage, and the halt point are the result.
1076
+ // fwgr2-n2/n3 died this way on an OpenRouter 429 and left nothing.
1077
+ const message = err instanceof Error ? err.message : String(err);
1078
+ stepResults.push({ id: step.id, status: 'blocked', summary: `recovery failed before completing: ${message.slice(0, 300)}`, values: {}, tier: null, replayed: null, repaired: false, turns: 0, recovered: true, fellBack });
1079
+ halted = true;
1080
+ break;
1081
+ }
1082
+ if (direct.partial && result.skill)
1083
+ result.skill = { ...result.skill, ...direct.partial, listed: result.skill.listed };
1084
+ }
1085
+ // Learn from a repair so the flow's steps get cheaper over successive runs.
1086
+ let repinned;
1087
+ let repinParams;
1088
+ if (this.browser.learn) {
1089
+ const recoveryEntries = this.browser.script?.entriesSince(mark) ?? [];
1090
+ const learned = learnFromInstruction(this.browser.learn, {
1091
+ result,
1092
+ // Never hand compile an instruction with unresolved {{ref}} markers:
1093
+ // they leak verbatim into the skill template (s_166633 carried a
1094
+ // literal "{{01-open.ticket_ref}}"), which no live instruction can
1095
+ // ever match. The soft-resolved text is what actually drove the run.
1096
+ instruction: unresolved ? recoveryText : text,
1097
+ entries: recoveryEntries,
1098
+ session: this.opts.session,
1099
+ model: opts.provider.model,
1100
+ // Slot-by-policy inputs: this run's declared vars plus every url
1101
+ // provenance value minted so far, so a skill compiled from a repair
1102
+ // is generic across runs instead of baking in this run's ids.
1103
+ vars: { ...varsIn, ...provenanceValues(outputs), ...referencedValues(step, outputs) },
1104
+ });
1105
+ // Whether the pin moves is decideRepin's call (see it for the
1106
+ // lifecycle and graduation rules). The pin is a hint, not an
1107
+ // authority: selection each run is by track record
1108
+ // (selectCandidates), so an unhealthy pin costs one refused/failed
1109
+ // attempt, not the step.
1110
+ const outcome = learned?.outcome;
1111
+ // Ownership gate (fwrd14l-n2: two steps re-pinned onto step 07's
1112
+ // READ-ONLY skill, replayed its read chain three times, mutated
1113
+ // nothing and reported success). Pending re-pins count as owned: the
1114
+ // write-back happens after the loop, so on the flow object alone this
1115
+ // run's own adoptions are invisible — fwrd16-n3 re-pinned 02-create
1116
+ // AND 10-open onto the same s_738ec0 in one pass.
1117
+ const owned = flow.steps.map((st) => ({ id: st.id, skill: pendingPins.get(st.id) ?? st.skill }));
1118
+ const adoptable = Boolean(outcome && canAdoptPin(this.browser.learn, owned, step.id, step.skill, outcome.skill, mutatingIntent(step.instruction) ? 'mutating' : 'read-only'));
1119
+ // A candidate whose navigation targets carry an identifier THIS
1120
+ // step's recovery minted (a url part first banked under this
1121
+ // instruction) would replay onto this run's record. An identifier
1122
+ // banked by an EARLIER instruction is left alone: fwod19's odoo menu
1123
+ // id looked minted and was an app constant.
1124
+ const candidate = outcome?.ok && outcome.skill ? (this.browser.learn?.get(outcome.skill) ?? null) : null;
1125
+ const mintedLeaks = candidate
1126
+ ? [
1127
+ ...new Set(scanForLeaks(candidate, this.ledger, outcome.skill)
1128
+ .filter((l) => /args\.url/.test(l.where) && l.binding.from === 'url' && l.binding.step === `i${this.instructionIndex}`)
1129
+ .map((l) => l.value)),
1130
+ ]
1131
+ : [];
1132
+ const decision = decideRepin({
1133
+ step,
1134
+ reportStatus: result.report.status,
1135
+ outcome,
1136
+ stray: agentGesturesOutsideReplay(recoveryEntries),
1137
+ adoptable,
1138
+ mintedLeaks,
1139
+ });
1140
+ if (decision && 'refused' in decision) {
1141
+ opts.progress(`[flow ${flow.name}] ${step.id}: ${decision.refused}`);
1142
+ }
1143
+ else if (decision && candidate) {
1144
+ // The new skill's slots are named and numbered its own way: bind
1145
+ // them by ORIGIN (see remapParams). A record-identifying slot with
1146
+ // no origin would replay as the learning run's literal — rpat2
1147
+ // named a live run's items after an earlier run that way — so
1148
+ // such a re-pin is refused and the incumbent keeps the step.
1149
+ // A skill another step already pins (a shared read-only check)
1150
+ // carries that step's flow bindings: inherit them for slots the
1151
+ // store recorded no origin for.
1152
+ const sibling = flow.steps.find((st) => st.id !== step.id && st.skill === candidate.id && st.params);
1153
+ const remap = remapParams(candidate, sibling?.params ?? {});
1154
+ if (remap.unbound.length) {
1155
+ opts.progress(`[flow ${flow.name}] ${step.id}: not re-pinning ${candidate.id} — slot(s) ${remap.unbound.join(', ')} identify the record but carry no origin to rebind from`);
1156
+ }
1157
+ else {
1158
+ repinned = decision.skill;
1159
+ repinParams = remap.params;
1160
+ pendingPins.set(step.id, decision.skill);
1161
+ if (decision.graduated) {
1162
+ graduated.add(step.id);
1163
+ opts.progress(`[flow ${flow.name}] ${step.id}: adopted step graduated — pinned ${decision.skill} (${outcome?.status}), shedding model-first replay`);
1164
+ }
1165
+ }
1166
+ }
1167
+ }
1168
+ usage.promptTokens += result.usage.promptTokens;
1169
+ usage.completionTokens += result.usage.completionTokens;
1170
+ usage.cachedTokens += result.usage.cachedTokens;
1171
+ const values = {};
1172
+ for (const [k, v] of Object.entries(result.report.evidence?.values ?? {}))
1173
+ values[k] = String(v);
1174
+ // A recovery's model names its read-backs freely (ticketRef vs
1175
+ // ticket_ref vs ticket-id); later steps reference the names recorded at
1176
+ // capture time. Alias each expected output that is missing but present
1177
+ // under a cosmetically different key, so one cosmetic rename cannot
1178
+ // cascade every later step into recovery (the flow6 failure mode).
1179
+ if (recovered) {
1180
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
1181
+ for (const want of step.outputs) {
1182
+ if (want in values)
1183
+ continue;
1184
+ const hits = Object.keys(values).filter((k) => norm(k) === norm(want));
1185
+ if (hits.length === 1)
1186
+ values[want] = values[hits[0]];
1187
+ }
1188
+ }
1189
+ // Mechanism 1 at the flow level (PLAN-replay-v2): the step's end-url
1190
+ // parts are outputs too, so a later step whose recorded value was
1191
+ // minted here (a dashboard uid in the post-create url) binds to THIS
1192
+ // run's value. Kept out of the step's reported values in the flow
1193
+ // result — they are addresses, not findings.
1194
+ const stepOutputs = { ...values };
1195
+ try {
1196
+ // A model-driven end state has no reason to carry the recorded url
1197
+ // shape, so a recovered step does not wait for the consumed parts.
1198
+ const urlOuts = await captureUrlOutputs(await this.browser.getPage(), recovered ? undefined : wantedUrlOuts.get(step.id), step.id);
1199
+ for (const [key, value] of Object.entries(urlOuts)) {
1200
+ if (!(key in stepOutputs))
1201
+ stepOutputs[key] = value;
1202
+ }
1203
+ }
1204
+ catch {
1205
+ /* browser gone — nothing to bind */
1206
+ }
1207
+ outputs[step.id] = stepOutputs;
1208
+ const sk = result.skill;
1209
+ // Drift telemetry: record, never repair inline. One ticket per primary-
1210
+ // locator miss, plus one for a recovery with no structured miss to blame.
1211
+ if (sk?.invoked) {
1212
+ const pageUrlPattern = sk.replayUrl ? compiledUrlPattern(sk.replayUrl) : undefined;
1213
+ // Both forms travel: the pattern is cross-run evidence, the concrete
1214
+ // url is the only thing a repair pass can navigate back to. See
1215
+ // DriftTicket.pageUrl — a pattern that generalised a run-minted id is
1216
+ // unfillable afterwards, which is what used to send three of fwrd42's
1217
+ // tickets to "needs re-record" when they were perfectly patchable.
1218
+ const pageUrl = sk.replayUrl;
1219
+ for (const m of sk.misses ?? []) {
1220
+ driftTickets.push({
1221
+ flow: flow.name, step: step.id, skill: m.skill ?? sk.invoked, atStep: m.step, key: m.key,
1222
+ similarity: sk.similarity, missedLocator: m.primary, fallbackUsed: m.used, ...(m.usedIndex !== undefined ? { fallbackIndex: m.usedIndex } : {}), recovered,
1223
+ ...(pageUrlPattern ? { pageUrlPattern } : {}),
1224
+ ...(pageUrl ? { pageUrl } : {}),
1225
+ });
1226
+ }
1227
+ if (recovered && !(sk.misses ?? []).length) {
1228
+ driftTickets.push({
1229
+ flow: flow.name, step: step.id, skill: sk.invoked, similarity: sk.similarity,
1230
+ missedLocator: null, fallbackUsed: null, recovered: true,
1231
+ fellBack,
1232
+ ...(sk.failReason ? { reason: sk.failReason } : {}),
1233
+ ...(pageUrlPattern ? { pageUrlPattern } : {}),
1234
+ ...(pageUrl ? { pageUrl } : {}),
1235
+ });
1236
+ }
1237
+ }
1238
+ // Cross-run evidence: did this run produce the same values here? That is
1239
+ // what decides whether a reference to one of them is a record pointer or
1240
+ // app furniture — the question run 1 could not answer. Only on success:
1241
+ // a blocked step's values describe how far it got, not what the app
1242
+ // shows.
1243
+ if (result.report.status === 'success') {
1244
+ const changed = noteOutputEvidence(step, values);
1245
+ if (changed.length) {
1246
+ evidenceChanged += changed.length;
1247
+ const verdict = (n) => {
1248
+ const ev = step.outputEvidence?.[n];
1249
+ return ev && ev.differed === 0 ? 'stable' : 'volatile';
1250
+ };
1251
+ opts.progress(`[flow ${flow.name}] ${step.id}: ${changed.map((n) => `${n}=${verdict(n)}`).join(', ')}`);
1252
+ }
1253
+ }
1254
+ stepResults.push({
1255
+ id: step.id,
1256
+ status: result.report.status,
1257
+ summary: result.report.summary,
1258
+ values,
1259
+ tier: sk?.tier ?? null,
1260
+ // Why the model was needed, on the STEP — a ticket is only filed when
1261
+ // a pinned skill was actually invoked, so a skill that refused before
1262
+ // replaying left no record of the cause anywhere (sp4od 06-open).
1263
+ recovered,
1264
+ ...(recovered ? { fellBack } : {}),
1265
+ replayed: sk?.invoked ? `${sk.stepsReplayed}/${sk.stepsTotal}` : null,
1266
+ repaired: Boolean(sk?.repaired),
1267
+ turns: result.turns,
1268
+ ...(repinned ? { repinned } : {}),
1269
+ ...(repinParams ? { repinParams } : {}),
1270
+ });
1271
+ if (result.report.status !== 'success') {
1272
+ // An adopted step does not halt the flow: the recording's own path
1273
+ // continued from this instruction's partial state (that continuation
1274
+ // is why it was adopted at all), so a replay that got as far as the
1275
+ // recording did is no worse off. If the work genuinely did not stick,
1276
+ // the NEXT step fails on its own terms and halts honestly.
1277
+ if (step.adopted) {
1278
+ opts.progress(`[flow ${flow.name}] ${step.id}: adopted step ended ${result.report.status} — continuing, as the recording's own path did`);
1279
+ }
1280
+ else {
1281
+ halted = true;
1282
+ break;
1283
+ }
1284
+ }
1285
+ // A recovery may have left a dialog open; sweep it at the next boundary.
1286
+ prevRecovered = recovered;
1287
+ }
1288
+ // Re-pin any repaired steps so the flow file itself gets cheaper next run.
1289
+ let updated = 0;
1290
+ for (const r of stepResults) {
1291
+ if (r.repinned) {
1292
+ const step = flow.steps.find((st) => st.id === r.id);
1293
+ if (step) {
1294
+ step.skill = String(r.repinned);
1295
+ if (r.repinParams)
1296
+ step.params = r.repinParams;
1297
+ // A graduated adopted step is no longer model-first: it now owns a
1298
+ // skill that completed it, so drop the flag that gave it the doubled
1299
+ // recovery budget and the 'adopted' recovery route.
1300
+ if (graduated.has(String(r.id)) && step.adopted)
1301
+ delete step.adopted;
1302
+ updated++;
1303
+ }
1304
+ }
1305
+ }
1306
+ // Evidence is written back even when nothing was re-pinned: it is the
1307
+ // whole point of this run for a flow whose references cannot resolve yet.
1308
+ if (updated || evidenceChanged)
1309
+ saveFlow(flow, flowFile);
1310
+ const passed = stepResults.filter((r) => r.status === 'success').length;
1311
+ return {
1312
+ flow: flow.name,
1313
+ status: halted && passed < flow.steps.length ? 'halted' : 'success',
1314
+ steps: stepResults,
1315
+ passed,
1316
+ total: flow.steps.length,
1317
+ repinned: updated,
1318
+ drift: driftTickets.length,
1319
+ ...(driftTickets.length ? { driftTickets } : {}),
1320
+ wallMs: Date.now() - started,
1321
+ model: opts.provider.model,
1322
+ // What the replay actually cost. `usageByModel` is the accurate
1323
+ // per-model split (recordUsage buckets each instruction under the
1324
+ // provider that ran it, escalations under theirs); `usage` +
1325
+ // `recoveryModel` remain as the coarse fallback for older tooling,
1326
+ // priced at the dearest tier in play — an over-, never under-estimate.
1327
+ usage,
1328
+ usageByModel: diffUsageByModel(usageBefore, this.state.usageByModel),
1329
+ recoveryModel: opts.recovery.model,
1330
+ provider: this.provider().constructor.name === 'AnthropicProvider' ? 'anthropic' : (process.env.SITELOOPER_PROVIDER || 'zhipu'),
1331
+ };
1332
+ }
1333
+ /**
1334
+ * Tier A: try a validated, template-matching skill before the model is
1335
+ * involved at all. Returns a finished result when the replay completed, a
1336
+ * prelude for the agent when it stopped part-way, or nothing when no skill
1337
+ * matched (the common case, and free: one store read, no page round trip).
1338
+ */
1339
+ async replayDirect(instruction, screenshotDir, signal, progress,
1340
+ /** Flow replay pins the skill (and may supply its params); without it, fall back to a validated template match. */
1341
+ chosen) {
1342
+ const store = this.browser.learn;
1343
+ if (!store || !this.browser.isOpen)
1344
+ return { why: 'no skill store, or the browser is closed' };
1345
+ let url;
1346
+ try {
1347
+ url = (await this.browser.getPage()).url();
1348
+ }
1349
+ catch {
1350
+ return { why: 'could not read the page url' };
1351
+ }
1352
+ const origin = originOf(url);
1353
+ if (!origin)
1354
+ return { why: `no origin for ${url}` };
1355
+ // Candidates for this instruction, best track record first. In flow mode
1356
+ // the pinned skill is only a hint that names the procedure family —
1357
+ // selection is by the store's own lifecycle (validated > success rate >
1358
+ // experience), so a fragile pin cannot dominate the step run after run.
1359
+ let candidates;
1360
+ if (chosen) {
1361
+ candidates = selectCandidates(store.list(origin), chosen.id, instruction, chosen.params, this.knownValues());
1362
+ }
1363
+ else {
1364
+ const m = matchTemplate(store.list(origin), instruction, url, this.knownValues());
1365
+ candidates = m ? [m] : [];
1366
+ }
1367
+ if (!candidates.length)
1368
+ return { why: chosen ? `the pinned skill ${chosen.id} bound no params for this instruction` : 'no validated skill matched the instruction and page' };
1369
+ this.browser.script?.beginInstruction(instruction, { url });
1370
+ let match = null;
1371
+ let replay = null;
1372
+ let attempts = 0;
1373
+ let wrongRecord;
1374
+ const refusals = [];
1375
+ for (const cand of candidates) {
1376
+ if (attempts >= MAX_CANDIDATE_ATTEMPTS)
1377
+ break;
1378
+ progress(`[skill] trying ${cand.skill.id} (${cand.skill.status}, ${cand.skill.stats.successes}/${cand.skill.stats.uses}) without the model`);
1379
+ const execution = await executeTool(this.browser, 'run_skill', { id: cand.skill.id, params: cand.params }, screenshotDir, signal);
1380
+ const r = execution.replay;
1381
+ if (!r)
1382
+ return { why: `run_skill returned nothing for ${cand.skill.id}` };
1383
+ if (r.refused) {
1384
+ // Right template, wrong record: no other skill can fix that, so keep
1385
+ // the reason and let the caller re-establish the page (see below).
1386
+ if (r.wrongRecord)
1387
+ wrongRecord = r.wrongRecord;
1388
+ refusals.push(`${cand.skill.id}: ${r.reason ?? 'refused'}`);
1389
+ continue; // wrong page / bad params: nothing ran, free to try the next
1390
+ }
1391
+ attempts++;
1392
+ match = cand;
1393
+ replay = r;
1394
+ if (r.ok)
1395
+ break;
1396
+ if (r.stepsRun === 0 && !r.acted && !r.created.length) {
1397
+ // Failed before touching the page — safe to try the next candidate.
1398
+ // `stepsRun === 0` alone does NOT establish that: a step whose action
1399
+ // fires and whose expectation then fails stops without counting, so
1400
+ // the click already happened. Trying the next candidate then clicks
1401
+ // Create a second time, which is the shape of fwod13 finishing with
1402
+ // 2 and 3 orders where the task creates one.
1403
+ // Record the failure so the store's own lifecycle (two strikes →
1404
+ // demoted) drops a flaky skill out of selection.
1405
+ store.recordOutcome(cand.skill.id, { ok: false, failedAt: r.failedAt, fallthroughs: r.fallthroughs, instructionSucceeded: false });
1406
+ // Recorded, or the fallback reason reads "no candidate ran" and names
1407
+ // neither the skill nor the failure — which is exactly what fwrd28l's
1408
+ // one tier-B step reported, leaving nothing to diagnose it with.
1409
+ refusals.push(`${cand.skill.id}: failed at step ${r.failedAt ?? '?'} before touching the page — ${r.reason ?? 'no reason recorded'}`);
1410
+ match = null;
1411
+ replay = null;
1412
+ continue;
1413
+ }
1414
+ break; // partial: the page has changed — hand what ran to recovery, never restart another candidate
1415
+ }
1416
+ if (!match || !replay) {
1417
+ const why = refusals.length ? `every candidate refused — ${refusals.join('; ')}` : 'no candidate ran';
1418
+ return wrongRecord ? { wrongRecord, why } : { why };
1419
+ }
1420
+ // Walk the segment chain: a multi-segment skill replays segment by
1421
+ // segment, each gated by its own precondition. A cleanly-replayed
1422
+ // segment's outcome is recorded on ITS skill immediately (its own success
1423
+ // regardless of what later segments do — that independence is the point
1424
+ // of segmentation); the LAST replay, clean or not, is left to the
1425
+ // instruction-level learning so it is not double-counted. On a mid-chain
1426
+ // stop, recovery inherits only the failed segment's blame.
1427
+ const agg = {
1428
+ stepsRun: replay.stepsRun,
1429
+ stepsTotal: replay.stepsTotal,
1430
+ fallthroughs: replay.fallthroughs,
1431
+ misses: replay.misses.map((m) => ({ ...m, skill: replay.skill })),
1432
+ evidence: replay.candidateEvidence.map((e) => ({ ...e, skill: match.skill.id })),
1433
+ values: { ...replay.values },
1434
+ echoed: [...replay.echoedValues],
1435
+ segmentsDone: 0,
1436
+ };
1437
+ // Values the replay itself minted ({{dN}}): bound in the segment that
1438
+ // minted them, threaded into every later segment's params so a later
1439
+ // precondition/locator references THIS run's identifier.
1440
+ const derived = { ...replay.derivedValues };
1441
+ let current = match.skill;
1442
+ let last = match.skill; // whose replay `replay` currently holds
1443
+ while (replay.ok && current.seq && current.seq.index < current.seq.of - 1) {
1444
+ const next = store.list(origin).find((s) => s.seq?.chain === current.seq.chain && s.seq?.index === current.seq.index + 1);
1445
+ if (!next) {
1446
+ replay = { ...replay, ok: false, reason: `segment ${current.seq.index + 2}/${current.seq.of} of this procedure chain is missing from the store` };
1447
+ break;
1448
+ }
1449
+ // The just-finished segment succeeded on its own terms — record it now.
1450
+ // Not the head: instruction-level learning records the head (as
1451
+ // record.invoked) when the chain succeeds, and the block after the walk
1452
+ // does when it fails — recording it here too doubled its stats.
1453
+ if (last.id !== match.skill.id)
1454
+ store.recordOutcome(last.id, { ok: true, fallthroughs: replay.fallthroughs, instructionSucceeded: true });
1455
+ agg.segmentsDone++;
1456
+ progress(`[skill] chain ${current.seq.chain}: segment ${next.seq.index + 1}/${next.seq.of} → ${next.id}`);
1457
+ const nextExec = await executeTool(this.browser, 'run_skill', { id: next.id, params: { ...match.params, ...derived } }, screenshotDir, signal);
1458
+ const r = nextExec.replay;
1459
+ if (!r)
1460
+ return {};
1461
+ Object.assign(derived, r.derivedValues ?? {});
1462
+ // A chain's earlier segment may have created the record the later one
1463
+ // stops on; recovery needs the whole chain's creations, not the last
1464
+ // segment's. (Reassigning `replay` first and then pushing r.created into
1465
+ // it doubled this segment's and lost the earlier ones.)
1466
+ const createdSoFar = replay.created;
1467
+ replay = r;
1468
+ replay.created = [...createdSoFar, ...r.created];
1469
+ last = next;
1470
+ current = next;
1471
+ agg.stepsRun += r.stepsRun;
1472
+ agg.stepsTotal += r.stepsTotal;
1473
+ agg.fallthroughs += r.fallthroughs;
1474
+ agg.misses.push(...r.misses.map((m) => ({ ...m, skill: next.id })));
1475
+ agg.evidence.push(...r.candidateEvidence.map((e) => ({ ...e, skill: next.id })));
1476
+ Object.assign(agg.values, r.values);
1477
+ agg.echoed.push(...r.echoedValues);
1478
+ }
1479
+ // The walk records each segment when it advances PAST it, and the
1480
+ // instruction-level learning records the head (record.invoked). A chain's
1481
+ // FINAL segment is neither — record it here, or it could never validate.
1482
+ if (replay.ok && last.id !== match.skill.id) {
1483
+ store.recordOutcome(last.id, { ok: true, fallthroughs: replay.fallthroughs, instructionSucceeded: true });
1484
+ }
1485
+ // A chain that advanced past its head and then stopped: the head's clean
1486
+ // replay is real evidence, and record.invoked now names the failing
1487
+ // segment, so nothing else will bank it.
1488
+ if (!replay.ok && agg.segmentsDone > 0) {
1489
+ store.recordOutcome(match.skill.id, { ok: true, fallthroughs: 0, instructionSucceeded: true });
1490
+ }
1491
+ // Per-candidate evidence, folded on ONLY when the run got past these steps
1492
+ // — the same rule the url generalisations follow. A miss inside a run that
1493
+ // then failed says more about the run than about the locator. This is what
1494
+ // decides whether a recorded id is a real handle or an ephemeral one:
1495
+ // observation across runs, not the shape of the token.
1496
+ if (replay.ok) {
1497
+ const bySkill = new Map();
1498
+ for (const e of agg.evidence) {
1499
+ if (!e.skill)
1500
+ continue;
1501
+ bySkill.set(e.skill, [...(bySkill.get(e.skill) ?? []), e]);
1502
+ }
1503
+ for (const [id, list] of bySkill)
1504
+ recordCandidateEvidence(store, id, list);
1505
+ }
1506
+ const record = {
1507
+ // On success the chain head answers for the whole run; on a stop, the
1508
+ // segment that stopped does, so demotion and variants attach to it.
1509
+ invoked: replay.ok ? match.skill.id : last.id,
1510
+ stepsReplayed: replay.ok ? agg.stepsRun : replay.stepsRun,
1511
+ stepsTotal: replay.ok ? agg.stepsTotal : replay.stepsTotal,
1512
+ refused: Boolean(replay.refused),
1513
+ fallthroughs: agg.fallthroughs,
1514
+ similarity: replay.similarity,
1515
+ ...(agg.misses.length ? { misses: agg.misses } : {}),
1516
+ ...(replay.reason ? { failReason: replay.reason } : {}),
1517
+ ...(replay.failedAt !== undefined ? { failedAt: replay.failedAt } : {}),
1518
+ replayUrl: replay.url,
1519
+ deterministicActions: agg.stepsRun,
1520
+ totalActions: agg.stepsRun,
1521
+ tier: 'A',
1522
+ };
1523
+ if (!replay.ok) {
1524
+ const ranNote = agg.segmentsDone
1525
+ ? `[replay] ${agg.segmentsDone} earlier segment(s) of this procedure chain replayed cleanly and HAVE changed the page. Then a stored segment stopped part-way. Its output:\n`
1526
+ : `[replay] A stored procedure was replayed before you started and stopped part-way. Its output:\n`;
1527
+ return {
1528
+ prelude: ranNote + renderReplay(last, replay),
1529
+ partial: record,
1530
+ why: `${last.id} stopped at step ${replay.failedAt ?? '?'} — ${replay.reason ?? 'no reason recorded'}`,
1531
+ };
1532
+ }
1533
+ // Drop echo reads from the report's confident values: a value the skill
1534
+ // only re-read from a control it set itself is not proof the app persisted
1535
+ // it (grafana's time picker — see ReplayResult.echoedValues). A later step
1536
+ // that genuinely needs the value still routes to recovery rather than
1537
+ // trusting an echo, and the flow stops reporting a persist it cannot vouch
1538
+ // for. A value re-observed by a NON-echo read in another segment survives.
1539
+ const confidentValues = { ...agg.values };
1540
+ for (const key of agg.echoed) {
1541
+ if (!Object.keys(agg.values).includes(key))
1542
+ continue;
1543
+ // Keep it only if some segment read it back WITHOUT it being an echo
1544
+ // there — i.e. it appears in values but the echoed list is not the whole
1545
+ // story. Simplest sound rule: echoed anywhere ⇒ not confident.
1546
+ delete confidentValues[key];
1547
+ }
1548
+ if (agg.echoed.length)
1549
+ progress(`[replay] dropped ${agg.echoed.length} echo read(s) from confident values: ${[...new Set(agg.echoed)].join(', ')}`);
1550
+ const report = synthesizeReport(last, match.params, confidentValues);
1551
+ // Keep the conversation coherent for later instructions: the same one-line
1552
+ // entry the loop would have written.
1553
+ this.state.messages.push({ role: 'user', content: instruction });
1554
+ const facts = Object.entries(report.evidence?.values ?? {})
1555
+ .map(([k, v]) => `${k}=${v}`)
1556
+ .join(', ');
1557
+ this.state.messages.push({ role: 'assistant', content: `[report] success: ${report.summary}${facts ? ' | ' + facts : ''}` });
1558
+ // Close the recording's instruction group the way the loop's finish()
1559
+ // does. Without it a zero-model step had steps but no report, the flow
1560
+ // export dropped it, and the replayed sign-in went missing from the flow.
1561
+ this.browser.script?.endInstruction({
1562
+ status: 'success',
1563
+ summary: report.summary,
1564
+ values: Object.fromEntries(Object.entries(report.evidence?.values ?? {}).map(([k, v]) => [k, String(v)])),
1565
+ skill: match.skill.id,
1566
+ tier: 'A',
1567
+ });
1568
+ return {
1569
+ done: {
1570
+ report,
1571
+ turns: 0,
1572
+ usage: { promptTokens: 0, completionTokens: 0, cachedTokens: 0 },
1573
+ screenshots: [],
1574
+ skill: { listed: [match.skill.id], repaired: false, ...record },
1575
+ },
1576
+ };
1577
+ }
1578
+ async shutdown() {
1579
+ await this.browser.close();
1580
+ this.server?.close();
1581
+ // give the result frame time to flush before exiting
1582
+ await new Promise((r) => setTimeout(r, 150));
1583
+ process.exit(0);
1584
+ }
1585
+ }
1586
+ /**
1587
+ * Between-step hygiene: at a step boundary the page should be at rest, so a
1588
+ * still-open modal is debris from the previous step's recovery. Escape it
1589
+ * (bounded) before the next step's own skill replay, so the debris is not
1590
+ * charged to that step. Uses the ARIA-standard modal signal, never an app
1591
+ * selector; in-skill dialogs are unaffected because this runs only at the
1592
+ * boundary. Returns false when a dialog would not go.
1593
+ */
1594
+ async function dismissBlockingDialogs(page) {
1595
+ const blockers = page.locator('[role="dialog"], [aria-modal="true"]');
1596
+ const blocking = async () => (await blockers.count()) > 0 && (await blockers.first().isVisible().catch(() => false));
1597
+ for (let i = 0; i < 3 && (await blocking()); i++) {
1598
+ await page.keyboard.press('Escape');
1599
+ await page.waitForTimeout(250);
1600
+ }
1601
+ return !(await blocking());
1602
+ }
1603
+ /** How long a step's end-url capture waits for a consumed url output to appear. */
1604
+ const URL_OUTPUT_WAIT_MS = 5_000;
1605
+ /**
1606
+ * The outputs a step's end url publishes, under the same rule buildFlow used
1607
+ * to mint the references — see urlOutputs. When a later step is known to
1608
+ * consume one of them (`wanted`), wait (bounded) for the URL to actually
1609
+ * carry it: an SPA can update its URL a beat after the page settles, and a
1610
+ * structural replay finishes inside that beat — fwod30 lost
1611
+ * {{03-open.url.q.id}} to a snapshot taken before Odoo's hash gained the
1612
+ * freshly minted id.
1613
+ */
1614
+ async function captureUrlOutputs(page, wanted, stepId) {
1615
+ let urlOuts = urlOutputs(page.url());
1616
+ if (!wanted?.size)
1617
+ return urlOuts;
1618
+ const deadline = Date.now() + URL_OUTPUT_WAIT_MS;
1619
+ while ([...wanted].some((k) => !(k in urlOuts)) && Date.now() < deadline) {
1620
+ await new Promise((r) => setTimeout(r, 250));
1621
+ urlOuts = urlOutputs(page.url());
1622
+ }
1623
+ const missing = [...wanted].filter((k) => !(k in urlOuts));
1624
+ if (missing.length)
1625
+ console.error(`[flow] ${stepId}: url output(s) never appeared: ${missing.join(', ')} (url: ${page.url().slice(0, 160)})`);
1626
+ return urlOuts;
1627
+ }
1628
+ function listFlowsSummary() {
1629
+ return listFlows().map((f) => ({ name: f.name, origin: f.origin, steps: f.steps.length, vars: f.vars, created: f.provenance.created }));
1630
+ }
1631
+ /** Per-model token delta between two snapshots of the session's usage ledger, models with no activity omitted. */
1632
+ /**
1633
+ * The url-provenance values earlier flow steps published this run
1634
+ * ({{sid.url.*}} parts — minted ids like a dashboard uid). Fed to compile as
1635
+ * slot-by-policy values so a repair's skill parameterises them instead of
1636
+ * baking this run's id into its template and steps.
1637
+ */
1638
+ function provenanceValues(outputs) {
1639
+ const out = {};
1640
+ for (const [sid, vals] of Object.entries(outputs)) {
1641
+ for (const [name, value] of Object.entries(vals)) {
1642
+ if (name.startsWith('url.') && value)
1643
+ out[`${sid}.${name}`] = value;
1644
+ }
1645
+ }
1646
+ return out;
1647
+ }
1648
+ /**
1649
+ * The values behind the {{step.output}} references this flow step's
1650
+ * instruction/params carry (a ticket ref, a minted uid) — run-scoped by
1651
+ * definition, so compile slots them by policy too.
1652
+ */
1653
+ function referencedValues(step, outputs) {
1654
+ const out = {};
1655
+ for (const text of [step.instruction, ...Object.values(step.params ?? {})]) {
1656
+ for (const m of text.matchAll(/\{\{([\w-]+)\.([\w.#-]+)\}\}/g)) {
1657
+ const v = lookupOutput(outputs, m[1], m[2]);
1658
+ if (v)
1659
+ out[`${m[1]}.${m[2]}`] = v;
1660
+ }
1661
+ }
1662
+ return out;
1663
+ }
1664
+ function diffUsageByModel(before, after) {
1665
+ const out = {};
1666
+ for (const [model, u] of Object.entries(after)) {
1667
+ const b = before[model];
1668
+ const d = {
1669
+ promptTokens: u.promptTokens - (b?.promptTokens ?? 0),
1670
+ completionTokens: u.completionTokens - (b?.completionTokens ?? 0),
1671
+ cachedTokens: u.cachedTokens - (b?.cachedTokens ?? 0),
1672
+ };
1673
+ if (d.promptTokens || d.completionTokens || d.cachedTokens)
1674
+ out[model] = d;
1675
+ }
1676
+ return out;
1677
+ }
1678
+ function describeLearned(l) {
1679
+ if (!l)
1680
+ return 'nothing';
1681
+ const parts = [];
1682
+ if (l.outcome)
1683
+ parts.push(`${l.outcome.skill} ${l.outcome.ok ? 'replayed ok' : 'replay stopped part-way'} → ${l.outcome.status}`);
1684
+ if (l.compiled)
1685
+ parts.push(`stored ${l.compiled}${l.variantOf ? ` as a variant of ${l.variantOf}` : ''}`);
1686
+ if (l.merged)
1687
+ parts.push(`merged into ${l.merged}`);
1688
+ if (l.superseded)
1689
+ parts.push(`${l.superseded} superseded`);
1690
+ return parts.join('; ') || 'nothing';
1691
+ }
1692
+ function delay(ms) {
1693
+ return new Promise((r) => setTimeout(r, ms));
1694
+ }
1695
+ /** Anthropic speaks its own wire format; everything else is OpenAI-compatible. */
1696
+ function build(config) {
1697
+ return config.provider === 'anthropic' ? new AnthropicProvider(config) : new OpenAICompatProvider(config);
1698
+ }
1699
+ // --- entrypoint: node dist/daemon/server.js --session <name> [--headed] ---
1700
+ const isMain = process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1]));
1701
+ if (isMain) {
1702
+ aliasLegacyEnv(); // honor legacy BROWSER_PILOT_* env vars (also inherited from the CLI) — see paths.ts
1703
+ const argv = process.argv.slice(2);
1704
+ const sessionIdx = argv.indexOf('--session');
1705
+ const session = validateSessionName(sessionIdx >= 0 ? argv[sessionIdx + 1] : 'default');
1706
+ const daemon = new Daemon({
1707
+ session,
1708
+ headed: argv.includes('--headed'),
1709
+ record: argv.includes('--record'),
1710
+ script: argv.includes('--script'),
1711
+ learn: argv.includes('--learn'),
1712
+ });
1713
+ daemon
1714
+ .listen()
1715
+ .then(() => {
1716
+ // parent (CLI) reads this line to know the pipe is ready when not detached
1717
+ process.stdout.write(`sitelooper daemon listening (session=${session}, pid=${process.pid})\n`);
1718
+ })
1719
+ .catch((err) => {
1720
+ process.stderr.write(`daemon failed to start: ${err?.message || err}\n`);
1721
+ process.exit(2);
1722
+ });
1723
+ }
1724
+ //# sourceMappingURL=server.js.map