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.
- package/LICENSE +21 -0
- package/README.md +625 -0
- package/bin/sitelooper.js +6 -0
- package/dist/agent/llm.js +460 -0
- package/dist/agent/llm.js.map +1 -0
- package/dist/agent/loop.js +870 -0
- package/dist/agent/loop.js.map +1 -0
- package/dist/agent/prompt.js +40 -0
- package/dist/agent/prompt.js.map +1 -0
- package/dist/agent/report.js +545 -0
- package/dist/agent/report.js.map +1 -0
- package/dist/agent/tools.js +1147 -0
- package/dist/agent/tools.js.map +1 -0
- package/dist/cli.js +1692 -0
- package/dist/cli.js.map +1 -0
- package/dist/daemon/browser.js +218 -0
- package/dist/daemon/browser.js.map +1 -0
- package/dist/daemon/codegen.js +241 -0
- package/dist/daemon/codegen.js.map +1 -0
- package/dist/daemon/dialogs.js +57 -0
- package/dist/daemon/dialogs.js.map +1 -0
- package/dist/daemon/diff.js +198 -0
- package/dist/daemon/diff.js.map +1 -0
- package/dist/daemon/fingerprint.js +98 -0
- package/dist/daemon/fingerprint.js.map +1 -0
- package/dist/daemon/inputs.js +134 -0
- package/dist/daemon/inputs.js.map +1 -0
- package/dist/daemon/recorder.js +1232 -0
- package/dist/daemon/recorder.js.map +1 -0
- package/dist/daemon/refs.js +194 -0
- package/dist/daemon/refs.js.map +1 -0
- package/dist/daemon/server.js +1724 -0
- package/dist/daemon/server.js.map +1 -0
- package/dist/daemon/state.js +239 -0
- package/dist/daemon/state.js.map +1 -0
- package/dist/doctor.js +90 -0
- package/dist/doctor.js.map +1 -0
- package/dist/shared/paths.js +80 -0
- package/dist/shared/paths.js.map +1 -0
- package/dist/shared/protocol.js +28 -0
- package/dist/shared/protocol.js.map +1 -0
- package/dist/shared/secrets.js +92 -0
- package/dist/shared/secrets.js.map +1 -0
- package/dist/shared/text.js +39 -0
- package/dist/shared/text.js.map +1 -0
- package/dist/skills/compile.js +1420 -0
- package/dist/skills/compile.js.map +1 -0
- package/dist/skills/components.js +456 -0
- package/dist/skills/components.js.map +1 -0
- package/dist/skills/flow.js +1041 -0
- package/dist/skills/flow.js.map +1 -0
- package/dist/skills/learn.js +406 -0
- package/dist/skills/learn.js.map +1 -0
- package/dist/skills/ledger.js +304 -0
- package/dist/skills/ledger.js.map +1 -0
- package/dist/skills/relabel.js +206 -0
- package/dist/skills/relabel.js.map +1 -0
- package/dist/skills/repair.js +570 -0
- package/dist/skills/repair.js.map +1 -0
- package/dist/skills/replay.js +1281 -0
- package/dist/skills/replay.js.map +1 -0
- package/dist/skills/store.js +147 -0
- package/dist/skills/store.js.map +1 -0
- package/dist/spec/check.js +428 -0
- package/dist/spec/check.js.map +1 -0
- package/dist/spec/diagnostics.js +58 -0
- package/dist/spec/diagnostics.js.map +1 -0
- package/dist/spec/emit.js +2084 -0
- package/dist/spec/emit.js.map +1 -0
- package/dist/spec/index.js +62 -0
- package/dist/spec/index.js.map +1 -0
- package/dist/spec/ir.js +216 -0
- package/dist/spec/ir.js.map +1 -0
- package/dist/spec/lift.js +162 -0
- package/dist/spec/lift.js.map +1 -0
- package/dist/spec/locators.js +270 -0
- package/dist/spec/locators.js.map +1 -0
- package/dist/spec/lower.js +124 -0
- package/dist/spec/lower.js.map +1 -0
- package/dist/spec/repair.js +657 -0
- package/dist/spec/repair.js.map +1 -0
- package/dist/spec/rerecord.js +169 -0
- package/dist/spec/rerecord.js.map +1 -0
- package/dist/spec/rethread.js +120 -0
- package/dist/spec/rethread.js.map +1 -0
- package/package.json +50 -0
- 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
|