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
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1692 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { clip } from './shared/text.js';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { AnthropicProvider, OpenAICompatProvider, globalConfigPath, resolveProviderConfig, writeGlobalConfig } from './agent/llm.js';
|
|
8
|
+
import { encodeFrame, LineDecoder } from './shared/protocol.js';
|
|
9
|
+
import { aliasLegacyEnv, sessionsDir, socketPath, validateSessionName } from './shared/paths.js';
|
|
10
|
+
import { candidateExpr } from './daemon/recorder.js';
|
|
11
|
+
import { fillParams } from './skills/compile.js';
|
|
12
|
+
import { SkillStore, skillsDir, successRate } from './skills/store.js';
|
|
13
|
+
import { listFlows, loadFlow, loadFlowFile, saveFlow } from './skills/flow.js';
|
|
14
|
+
import { drainDrift, llmProposer, triage } from './skills/repair.js';
|
|
15
|
+
import { compileFlow } from './spec/index.js';
|
|
16
|
+
import { foldTicketEvidence, mintVars, notConverged, reorderByEvidence } from './spec/repair.js';
|
|
17
|
+
import { emitFlowFile } from './spec/emit.js';
|
|
18
|
+
import { LiftError, liftFlowFile } from './spec/lift.js';
|
|
19
|
+
import { diffSpecChanges, foldPatchedVariants, reloadStaged, rerecordDiagnostics, stageRepair } from './spec/repair.js';
|
|
20
|
+
import { diagnosticLine, formatDiagnostic } from './spec/diagnostics.js';
|
|
21
|
+
import { runSpecCheck } from './spec/check.js';
|
|
22
|
+
import { backupFlowFile, formatRerecordDiagnostic, RerecordError, rerecordVerdict, stepLine, stepNote, stepOf, unpinStep, } from './spec/rerecord.js';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
const USAGE = `sitelooper — agent-in-the-loop Playwright CLI
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
sitelooper do "<instruction>" [--json] [--max-turns N] [--timeout S] [--turn-timeout S] [--provider P] [--model M]
|
|
28
|
+
[--fallback-model M | --no-escalate]
|
|
29
|
+
sitelooper open <url>
|
|
30
|
+
sitelooper brief <file.md> [--append]
|
|
31
|
+
sitelooper note "<text>"
|
|
32
|
+
sitelooper reset # clear the LLM conversation only (browser/cookies/briefing/notes kept)
|
|
33
|
+
sitelooper peek [--selector <sel>] [--interactive]
|
|
34
|
+
sitelooper script [out.spec.ts] [--title T] [--clear] # emit a Playwright spec from the recorded actions
|
|
35
|
+
sitelooper compile <flow-name-or-path> [--out <dir>] [--force] [--json]
|
|
36
|
+
# compile a converged flow to a standalone Playwright
|
|
37
|
+
# spec (Tier 2, no sitelooper runtime) — no daemon needed.
|
|
38
|
+
# Problems are printed first as diagnostics (what / why /
|
|
39
|
+
# fix). A step pinned to a DEMOTED skill is an error: its
|
|
40
|
+
# recording, not the app, is what is wrong, so nothing is
|
|
41
|
+
# written — re-record the step, or pass --force.
|
|
42
|
+
# --force: overwrite an existing .spec.ts AND compile a
|
|
43
|
+
# demoted pin anyway (the emitted file then carries the
|
|
44
|
+
# diagnostic above the step and in its failure message).
|
|
45
|
+
sitelooper skills list [--origin <origin>] # stored procedures (learning mode; no daemon needed)
|
|
46
|
+
sitelooper skills show <id>
|
|
47
|
+
sitelooper skills rm <id>
|
|
48
|
+
sitelooper skills clear --origin <origin> | --all
|
|
49
|
+
sitelooper skills repair --drift <run-drift.json> [--dry-run] [--model M]
|
|
50
|
+
# post-session repair of a drift sidecar, in a COLD browser
|
|
51
|
+
# (signed into nothing). Prefer "sitelooper repair" below,
|
|
52
|
+
# which drains the same tickets on the live, signed-in page.
|
|
53
|
+
sitelooper repair <name.flow.ts> [--var k=v ...] [--out <file>] [--converge <n>]
|
|
54
|
+
[--reset-cmd "<shell command>"] [--check-spec] [--dry-run]
|
|
55
|
+
[--model M] [--json]
|
|
56
|
+
# self-updating spec: replay a compiled flow file against
|
|
57
|
+
# the live app in an ISOLATED temp store, let the recovery
|
|
58
|
+
# ladder adapt it, fold the adaptation back into the owned
|
|
59
|
+
# .flow.ts and re-emit it. Never touches the .spec.ts.
|
|
60
|
+
# --converge n re-runs the repaired flow n more times
|
|
61
|
+
# (default 1) and refuses to write unless every step is a
|
|
62
|
+
# clean tier-A replay with no drift. Each of those runs is
|
|
63
|
+
# a REAL run against the app: give a record-creating flow
|
|
64
|
+
# a per-run name with {n} (--var runid=fix-{n} becomes
|
|
65
|
+
# fix-0, fix-1, ...), or reset the app between runs with
|
|
66
|
+
# --reset-cmd, which runs a shell command before run 1 and
|
|
67
|
+
# before every converge run and aborts if it exits non-zero
|
|
68
|
+
# (--reset-cmd "curl -s -X POST http://localhost:3000/__reset").
|
|
69
|
+
# --check-spec runs the sibling .spec.ts ONCE under plain
|
|
70
|
+
# @playwright/test after the file is written, because repair
|
|
71
|
+
# replays the IR through the daemon and so cannot see an
|
|
72
|
+
# EMITTER defect. A failed check does not un-write the file
|
|
73
|
+
# (the diff is still yours) but exits 4.
|
|
74
|
+
sitelooper rerecord <flow-name-or-path> <step-id> [--instruction "<text>"] [--var k=v ...]
|
|
75
|
+
[--runs n] [--reset-cmd "<cmd>"] [--json]
|
|
76
|
+
# re-record ONE step of a saved flow, when the step's
|
|
77
|
+
# recording is what is wrong (its pinned procedure is
|
|
78
|
+
# demoted, or the step only passes because the engine
|
|
79
|
+
# replays some other skill). Backs the flow file up as
|
|
80
|
+
# <file>.bak-<stamp>.json, throws that step's pin, params
|
|
81
|
+
# and recorded values away — keeping its outputs — and
|
|
82
|
+
# replays the flow --runs times (default 2) in learning
|
|
83
|
+
# mode, so the agent records the step afresh and the
|
|
84
|
+
# store's own re-pin rule decides whether to keep it.
|
|
85
|
+
# Succeeds only when the LAST run replays the step at
|
|
86
|
+
# tier A with the newly pinned procedure; otherwise it
|
|
87
|
+
# prints why and exits 1. Each run is a REAL run against
|
|
88
|
+
# the app: mint per-run values with {n} (--var
|
|
89
|
+
# runid=fix-{n} becomes fix-0, fix-1, ...) or reset the
|
|
90
|
+
# app with --reset-cmd, which runs before every run.
|
|
91
|
+
# --instruction replaces the step's instruction first,
|
|
92
|
+
# which is the fix when the recorded instruction asked
|
|
93
|
+
# for something the app is no longer in a state to do.
|
|
94
|
+
sitelooper check <name.flow.ts> [--var k=v ...] [--reset-cmd "<cmd>"] [--json]
|
|
95
|
+
# run the sibling .spec.ts once under plain @playwright/test
|
|
96
|
+
# (minimal config, headless, one worker, 60 s per test) and
|
|
97
|
+
# report pass/fail, the nearest @step to the failure, and any
|
|
98
|
+
# [sitelooper drift] lines. No daemon, no model. Exit 4 when
|
|
99
|
+
# the spec fails; skipped (exit 0) when @playwright/test
|
|
100
|
+
# cannot be resolved from the project.
|
|
101
|
+
sitelooper var <name>=<value> # EXPERIMENTAL: declare a run variable (becomes {{name}} in a flow)
|
|
102
|
+
sitelooper flow list | show <name> # EXPERIMENTAL: saved flows (recorded sessions you can replay with run)
|
|
103
|
+
sitelooper run <flow> [--var k=v ...] # EXPERIMENTAL: replay a saved flow, repairing drifted steps
|
|
104
|
+
sitelooper screenshot [path]
|
|
105
|
+
sitelooper session list
|
|
106
|
+
sitelooper stop [--all] [--save-flow <name>]
|
|
107
|
+
sitelooper doctor # diagnose an install: node, browser, provider, key (no daemon needed)
|
|
108
|
+
sitelooper config # show resolved provider/model/paths
|
|
109
|
+
sitelooper config set <key> <value> # persist a default (provider, model, fallbackModel, baseUrl, apiKey)
|
|
110
|
+
|
|
111
|
+
Sizing an instruction:
|
|
112
|
+
One \`do\` = one logical, verifiable step: a goal plus the check that it worked
|
|
113
|
+
("create a project named X, fill any required fields, submit, and report the row
|
|
114
|
+
that appears"). Several UI actions inside one instruction is normal — that is the
|
|
115
|
+
point of the tool.
|
|
116
|
+
Too big: several unrelated goals or assertions in one string. The agent stalls on
|
|
117
|
+
planning and burns --max-turns. If a result comes back "blocked", split
|
|
118
|
+
it and retry the halves.
|
|
119
|
+
Too small: one click, one fill, one read. You pay for a whole agent loop to do what
|
|
120
|
+
\`peek\` gives you for free.
|
|
121
|
+
Do not drive the page by repeated \`peek\`/\`config\` polling. \`peek\` is for orienting
|
|
122
|
+
ONCE when a \`do\` reports something you did not expect. If you are about to issue the
|
|
123
|
+
same read a second time, issue a \`do\` instead.
|
|
124
|
+
|
|
125
|
+
Escalation:
|
|
126
|
+
When the routine model reports an instruction "blocked", it is retried once on a
|
|
127
|
+
stronger fallback model, on the same live browser and history (told to verify state
|
|
128
|
+
before repeating anything). Verified "failure" results are NOT retried. Disable with
|
|
129
|
+
--no-escalate, or set the fallback model to "none".
|
|
130
|
+
|
|
131
|
+
Learning (progressive automation):
|
|
132
|
+
Start a session with --learn (or SITELOOPER_SKILLS=1) and every instruction that
|
|
133
|
+
reports success is compiled into a stored procedure: its actions, durable locators
|
|
134
|
+
with fallbacks, the values it typed turned into parameters, and what each step
|
|
135
|
+
changed. On later instructions the procedures that start on the current page are
|
|
136
|
+
offered to the internal agent, which replays one deterministically (run_skill) and
|
|
137
|
+
only reasons when a step no longer works — the repair is stored as a variant. A
|
|
138
|
+
validated procedure whose template matches an instruction word for word is replayed
|
|
139
|
+
with no model call at all. Procedures live under ~/.sitelooper/skills/<origin>.json
|
|
140
|
+
(override with SITELOOPER_SKILLS_DIR); inspect with "sitelooper skills".
|
|
141
|
+
|
|
142
|
+
Global flags:
|
|
143
|
+
--session <name> session name (default "default"; one daemon+browser per session)
|
|
144
|
+
--verbose stream the internal agent's actions + token accounting while it works
|
|
145
|
+
--progress stream the agent's actions to stderr (composes with --json)
|
|
146
|
+
--headed launch the browser with a visible window (first call only)
|
|
147
|
+
--record record the session to webm, one file per tab; paths are printed
|
|
148
|
+
on stop, which is when Playwright writes them (first call only)
|
|
149
|
+
--script record every action as a replayable Playwright step (first call
|
|
150
|
+
only); write the spec out later with "sitelooper script"
|
|
151
|
+
--learn learning mode: compile successful instructions into stored
|
|
152
|
+
procedures and replay them on later instructions (first call only)
|
|
153
|
+
--json machine-readable output
|
|
154
|
+
|
|
155
|
+
Providers (presets; each field overridable by flag > env > config file):
|
|
156
|
+
zhipu (default) glm-5.2 @ api.z.ai key: GLM_API_KEY / ZHIPU_API_KEY
|
|
157
|
+
novita deepseek/deepseek-v4-flash @ novita.ai key: NOVITA_API_KEY
|
|
158
|
+
escalates to zai-org/glm-5.3 when blocked
|
|
159
|
+
openrouter z-ai/glm-5.2 @ openrouter.ai key: OPENROUTER_API_KEY
|
|
160
|
+
openai gpt-5-mini @ api.openai.com key: OPENAI_API_KEY
|
|
161
|
+
anthropic claude-sonnet-5 @ api.anthropic.com (native Messages API, not
|
|
162
|
+
OpenAI-compatible — its own adapter) key: ANTHROPIC_API_KEY
|
|
163
|
+
|
|
164
|
+
Environment:
|
|
165
|
+
SITELOOPER_PROVIDER provider preset name
|
|
166
|
+
SITELOOPER_MODEL model id override
|
|
167
|
+
SITELOOPER_FALLBACK_MODEL escalation model for blocked instructions ("none" disables)
|
|
168
|
+
SITELOOPER_BASE_URL any OpenAI-compatible base URL
|
|
169
|
+
SITELOOPER_API_KEY API key (works with any provider)
|
|
170
|
+
Secrets: write {{env:NAME}} in an instruction/briefing instead of a plaintext credential.
|
|
171
|
+
It resolves from the DAEMON's environment at the moment a tool runs — the model, transcript,
|
|
172
|
+
skills, and flows only ever carry the marker. Export NAME before the session's first call.
|
|
173
|
+
SITELOOPER_CHANNEL browser channel (default chrome, falls back to msedge)
|
|
174
|
+
SITELOOPER_HEADED=1 headed browser
|
|
175
|
+
SITELOOPER_RECORD=1 record session video to <session dir>/video
|
|
176
|
+
SITELOOPER_SCRIPT=1 record actions as a Playwright script (see the script command)
|
|
177
|
+
SITELOOPER_SKILLS=1 learning mode (see --learn); SITELOOPER_SKILLS_DIR relocates the store
|
|
178
|
+
|
|
179
|
+
Exit codes: 0 instruction succeeded · 1 failed/blocked · 2 infra error · 3 repair did not converge
|
|
180
|
+
· 4 the emitted .spec.ts failed its --check-spec run (the .flow.ts was still written)`;
|
|
181
|
+
function parseArgv(argv) {
|
|
182
|
+
const positional = [];
|
|
183
|
+
const flags = new Map();
|
|
184
|
+
const valueFlags = new Set([
|
|
185
|
+
'session',
|
|
186
|
+
'max-turns',
|
|
187
|
+
'timeout',
|
|
188
|
+
'turn-timeout',
|
|
189
|
+
'provider',
|
|
190
|
+
'model',
|
|
191
|
+
'fallback-model',
|
|
192
|
+
'base-url',
|
|
193
|
+
'selector',
|
|
194
|
+
'title',
|
|
195
|
+
'origin',
|
|
196
|
+
'save-flow',
|
|
197
|
+
'recovery-model',
|
|
198
|
+
'drift',
|
|
199
|
+
'var',
|
|
200
|
+
'out',
|
|
201
|
+
'converge',
|
|
202
|
+
'reset-cmd',
|
|
203
|
+
'instruction',
|
|
204
|
+
'runs',
|
|
205
|
+
]);
|
|
206
|
+
/**
|
|
207
|
+
* Every flag that takes no value. Unknown options are rejected rather than
|
|
208
|
+
* assumed boolean: an unrecognised `--url http://…` used to set a phantom
|
|
209
|
+
* boolean and drop the URL into the positionals, where `do` appended it to
|
|
210
|
+
* the instruction. The run still worked, so nothing looked wrong — but the
|
|
211
|
+
* compiled skill's template carried the URL and a slot for it, and no later
|
|
212
|
+
* instruction could bind that template. A typo silently changing what the
|
|
213
|
+
* agent was asked to do is not a defensible default for a tool whose
|
|
214
|
+
* results are meant to be reproducible.
|
|
215
|
+
*/
|
|
216
|
+
const booleanFlags = new Set([
|
|
217
|
+
'all',
|
|
218
|
+
'append',
|
|
219
|
+
'clear',
|
|
220
|
+
'dry-run',
|
|
221
|
+
'force',
|
|
222
|
+
'full-page',
|
|
223
|
+
'headed',
|
|
224
|
+
'help',
|
|
225
|
+
'interactive',
|
|
226
|
+
'check-spec',
|
|
227
|
+
'json',
|
|
228
|
+
'learn',
|
|
229
|
+
'no-escalate',
|
|
230
|
+
'progress',
|
|
231
|
+
'record',
|
|
232
|
+
'script',
|
|
233
|
+
'verbose',
|
|
234
|
+
'version',
|
|
235
|
+
]);
|
|
236
|
+
for (let i = 0; i < argv.length; i++) {
|
|
237
|
+
const arg = argv[i];
|
|
238
|
+
if (arg.startsWith('--')) {
|
|
239
|
+
const name = arg.slice(2);
|
|
240
|
+
if (valueFlags.has(name)) {
|
|
241
|
+
flags.set(name, argv[++i] ?? '');
|
|
242
|
+
}
|
|
243
|
+
else if (booleanFlags.has(name)) {
|
|
244
|
+
flags.set(name, true);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
const known = [...valueFlags, ...booleanFlags].sort();
|
|
248
|
+
throw new Error(`unknown option "--${name}". Known options: ${known.map((f) => `--${f}`).join(' ')}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
positional.push(arg);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const command = positional.shift() ?? '';
|
|
256
|
+
return { command, positional, flags };
|
|
257
|
+
}
|
|
258
|
+
// --- daemon connection ---
|
|
259
|
+
function connect(sock, timeoutMs = 1000) {
|
|
260
|
+
return new Promise((resolve, reject) => {
|
|
261
|
+
const conn = net.connect(sock);
|
|
262
|
+
const timer = setTimeout(() => {
|
|
263
|
+
conn.destroy();
|
|
264
|
+
reject(new Error('connect timeout'));
|
|
265
|
+
}, timeoutMs);
|
|
266
|
+
conn.once('connect', () => {
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
resolve(conn);
|
|
269
|
+
});
|
|
270
|
+
conn.once('error', (err) => {
|
|
271
|
+
clearTimeout(timer);
|
|
272
|
+
reject(err);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
/** Connect and prove the daemon is alive with a ping round-trip (a pipe can
|
|
277
|
+
* still accept connections while its daemon is shutting down). */
|
|
278
|
+
async function connectValidated(sock) {
|
|
279
|
+
const conn = await connect(sock);
|
|
280
|
+
try {
|
|
281
|
+
await request(conn, 'ping', {}, undefined, 5_000);
|
|
282
|
+
return conn;
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
conn.destroy();
|
|
286
|
+
throw err;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
async function connectOrSpawn(session, opts) {
|
|
290
|
+
const sock = socketPath(session);
|
|
291
|
+
try {
|
|
292
|
+
return await connectValidated(sock);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
// not running — spawn the daemon detached and wait for the pipe
|
|
296
|
+
}
|
|
297
|
+
const serverPath = fileURLToPath(new URL('./daemon/server.js', import.meta.url));
|
|
298
|
+
const args = [serverPath, '--session', session];
|
|
299
|
+
if (opts.headed)
|
|
300
|
+
args.push('--headed');
|
|
301
|
+
if (opts.record)
|
|
302
|
+
args.push('--record');
|
|
303
|
+
if (opts.script)
|
|
304
|
+
args.push('--script');
|
|
305
|
+
if (opts.learn)
|
|
306
|
+
args.push('--learn');
|
|
307
|
+
const child = spawn(process.execPath, args, {
|
|
308
|
+
detached: true,
|
|
309
|
+
stdio: 'ignore',
|
|
310
|
+
windowsHide: true,
|
|
311
|
+
env: process.env,
|
|
312
|
+
});
|
|
313
|
+
child.unref();
|
|
314
|
+
const deadline = Date.now() + 15_000;
|
|
315
|
+
let lastErr;
|
|
316
|
+
while (Date.now() < deadline) {
|
|
317
|
+
try {
|
|
318
|
+
return await connectValidated(sock);
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
lastErr = err;
|
|
322
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
throw new Error(`daemon did not come up for session "${session}": ${lastErr?.message}`);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* `timeoutMs` guards the control commands (ping/stop): a daemon that is wedged
|
|
329
|
+
* — rather than merely busy — must not hang the CLI indefinitely. `do` passes
|
|
330
|
+
* no timeout; the daemon enforces its own instruction deadline.
|
|
331
|
+
*/
|
|
332
|
+
function request(conn, command, args, onProgress, timeoutMs) {
|
|
333
|
+
return new Promise((resolve, reject) => {
|
|
334
|
+
const req = { id: Date.now() % 1_000_000, command, args };
|
|
335
|
+
const decoder = new LineDecoder();
|
|
336
|
+
const timer = timeoutMs
|
|
337
|
+
? setTimeout(() => {
|
|
338
|
+
cleanup();
|
|
339
|
+
reject(new Error(`${command} timed out after ${timeoutMs}ms — daemon not responding`));
|
|
340
|
+
}, timeoutMs)
|
|
341
|
+
: undefined;
|
|
342
|
+
const cleanup = () => {
|
|
343
|
+
clearTimeout(timer);
|
|
344
|
+
conn.removeListener('data', onData);
|
|
345
|
+
conn.removeListener('error', onError);
|
|
346
|
+
conn.removeListener('close', onClose);
|
|
347
|
+
};
|
|
348
|
+
const onData = (chunk) => {
|
|
349
|
+
let frames;
|
|
350
|
+
try {
|
|
351
|
+
frames = decoder.push(chunk);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
cleanup();
|
|
355
|
+
return reject(err);
|
|
356
|
+
}
|
|
357
|
+
for (const frame of frames) {
|
|
358
|
+
if (frame.type === 'progress')
|
|
359
|
+
onProgress?.(frame.message);
|
|
360
|
+
else {
|
|
361
|
+
cleanup();
|
|
362
|
+
resolve(frame);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
const onError = (err) => {
|
|
367
|
+
cleanup();
|
|
368
|
+
reject(err);
|
|
369
|
+
};
|
|
370
|
+
const onClose = () => {
|
|
371
|
+
cleanup();
|
|
372
|
+
reject(new Error('connection closed before result'));
|
|
373
|
+
};
|
|
374
|
+
conn.on('data', onData);
|
|
375
|
+
conn.on('error', onError);
|
|
376
|
+
conn.on('close', onClose);
|
|
377
|
+
conn.write(encodeFrame(req));
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
// --- output helpers ---
|
|
381
|
+
function fail(message, code = 2) {
|
|
382
|
+
console.error(`sitelooper: ${message}`);
|
|
383
|
+
process.exit(code);
|
|
384
|
+
}
|
|
385
|
+
function printResult(res, json) {
|
|
386
|
+
if (!res.ok)
|
|
387
|
+
fail(res.error ?? 'unknown error', res.errorKind === 'infra' ? 2 : 1);
|
|
388
|
+
if (json)
|
|
389
|
+
console.log(JSON.stringify(res.data, null, 2));
|
|
390
|
+
return res.data;
|
|
391
|
+
}
|
|
392
|
+
// --- main ---
|
|
393
|
+
async function main() {
|
|
394
|
+
aliasLegacyEnv(); // honor legacy BROWSER_PILOT_* env vars — see paths.ts
|
|
395
|
+
const { command, positional, flags } = parseArgv(process.argv.slice(2));
|
|
396
|
+
if (!command || flags.has('help') || command === 'help') {
|
|
397
|
+
console.log(USAGE);
|
|
398
|
+
process.exit(command ? 0 : 2);
|
|
399
|
+
}
|
|
400
|
+
const session = validateSessionName(String(flags.get('session') ?? 'default'));
|
|
401
|
+
const json = flags.has('json');
|
|
402
|
+
const verbose = flags.has('verbose');
|
|
403
|
+
// --progress streams the agent's actions to stderr without the full --verbose
|
|
404
|
+
// token accounting, so it composes with --json (JSON stays clean on stdout).
|
|
405
|
+
const onProgress = verbose || flags.has('progress') ? (m) => console.error(` · ${m}`) : undefined;
|
|
406
|
+
// Commands that don't need (or must not start) a daemon:
|
|
407
|
+
if (command === 'config' && positional[0] === 'set') {
|
|
408
|
+
const [, key, value] = positional;
|
|
409
|
+
if (!key || value === undefined)
|
|
410
|
+
fail('usage: config set <provider|model|fallbackModel|baseUrl|apiKey> <value>', 2);
|
|
411
|
+
const merged = writeGlobalConfig({ [key]: value });
|
|
412
|
+
const shown = { ...merged, ...(merged.apiKey ? { apiKey: '***' } : {}) };
|
|
413
|
+
console.log(`${globalConfigPath()}: ${JSON.stringify(shown)}`);
|
|
414
|
+
console.log('applies to the next instruction — running daemons re-read this file per call');
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (command === 'doctor') {
|
|
418
|
+
const { runDoctor } = await import('./doctor.js');
|
|
419
|
+
process.exit(await runDoctor(json));
|
|
420
|
+
}
|
|
421
|
+
if (command === 'skills' && positional[0] === 'repair') {
|
|
422
|
+
await repairCommand(positional, flags, json);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (command === 'skills') {
|
|
426
|
+
skillsCommand(positional, flags, json);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (command === 'flow' && positional[0] !== undefined && positional[0] !== 'run') {
|
|
430
|
+
flowCommand(positional, json);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (command === 'compile') {
|
|
434
|
+
await compileCommand(positional, flags, json);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (command === 'repair') {
|
|
438
|
+
await repairFlowCommand(positional, flags, json, onProgress);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (command === 'check') {
|
|
442
|
+
checkSpecCommand(positional, flags, json, onProgress);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (command === 'rerecord') {
|
|
446
|
+
await rerecordFlowCommand(positional, flags, json, onProgress);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (command === 'session') {
|
|
450
|
+
if (positional[0] !== 'list')
|
|
451
|
+
fail(`unknown subcommand "session ${positional[0] ?? ''}" (try: session list)`);
|
|
452
|
+
await listSessions(json);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (command === 'stop') {
|
|
456
|
+
const names = flags.has('all') ? allSessionNames() : [session];
|
|
457
|
+
for (const name of names) {
|
|
458
|
+
let conn;
|
|
459
|
+
try {
|
|
460
|
+
conn = await connect(socketPath(name));
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
if (!flags.has('all'))
|
|
464
|
+
console.log(`not running: ${name}`);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
// Generous: the daemon aborts any in-flight instruction and lets it
|
|
469
|
+
// unwind before closing the browser. Reachable-but-unresponsive is a
|
|
470
|
+
// real failure worth reporting, not a silent "not running".
|
|
471
|
+
//
|
|
472
|
+
// A --save-flow stop is doing real work, not just unwinding: flow
|
|
473
|
+
// export includes the post-session relabel (an LLM call, time-boxed
|
|
474
|
+
// daemon-side), read-back pinning and the flow lint. fwod26 hit the
|
|
475
|
+
// old shared 20s budget mid-export — the client gave up, the sweep
|
|
476
|
+
// read "flow was never saved" and SKIPPED both replays, while the
|
|
477
|
+
// detached daemon finished writing a perfectly good flow seconds
|
|
478
|
+
// later. Reachable-and-working must be allowed to finish.
|
|
479
|
+
// 150s: the relabel pass inside stop may ride out a full OpenRouter
|
|
480
|
+
// rate-limit wait (its own 100s timebox) and the export still needs room.
|
|
481
|
+
const stopTimeout = flags.get('save-flow') ? 150_000 : 20_000;
|
|
482
|
+
const res = await request(conn, 'stop', { saveFlow: flags.get('save-flow') || undefined }, undefined, stopTimeout);
|
|
483
|
+
const data = res.data;
|
|
484
|
+
console.log(`stopped: ${name}${data?.preempted ? ' (interrupted a running instruction)' : ''}`);
|
|
485
|
+
for (const video of data?.videos ?? [])
|
|
486
|
+
console.log(` video: ${video}`);
|
|
487
|
+
if (data?.flow?.error)
|
|
488
|
+
console.error(` flow not saved: ${data.flow.error}`);
|
|
489
|
+
else if (data?.flow?.path) {
|
|
490
|
+
console.log(` flow "${data.flow.name}" saved: ${data.flow.steps} step(s)${data.flow.vars?.length ? `, vars ${data.flow.vars.join(', ')}` : ''} → ${data.flow.path}`);
|
|
491
|
+
for (const w of data.flow.warnings ?? [])
|
|
492
|
+
console.error(` warning: ${w}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
console.error(`sitelooper: could not stop ${name}: ${err.message}`);
|
|
497
|
+
}
|
|
498
|
+
finally {
|
|
499
|
+
conn.destroy();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
const conn = await connectOrSpawn(session, {
|
|
505
|
+
headed: flags.has('headed'),
|
|
506
|
+
record: flags.has('record'),
|
|
507
|
+
script: flags.has('script'),
|
|
508
|
+
learn: flags.has('learn'),
|
|
509
|
+
}).catch((err) => fail(err.message));
|
|
510
|
+
try {
|
|
511
|
+
switch (command) {
|
|
512
|
+
case 'do': {
|
|
513
|
+
const instruction = positional.join(' ').trim();
|
|
514
|
+
if (!instruction)
|
|
515
|
+
fail('do requires an instruction', 2);
|
|
516
|
+
const res = await request(conn, 'do', {
|
|
517
|
+
instruction,
|
|
518
|
+
maxTurns: flags.has('max-turns') ? Number(flags.get('max-turns')) : undefined,
|
|
519
|
+
timeoutS: flags.has('timeout') ? Number(flags.get('timeout')) : undefined,
|
|
520
|
+
turnTimeoutS: flags.has('turn-timeout') ? Number(flags.get('turn-timeout')) : undefined,
|
|
521
|
+
provider: flags.get('provider') || undefined,
|
|
522
|
+
model: flags.get('model') || undefined,
|
|
523
|
+
baseUrl: flags.get('base-url') || undefined,
|
|
524
|
+
fallbackModel: flags.get('fallback-model') || undefined,
|
|
525
|
+
escalate: flags.has('no-escalate') ? false : undefined,
|
|
526
|
+
}, onProgress);
|
|
527
|
+
if (!res.ok)
|
|
528
|
+
fail(res.error ?? 'unknown error', res.errorKind === 'infra' ? 2 : 1);
|
|
529
|
+
const data = res.data;
|
|
530
|
+
if (json) {
|
|
531
|
+
console.log(JSON.stringify(data, null, 2));
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
const mark = data.report.status === 'success' ? 'OK' : data.report.status.toUpperCase();
|
|
535
|
+
console.log(`[${mark}] ${data.report.summary}`);
|
|
536
|
+
if (data.escalation) {
|
|
537
|
+
const e = data.escalation;
|
|
538
|
+
console.log(` escalated: ${e.from} blocked after ${e.firstAttempt.turns} turns → retried on ${e.to} (${e.rescued ? 'rescued' : 'still not resolved'})`);
|
|
539
|
+
console.log(` blocked because: ${e.reason}`);
|
|
540
|
+
}
|
|
541
|
+
if (data.skill?.invoked) {
|
|
542
|
+
const k = data.skill;
|
|
543
|
+
console.log(` skill: ${k.tier === 'A' ? 'replayed without the model' : 'replayed'} ${k.invoked} ${k.stepsReplayed}/${k.stepsTotal} steps${k.repaired ? ' — agent repaired the rest' : ''}${k.refused ? ' (refused)' : ''}`);
|
|
544
|
+
}
|
|
545
|
+
if (data.learned) {
|
|
546
|
+
const l = data.learned;
|
|
547
|
+
const bits = [
|
|
548
|
+
l.compiled ? `stored ${l.compiled}${l.variantOf ? ` (variant of ${l.variantOf})` : ''}` : '',
|
|
549
|
+
l.merged ? `merged into ${l.merged}` : '',
|
|
550
|
+
l.outcome ? `${l.outcome.skill} → ${l.outcome.status}` : '',
|
|
551
|
+
l.superseded ? `${l.superseded} superseded` : '',
|
|
552
|
+
].filter(Boolean);
|
|
553
|
+
if (bits.length)
|
|
554
|
+
console.log(` learned: ${bits.join('; ')}`);
|
|
555
|
+
}
|
|
556
|
+
if (data.report.details)
|
|
557
|
+
console.log(data.report.details);
|
|
558
|
+
if (data.report.evidence?.values) {
|
|
559
|
+
for (const [k, v] of Object.entries(data.report.evidence.values))
|
|
560
|
+
console.log(` ${k}: ${v}`);
|
|
561
|
+
}
|
|
562
|
+
if (data.report.evidence?.capturedDialogs?.length) {
|
|
563
|
+
console.log(` dialogs: ${data.report.evidence.capturedDialogs.join(' | ')}`);
|
|
564
|
+
}
|
|
565
|
+
if (data.screenshots.length) {
|
|
566
|
+
console.log(` screenshots: ${data.screenshots.length}`);
|
|
567
|
+
for (const s of data.screenshots)
|
|
568
|
+
console.log(` ${s}`);
|
|
569
|
+
}
|
|
570
|
+
if (data.actions?.length) {
|
|
571
|
+
// On bail-out: the state-changing actions that ran, so you can verify
|
|
572
|
+
// before resuming rather than blindly repeating them.
|
|
573
|
+
console.log('--- actions taken (verify before resuming) ---');
|
|
574
|
+
for (const a of data.actions)
|
|
575
|
+
console.log(` ${a.ok ? '✓' : '✗'} ${a.tool} ${a.args}`);
|
|
576
|
+
}
|
|
577
|
+
if (data.transcriptTail?.length && !data.actions?.length) {
|
|
578
|
+
// Nothing ran — the agent's own reasoning is the only evidence there is.
|
|
579
|
+
console.log('--- transcript tail (no tool calls ran) ---');
|
|
580
|
+
for (const line of data.transcriptTail)
|
|
581
|
+
console.log(` ${line}`);
|
|
582
|
+
}
|
|
583
|
+
if (data.finalState) {
|
|
584
|
+
console.log(`--- browser left at: ${data.finalState.url}${data.finalState.title ? ` — "${data.finalState.title}"` : ''}`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (verbose) {
|
|
588
|
+
const u = data.usage;
|
|
589
|
+
const fresh = u.promptTokens - u.cachedTokens;
|
|
590
|
+
const hit = u.promptTokens ? Math.round((u.cachedTokens / u.promptTokens) * 100) : 0;
|
|
591
|
+
const models = data.escalation ? `${data.escalation.from} → ${data.escalation.to}` : data.model;
|
|
592
|
+
console.error(` · ${data.turns} turns, ${u.promptTokens} prompt (${u.cachedTokens} cached / ${fresh} fresh, ${hit}% hit) + ${u.completionTokens} completion tokens (${models})`);
|
|
593
|
+
}
|
|
594
|
+
process.exit(data.report.status === 'success' ? 0 : 1);
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
case 'var': {
|
|
598
|
+
const spec = positional.join(' ');
|
|
599
|
+
const eq = spec.indexOf('=');
|
|
600
|
+
if (eq < 1)
|
|
601
|
+
fail('usage: var <name>=<value>', 2);
|
|
602
|
+
const data = printResult(await request(conn, 'var', { name: spec.slice(0, eq).trim(), value: spec.slice(eq + 1) }), json);
|
|
603
|
+
if (!json)
|
|
604
|
+
console.log(`vars: ${Object.entries(data.vars).map(([k, v]) => `${k}=${v}`).join(', ')}`);
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
case 'run': {
|
|
608
|
+
const flowName = positional[0];
|
|
609
|
+
if (!flowName)
|
|
610
|
+
fail('run requires a flow name (see: flow list)', 2);
|
|
611
|
+
const vars = {};
|
|
612
|
+
// --var k=v may repeat; parseArgv keeps only the last, so re-scan argv.
|
|
613
|
+
for (let i = 0; i < process.argv.length - 1; i++) {
|
|
614
|
+
if (process.argv[i] === '--var') {
|
|
615
|
+
const kv = process.argv[i + 1];
|
|
616
|
+
const eq = kv.indexOf('=');
|
|
617
|
+
if (eq > 0)
|
|
618
|
+
vars[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const res = await request(conn, 'run', {
|
|
622
|
+
name: flowName,
|
|
623
|
+
vars,
|
|
624
|
+
maxTurns: flags.has('max-turns') ? Number(flags.get('max-turns')) : undefined,
|
|
625
|
+
timeoutS: flags.has('timeout') ? Number(flags.get('timeout')) : undefined,
|
|
626
|
+
escalate: flags.has('no-escalate') ? false : undefined,
|
|
627
|
+
recoveryModel: flags.get('recovery-model') || undefined,
|
|
628
|
+
}, onProgress);
|
|
629
|
+
if (!res.ok)
|
|
630
|
+
fail(res.error ?? 'unknown error', res.errorKind === 'infra' ? 2 : 1);
|
|
631
|
+
const data = res.data;
|
|
632
|
+
if (json)
|
|
633
|
+
console.log(JSON.stringify(data, null, 2));
|
|
634
|
+
else {
|
|
635
|
+
for (const st of data.steps) {
|
|
636
|
+
const mark = st.status === 'success' ? 'OK' : st.status.toUpperCase();
|
|
637
|
+
// `satisfied` is not a cheaper replay, it is no replay at all: the
|
|
638
|
+
// page already showed this step's goal for this record.
|
|
639
|
+
const how = st.satisfied ? 'satisfied' : st.tier === 'A' ? 'replay' : st.replayed ? (st.repaired ? `replay+repair ${st.replayed}` : `replay ${st.replayed}`) : 'agent';
|
|
640
|
+
console.log(`[${mark}] ${st.id} (${how}${st.turns ? `, ${st.turns} turns` : ''})${st.repinned ? ` re-pinned ${st.repinned}` : ''}`);
|
|
641
|
+
if (st.status !== 'success' && st.summary)
|
|
642
|
+
console.log(` ${st.summary}`);
|
|
643
|
+
}
|
|
644
|
+
console.log(`${data.flow}: ${data.passed}/${data.total} steps, ${(data.wallMs / 1000).toFixed(1)}s${data.repinned ? `, ${data.repinned} step(s) re-pinned` : ''} — ${data.status}`);
|
|
645
|
+
}
|
|
646
|
+
process.exit(data.status === 'success' ? 0 : 1);
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
case 'open': {
|
|
650
|
+
if (!positional[0])
|
|
651
|
+
fail('open requires a URL', 2);
|
|
652
|
+
const data = printResult(await request(conn, 'open', { url: positional[0] }, onProgress), json);
|
|
653
|
+
if (!json)
|
|
654
|
+
console.log(`${data.title} — ${data.url}`);
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
case 'brief': {
|
|
658
|
+
const file = positional[0];
|
|
659
|
+
if (!file || !fs.existsSync(file))
|
|
660
|
+
fail(`brief requires an existing file (got: ${file ?? 'nothing'})`, 2);
|
|
661
|
+
const text = fs.readFileSync(path.resolve(file), 'utf8');
|
|
662
|
+
const data = printResult(await request(conn, 'brief', { text, append: flags.has('append') }), json);
|
|
663
|
+
if (!json)
|
|
664
|
+
console.log(`briefing loaded (${data.briefingChars} chars)`);
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
case 'note': {
|
|
668
|
+
const text = positional.join(' ').trim();
|
|
669
|
+
if (!text)
|
|
670
|
+
fail('note requires text', 2);
|
|
671
|
+
const data = printResult(await request(conn, 'note', { text }), json);
|
|
672
|
+
if (!json)
|
|
673
|
+
console.log(`noted (${data.notes} notes in session)`);
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
case 'reset': {
|
|
677
|
+
const data = printResult(await request(conn, 'reset', {}), json);
|
|
678
|
+
if (!json)
|
|
679
|
+
console.log(`conversation reset (${data.clearedMessages} message(s) cleared; browser, briefing, and notes kept)`);
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
case 'peek': {
|
|
683
|
+
const data = printResult(await request(conn, 'peek', {
|
|
684
|
+
selector: flags.get('selector') || undefined,
|
|
685
|
+
interactiveOnly: flags.has('interactive'),
|
|
686
|
+
}), json);
|
|
687
|
+
if (!json) {
|
|
688
|
+
console.log(`${data.title} — ${data.url}`);
|
|
689
|
+
console.log(data.snapshot);
|
|
690
|
+
}
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
case 'script': {
|
|
694
|
+
const data = printResult(await request(conn, 'script', {
|
|
695
|
+
path: positional[0],
|
|
696
|
+
title: flags.get('title') || undefined,
|
|
697
|
+
clear: flags.has('clear'),
|
|
698
|
+
}), json);
|
|
699
|
+
if (!json) {
|
|
700
|
+
if (data.path) {
|
|
701
|
+
console.log(`${data.path} (${data.steps} action(s), ${data.instructions ?? 0} instruction(s))`);
|
|
702
|
+
if (data.cleared)
|
|
703
|
+
console.log('recording cleared');
|
|
704
|
+
if (!data.recording) {
|
|
705
|
+
console.log('note: this session is not recording — generated from previously recorded actions');
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
console.log(`recording cleared (${data.steps} action(s) discarded)`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
714
|
+
case 'screenshot': {
|
|
715
|
+
const data = printResult(await request(conn, 'screenshot', { path: positional[0], fullPage: flags.has('full-page') }), json);
|
|
716
|
+
if (!json)
|
|
717
|
+
console.log(data.path);
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
case 'config': {
|
|
721
|
+
const data = printResult(await request(conn, 'config', {}), true);
|
|
722
|
+
void data;
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
default:
|
|
726
|
+
fail(`unknown command "${command}"\n\n${USAGE}`, 2);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
finally {
|
|
730
|
+
conn.destroy();
|
|
731
|
+
}
|
|
732
|
+
process.exit(0);
|
|
733
|
+
}
|
|
734
|
+
// --- skills (reads the store directly; no daemon involved) ---
|
|
735
|
+
function skillsCommand(positional, flags, json) {
|
|
736
|
+
const store = new SkillStore();
|
|
737
|
+
const sub = positional[0] ?? 'list';
|
|
738
|
+
const origin = flags.get('origin') ? String(flags.get('origin')) : undefined;
|
|
739
|
+
switch (sub) {
|
|
740
|
+
case 'list': {
|
|
741
|
+
const skills = (origin ? store.list(origin) : store.all()).sort((a, b) => a.origin.localeCompare(b.origin) || b.stats.uses - a.stats.uses);
|
|
742
|
+
if (json) {
|
|
743
|
+
console.log(JSON.stringify(skills.map(skillSummary), null, 2));
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (!skills.length) {
|
|
747
|
+
console.log(`no stored procedures${origin ? ` for ${origin}` : ''} (store: ${store.dir})`);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
let last = '';
|
|
751
|
+
for (const s of skills) {
|
|
752
|
+
if (s.origin !== last) {
|
|
753
|
+
console.log(`${s.origin}`);
|
|
754
|
+
last = s.origin;
|
|
755
|
+
}
|
|
756
|
+
const pct = Math.round(successRate(s) * 100);
|
|
757
|
+
console.log(` ${s.id} ${s.status.padEnd(11)} ${String(s.steps.length).padStart(2)} steps ${s.stats.successes}/${s.stats.uses} (${pct}%)${s.variantOf ? ` variant of ${s.variantOf}` : ''}`);
|
|
758
|
+
console.log(` ${clipText(s.template, 110)}`);
|
|
759
|
+
}
|
|
760
|
+
console.log(`store: ${store.dir}`);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
case 'show': {
|
|
764
|
+
const id = positional[1];
|
|
765
|
+
if (!id)
|
|
766
|
+
fail('usage: skills show <id>', 2);
|
|
767
|
+
const s = store.get(id);
|
|
768
|
+
if (!s)
|
|
769
|
+
fail(`no skill ${id}`, 1);
|
|
770
|
+
if (json) {
|
|
771
|
+
console.log(JSON.stringify(s, null, 2));
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
console.log(`${s.id} ${s.status} ${s.origin}`);
|
|
775
|
+
console.log(`template: ${s.template}`);
|
|
776
|
+
console.log(`starts on: ${s.preconditions.urlPattern}`);
|
|
777
|
+
const params = Object.entries(s.params);
|
|
778
|
+
console.log(params.length ? `params: ${params.map(([k, p]) => `${k} = e.g. ${JSON.stringify(p.example)} (steps ${p.usedIn.join(',')})`).join('; ')}` : 'params: none');
|
|
779
|
+
console.log(`stats: ${s.stats.successes}/${s.stats.uses} ok, ${s.stats.partial} partial, ${s.stats.fallthroughs} locator fallthrough(s)${Object.keys(s.stats.failedAtStep).length ? `, failed at step ${Object.entries(s.stats.failedAtStep).map(([k, v]) => `${k}×${v}`).join(', ')}` : ''}; created ${s.provenance.created} in session ${s.provenance.session}${s.provenance.model ? ` by ${s.provenance.model}` : ''}`);
|
|
780
|
+
if (s.variantOf)
|
|
781
|
+
console.log(`variant of: ${s.variantOf}`);
|
|
782
|
+
console.log('steps:');
|
|
783
|
+
s.steps.forEach((st, i) => {
|
|
784
|
+
const target = st.locators.target?.[0] ? candidateExpr(st.locators.target[0]) : st.args.target ? String(st.args.target) : '';
|
|
785
|
+
const fallbacks = (st.locators.target?.length ?? 0) > 1 ? ` (+${st.locators.target.length - 1} fallback)` : '';
|
|
786
|
+
const args = Object.entries(st.args)
|
|
787
|
+
.filter(([k]) => k !== 'target' && k !== 'source')
|
|
788
|
+
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
|
789
|
+
.join(' ');
|
|
790
|
+
const literal = Object.entries(st.args)
|
|
791
|
+
.filter(([k, v]) => ['value', 'text', 'option'].includes(k) && typeof v === 'string' && !/\{\{v\d+\}\}/.test(v))
|
|
792
|
+
.map(([, v]) => JSON.stringify(v));
|
|
793
|
+
console.log(` ${String(i + 1).padStart(2)}. ${st.tool.padEnd(14)} ${target}${fallbacks}${args ? ' ' + args : ''}${st.label ? ` → ${st.label}` : ''}${literal.length ? ` [literal value ${literal.join(', ')} — not a parameter]` : ''}${st.via ? ` (via ${st.via.skill} #${st.via.step})` : ''}`);
|
|
794
|
+
if (st.expect?.urlPattern)
|
|
795
|
+
console.log(` expect url ${st.expect.urlPattern}`);
|
|
796
|
+
});
|
|
797
|
+
if (s.reportTemplate?.summary)
|
|
798
|
+
console.log(`report: ${clipText(fillParams(s.reportTemplate.summary, {}), 200)}`);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
case 'rm': {
|
|
802
|
+
const id = positional[1];
|
|
803
|
+
if (!id)
|
|
804
|
+
fail('usage: skills rm <id>', 2);
|
|
805
|
+
if (!store.remove(id))
|
|
806
|
+
fail(`no skill ${id}`, 1);
|
|
807
|
+
console.log(`removed ${id}`);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
case 'clear': {
|
|
811
|
+
if (flags.has('all')) {
|
|
812
|
+
let n = 0;
|
|
813
|
+
for (const o of store.origins())
|
|
814
|
+
n += store.clear(o);
|
|
815
|
+
console.log(`cleared ${n} skill(s) across all origins`);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (!origin)
|
|
819
|
+
fail('usage: skills clear --origin <origin> | --all', 2);
|
|
820
|
+
console.log(`cleared ${store.clear(origin)} skill(s) for ${origin}`);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
default:
|
|
824
|
+
fail(`unknown subcommand "skills ${sub}" (try: list, show <id>, rm <id>, clear)`, 2);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
function skillSummary(s) {
|
|
828
|
+
return {
|
|
829
|
+
id: s.id,
|
|
830
|
+
origin: s.origin,
|
|
831
|
+
status: s.status,
|
|
832
|
+
template: s.template,
|
|
833
|
+
steps: s.steps.length,
|
|
834
|
+
params: Object.fromEntries(Object.entries(s.params).map(([k, p]) => [k, p.example])),
|
|
835
|
+
uses: s.stats.uses,
|
|
836
|
+
successes: s.stats.successes,
|
|
837
|
+
partial: s.stats.partial,
|
|
838
|
+
urlPattern: s.preconditions.urlPattern,
|
|
839
|
+
...(s.variantOf ? { variantOf: s.variantOf } : {}),
|
|
840
|
+
created: s.provenance.created,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
function clipText(text, max) {
|
|
844
|
+
return clip(text.replace(/\s+/g, ' '), max);
|
|
845
|
+
}
|
|
846
|
+
function flowCommand(positional, json) {
|
|
847
|
+
const op = positional[0] ?? 'list';
|
|
848
|
+
if (op === 'list') {
|
|
849
|
+
const flows = listFlows();
|
|
850
|
+
if (json)
|
|
851
|
+
console.log(JSON.stringify(flows.map((f) => ({ name: f.name, steps: f.steps.length, vars: f.vars, origin: f.origin })), null, 2));
|
|
852
|
+
else if (!flows.length)
|
|
853
|
+
console.log('no saved flows');
|
|
854
|
+
else
|
|
855
|
+
for (const f of flows)
|
|
856
|
+
console.log(`${f.name} ${f.steps.length} step(s) ${f.vars.length ? `vars ${f.vars.join(', ')}` : 'no vars'} ${f.origin}`);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
if (op === 'show') {
|
|
860
|
+
const flow = loadFlow(positional[1] ?? '');
|
|
861
|
+
if (!flow)
|
|
862
|
+
fail(`no flow "${positional[1] ?? ''}"`, 1);
|
|
863
|
+
if (json) {
|
|
864
|
+
console.log(JSON.stringify(flow, null, 2));
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
console.log(`${flow.name} ${flow.origin} (recorded ${flow.provenance.created} in session ${flow.provenance.session})`);
|
|
868
|
+
console.log(`starts at: ${flow.startUrl}`);
|
|
869
|
+
console.log(flow.vars.length ? `vars: ${flow.vars.join(', ')}` : 'vars: none');
|
|
870
|
+
for (const st of flow.steps) {
|
|
871
|
+
console.log(` ${st.id}${st.skill ? ` [${st.skill}]` : ' [no skill]'}${st.outputs.length ? ` → ${st.outputs.join(', ')}` : ''}`);
|
|
872
|
+
console.log(` ${st.instruction.length > 120 ? st.instruction.slice(0, 120) + '…' : st.instruction}`);
|
|
873
|
+
}
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
fail(`unknown "flow ${op}" (try: list, show <name>)`, 2);
|
|
877
|
+
}
|
|
878
|
+
// --- compile (reads the flow + skill store directly; no daemon involved) ---
|
|
879
|
+
async function compileCommand(positional, flags, json) {
|
|
880
|
+
const flowNameOrPath = positional[0];
|
|
881
|
+
if (!flowNameOrPath)
|
|
882
|
+
fail('usage: compile <flow-name-or-path> [--out <dir>] [--force] [--json]', 2);
|
|
883
|
+
const outDir = flags.get('out') ? String(flags.get('out')) : '.';
|
|
884
|
+
let result;
|
|
885
|
+
try {
|
|
886
|
+
result = compileFlow(flowNameOrPath, { outDir, force: flags.has('force') });
|
|
887
|
+
}
|
|
888
|
+
catch (err) {
|
|
889
|
+
fail(`compile failed: ${err.message}`, 2);
|
|
890
|
+
}
|
|
891
|
+
if (json) {
|
|
892
|
+
console.log(JSON.stringify(result, null, 2));
|
|
893
|
+
}
|
|
894
|
+
else {
|
|
895
|
+
// Diagnostics FIRST — what is wrong, the evidence, and the command that
|
|
896
|
+
// fixes it — ahead of the file list, which is not what a caller needs when
|
|
897
|
+
// the answer is "re-record 08-open".
|
|
898
|
+
for (const d of result.diagnostics)
|
|
899
|
+
console.error(formatDiagnostic(d));
|
|
900
|
+
if (result.diagnostics.length)
|
|
901
|
+
console.error('');
|
|
902
|
+
if (result.refused) {
|
|
903
|
+
console.error('nothing written: the error(s) above are about the RECORDING, not the app — a compiled spec would fail at a locator and read as drift.');
|
|
904
|
+
console.error('re-record the step(s) with the fix command above, or pass --force to compile the demoted pin anyway.');
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
console.log(`flow: ${result.flowFile}`);
|
|
908
|
+
console.log(result.specFile ? `spec: ${result.specFile}` : 'spec: unchanged (already exists — pass --force to overwrite)');
|
|
909
|
+
}
|
|
910
|
+
// Anything the emitter said that no diagnostic above already carries.
|
|
911
|
+
const reported = new Set(result.diagnostics.map(diagnosticLine));
|
|
912
|
+
for (const w of result.warnings)
|
|
913
|
+
if (!reported.has(w))
|
|
914
|
+
console.error(` warning: ${w}`);
|
|
915
|
+
}
|
|
916
|
+
if (result.refused) {
|
|
917
|
+
fail('refused: a step is pinned to a demoted skill — see the diagnostics above (--force compiles it anyway)', 2);
|
|
918
|
+
}
|
|
919
|
+
if (!result.compilable) {
|
|
920
|
+
const missing = result.spec.steps.filter((s) => s.segments.length === 0).length;
|
|
921
|
+
fail(`not compilable: ${missing} step(s) have no converged procedure`, 2);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* `sitelooper check <name.flow.ts>` — run the emitted spec once, as a user would.
|
|
926
|
+
*
|
|
927
|
+
* The standalone half of `repair --check-spec`, and the same code underneath.
|
|
928
|
+
* It exists on its own because the question ("does the compiled spec actually
|
|
929
|
+
* pass under plain Playwright?") is worth asking about a file nobody is
|
|
930
|
+
* repairing: after a `compile`, after a hand edit to the `.spec.ts`, or in CI
|
|
931
|
+
* next to the sitelooper-free artifacts it is supposed to have produced.
|
|
932
|
+
*
|
|
933
|
+
* No daemon and no model are involved: this spawns `npx playwright test` over
|
|
934
|
+
* a copy of the two files with a config of its own, and reports the report.
|
|
935
|
+
*/
|
|
936
|
+
function checkSpecCommand(positional, flags, json, onProgress) {
|
|
937
|
+
const file = positional[0];
|
|
938
|
+
if (!file)
|
|
939
|
+
fail('usage: check <name.flow.ts> [--var k=v ...] [--reset-cmd "<cmd>"] [--json]', 2);
|
|
940
|
+
if (!fs.existsSync(file))
|
|
941
|
+
fail(`could not read ${file}`, 2);
|
|
942
|
+
const result = runSpecCheck({
|
|
943
|
+
flowFile: file,
|
|
944
|
+
vars: varFlags(),
|
|
945
|
+
resetCmd: flags.get('reset-cmd') ? String(flags.get('reset-cmd')) : undefined,
|
|
946
|
+
liveReplayPassed: false,
|
|
947
|
+
onProgress: onProgress ?? ((m) => console.error(m)),
|
|
948
|
+
});
|
|
949
|
+
if (json)
|
|
950
|
+
console.log(JSON.stringify({ file, specCheck: result }, null, 2));
|
|
951
|
+
else {
|
|
952
|
+
console.log(result.verdict);
|
|
953
|
+
for (const d of result.drift)
|
|
954
|
+
console.log(` ${d}`);
|
|
955
|
+
if (result.workspace)
|
|
956
|
+
console.log(` workspace: ${result.workspace}`);
|
|
957
|
+
}
|
|
958
|
+
// A skip is not a verdict about the spec, so it is not a failure either.
|
|
959
|
+
if (result.ran && !result.passed)
|
|
960
|
+
process.exit(4);
|
|
961
|
+
}
|
|
962
|
+
function allSessionNames() {
|
|
963
|
+
try {
|
|
964
|
+
return fs
|
|
965
|
+
.readdirSync(sessionsDir(), { withFileTypes: true })
|
|
966
|
+
.filter((d) => d.isDirectory())
|
|
967
|
+
.map((d) => d.name);
|
|
968
|
+
}
|
|
969
|
+
catch {
|
|
970
|
+
return [];
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
async function listSessions(json) {
|
|
974
|
+
const names = allSessionNames();
|
|
975
|
+
const rows = [];
|
|
976
|
+
for (const name of names) {
|
|
977
|
+
try {
|
|
978
|
+
const conn = await connect(socketPath(name), 500);
|
|
979
|
+
const res = await request(conn, 'ping', {}, undefined, 5_000);
|
|
980
|
+
conn.destroy();
|
|
981
|
+
const data = res.data;
|
|
982
|
+
rows.push({ session: name, running: true, pid: data.pid });
|
|
983
|
+
}
|
|
984
|
+
catch {
|
|
985
|
+
rows.push({ session: name, running: false });
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
if (json)
|
|
989
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
990
|
+
else if (!rows.length)
|
|
991
|
+
console.log('no sessions');
|
|
992
|
+
else
|
|
993
|
+
for (const r of rows)
|
|
994
|
+
console.log(`${r.session} ${r.running ? `running (pid ${r.pid})` : 'stopped'}`);
|
|
995
|
+
}
|
|
996
|
+
main().catch((err) => fail(err?.message ?? String(err)));
|
|
997
|
+
// --- post-session repair (SLOW MODE) ---
|
|
998
|
+
/**
|
|
999
|
+
* Drain one run's drift tickets, after the timed run is over:
|
|
1000
|
+
* - localized drift that already self-healed (a fallback resolved) → promote
|
|
1001
|
+
* that fallback to primary in the stored skill. Cheap, deterministic.
|
|
1002
|
+
* - localized drift with a dead chain → ask the repair model to re-derive
|
|
1003
|
+
* the moved control's locator on the live page, verify it resolves, and
|
|
1004
|
+
* store the patched chain as a provisional VARIANT that must earn adoption
|
|
1005
|
+
* through the normal lifecycle.
|
|
1006
|
+
* - low similarity → broad redesign: flag for a fresh record run, never
|
|
1007
|
+
* patch selectors.
|
|
1008
|
+
*/
|
|
1009
|
+
async function repairCommand(positional, flags, json) {
|
|
1010
|
+
const file = String(flags.get('drift') ?? positional[1] ?? '');
|
|
1011
|
+
if (!file)
|
|
1012
|
+
fail('usage: skills repair --drift <run-drift.json> [--dry-run] [--model M]', 2);
|
|
1013
|
+
let tickets;
|
|
1014
|
+
try {
|
|
1015
|
+
tickets = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1016
|
+
}
|
|
1017
|
+
catch (err) {
|
|
1018
|
+
return fail(`could not read drift tickets from ${file}: ${err.message}`, 1);
|
|
1019
|
+
}
|
|
1020
|
+
const dryRun = flags.has('dry-run');
|
|
1021
|
+
const store = new SkillStore();
|
|
1022
|
+
// The COLD path: a browser of its own, signed into nothing. Kept for the
|
|
1023
|
+
// standalone case (a drift sidecar from a CI run, days later, with no
|
|
1024
|
+
// session to attach to) — `sitelooper repair <flow.ts>` drains the same
|
|
1025
|
+
// tickets inside the run's own session, on a page that is still signed in,
|
|
1026
|
+
// and should be preferred whenever the flow file is at hand.
|
|
1027
|
+
const wantsPage = !dryRun && triage(tickets).some((a) => a.kind === 'patch-segment');
|
|
1028
|
+
const model = flags.get('model') ? String(flags.get('model')) : undefined;
|
|
1029
|
+
let browser = null;
|
|
1030
|
+
let summary;
|
|
1031
|
+
try {
|
|
1032
|
+
let propose;
|
|
1033
|
+
let openPage;
|
|
1034
|
+
if (wantsPage) {
|
|
1035
|
+
const config = resolveProviderConfig({ model });
|
|
1036
|
+
const resolved = model ?? (config.fallbackModel && config.fallbackModel !== 'none' ? config.fallbackModel : config.model);
|
|
1037
|
+
const provider = config.provider === 'anthropic' ? new AnthropicProvider({ ...config, model: resolved }) : new OpenAICompatProvider({ ...config, model: resolved });
|
|
1038
|
+
propose = llmProposer(provider);
|
|
1039
|
+
const { BrowserSession } = await import('./daemon/browser.js');
|
|
1040
|
+
browser = new BrowserSession({ session: 'repair', persist: false });
|
|
1041
|
+
openPage = async (url) => {
|
|
1042
|
+
const page = await browser.getPage();
|
|
1043
|
+
await page.goto(url, { waitUntil: 'load', timeout: 30_000 }).catch(() => { });
|
|
1044
|
+
return page;
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
summary = await drainDrift(store, tickets, { dryRun, model, propose, openPage });
|
|
1048
|
+
}
|
|
1049
|
+
finally {
|
|
1050
|
+
await browser?.close();
|
|
1051
|
+
}
|
|
1052
|
+
if (json) {
|
|
1053
|
+
console.log(JSON.stringify({ tickets: tickets.length, ...summary }, null, 2));
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
console.log(`${tickets.length} drift ticket(s) → ${summary.promoted.length} fallback(s) promoted, ${summary.patched.length} segment(s) patched, ${summary.reRecord.length} flagged for re-record, ${summary.skipped.length} skipped`);
|
|
1057
|
+
for (const p of summary.promoted)
|
|
1058
|
+
console.log(` promoted ${p.skill} step ${p.step}: ${p.to}${p.dryRun ? ' (dry run)' : ''}`);
|
|
1059
|
+
for (const p of summary.patched)
|
|
1060
|
+
console.log(` patched ${p.skill} step ${p.step} → variant ${p.variant} (${p.locator})`);
|
|
1061
|
+
for (const p of summary.reRecord)
|
|
1062
|
+
console.log(` re-record ${p.skill} (${p.flow}/${p.step}): ${p.why}`);
|
|
1063
|
+
for (const p of summary.skipped)
|
|
1064
|
+
console.log(` skipped ${p.skill}${p.step ? ` step ${p.step}` : ''}: ${p.why}`);
|
|
1065
|
+
}
|
|
1066
|
+
// --- repair on a compiled spec (PLAN-self-updating-spec.md, phase 4) ---
|
|
1067
|
+
/**
|
|
1068
|
+
* `--var k=v` may repeat, and `parseArgv` keeps only the last one, so the
|
|
1069
|
+
* repeats are re-scanned out of argv. Same rule `run` uses; shared so a flow
|
|
1070
|
+
* replayed by `repair` binds exactly what `run` would.
|
|
1071
|
+
*/
|
|
1072
|
+
function varFlags() {
|
|
1073
|
+
const vars = {};
|
|
1074
|
+
for (let i = 0; i < process.argv.length - 1; i++) {
|
|
1075
|
+
if (process.argv[i] === '--var') {
|
|
1076
|
+
const kv = process.argv[i + 1];
|
|
1077
|
+
const eq = kv.indexOf('=');
|
|
1078
|
+
if (eq > 0)
|
|
1079
|
+
vars[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return vars;
|
|
1083
|
+
}
|
|
1084
|
+
/** Best-effort shutdown of a throwaway repair session (its browser is the only thing holding that profile open). */
|
|
1085
|
+
async function stopSessionQuietly(name) {
|
|
1086
|
+
try {
|
|
1087
|
+
const conn = await connect(socketPath(name));
|
|
1088
|
+
try {
|
|
1089
|
+
await request(conn, 'stop', {}, undefined, 30_000);
|
|
1090
|
+
}
|
|
1091
|
+
finally {
|
|
1092
|
+
conn.destroy();
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
catch {
|
|
1096
|
+
/* already gone */
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Replay a staged flow through the daemon exactly as `sitelooper run` does —
|
|
1101
|
+
* same spawn, same `run` command, same recovery ladder — with the skill store
|
|
1102
|
+
* pointed at the staged temp dir so the run's re-pins, learned variants and
|
|
1103
|
+
* candidate evidence land there and nowhere near `~/.sitelooper`.
|
|
1104
|
+
*
|
|
1105
|
+
* A FRESH session per run, deliberately: the flow's first step signs in, and a
|
|
1106
|
+
* session still signed in from the previous run would send that step to model
|
|
1107
|
+
* recovery and make the convergence gate measure the wrong thing. The session
|
|
1108
|
+
* is stopped on the way out so a converge loop does not leave one browser per
|
|
1109
|
+
* iteration running.
|
|
1110
|
+
*/
|
|
1111
|
+
async function runStagedFlow(staged, vars, session, opts) {
|
|
1112
|
+
const prev = { skills: process.env.SITELOOPER_SKILLS, dir: process.env.SITELOOPER_SKILLS_DIR };
|
|
1113
|
+
process.env.SITELOOPER_SKILLS = '1';
|
|
1114
|
+
process.env.SITELOOPER_SKILLS_DIR = staged.skillsDir;
|
|
1115
|
+
try {
|
|
1116
|
+
const conn = await connectOrSpawn(session, { headed: opts.headed, record: false, script: false, learn: true });
|
|
1117
|
+
try {
|
|
1118
|
+
const res = await request(conn, 'run', { name: staged.flowFile, vars }, opts.onProgress);
|
|
1119
|
+
if (!res.ok)
|
|
1120
|
+
fail(res.error ?? 'the flow run failed', res.errorKind === 'infra' ? 2 : 1);
|
|
1121
|
+
const run = res.data;
|
|
1122
|
+
if (!opts.drain)
|
|
1123
|
+
return { run };
|
|
1124
|
+
const patched = await request(conn, 'patch', { tickets: run.driftTickets ?? [], dryRun: opts.drain.dryRun, model: opts.drain.model }, opts.onProgress);
|
|
1125
|
+
if (!patched.ok)
|
|
1126
|
+
fail(patched.error ?? 'the drift drain failed', patched.errorKind === 'infra' ? 2 : 1);
|
|
1127
|
+
return { run, drained: patched.data };
|
|
1128
|
+
}
|
|
1129
|
+
finally {
|
|
1130
|
+
conn.destroy();
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
finally {
|
|
1134
|
+
if (prev.skills === undefined)
|
|
1135
|
+
delete process.env.SITELOOPER_SKILLS;
|
|
1136
|
+
else
|
|
1137
|
+
process.env.SITELOOPER_SKILLS = prev.skills;
|
|
1138
|
+
if (prev.dir === undefined)
|
|
1139
|
+
delete process.env.SITELOOPER_SKILLS_DIR;
|
|
1140
|
+
else
|
|
1141
|
+
process.env.SITELOOPER_SKILLS_DIR = prev.dir;
|
|
1142
|
+
await stopSessionQuietly(session);
|
|
1143
|
+
// A repair run's session is scratch: its browser profile exists for one
|
|
1144
|
+
// replay and a converge loop would otherwise leave one directory per
|
|
1145
|
+
// iteration behind. Best effort — a profile Chrome has not finished
|
|
1146
|
+
// releasing is left for the OS to clean up rather than failing the run.
|
|
1147
|
+
try {
|
|
1148
|
+
fs.rmSync(path.join(sessionsDir(), session), { recursive: true, force: true });
|
|
1149
|
+
}
|
|
1150
|
+
catch {
|
|
1151
|
+
/* still held open — harmless */
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Put the app back where every run of a converge loop expects to find it.
|
|
1157
|
+
*
|
|
1158
|
+
* `{n}`-minted vars solve half of the accumulation problem (each run works its
|
|
1159
|
+
* own records); they do not solve the other half, which is everything the
|
|
1160
|
+
* PREVIOUS run left behind — rows in a list a locator counts, a seeded fixture
|
|
1161
|
+
* a create step consumes, a queue that grows. A record-creating flow replayed
|
|
1162
|
+
* three times is three different apps unless something resets it, and the gate
|
|
1163
|
+
* would then be measuring the app's history rather than the spec's stability.
|
|
1164
|
+
*
|
|
1165
|
+
* Deliberately a shell command rather than anything sitelooper knows how to
|
|
1166
|
+
* do: the reset is the application's business (a fixture endpoint, a `docker
|
|
1167
|
+
* compose down -v`, a seed script), and the only thing this tool has an
|
|
1168
|
+
* opinion about is that a reset which FAILED must stop the run — a converge
|
|
1169
|
+
* pass over an un-reset app reports a verdict about nothing.
|
|
1170
|
+
*/
|
|
1171
|
+
function runResetCmd(cmd, label, say) {
|
|
1172
|
+
if (!cmd)
|
|
1173
|
+
return;
|
|
1174
|
+
say(` reset (${label}): ${cmd}`);
|
|
1175
|
+
const res = spawnSync(cmd, { shell: true, stdio: 'pipe', encoding: 'utf8' });
|
|
1176
|
+
if (res.error)
|
|
1177
|
+
fail(`--reset-cmd could not run: ${res.error.message}`, 2);
|
|
1178
|
+
if (res.status !== 0) {
|
|
1179
|
+
if (res.stderr?.trim())
|
|
1180
|
+
console.error(res.stderr.trim());
|
|
1181
|
+
fail(`--reset-cmd exited ${res.status ?? 'by signal'} before ${label}; refusing to run against an app that was not reset`, 2);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* `sitelooper repair <name.flow.ts>` — the self-updating half of the compiled
|
|
1186
|
+
* runner (PLAN-self-updating-spec.md, "The loop").
|
|
1187
|
+
*
|
|
1188
|
+
* Lift the owned file back to its IR, stage it into a THROWAWAY store and flow
|
|
1189
|
+
* file, replay it against the live app through the daemon (whose recovery
|
|
1190
|
+
* ladder is the agent adapting the flow — that is the point), drain the run's
|
|
1191
|
+
* drift tickets onto the staged store, re-derive the IR from what the run and
|
|
1192
|
+
* the drain left there, and print the difference as English a reviewer can
|
|
1193
|
+
* read. Only then, and only if the convergence gate passes, is the `.flow.ts`
|
|
1194
|
+
* re-emitted. The `.spec.ts` is never touched: it is the user's file.
|
|
1195
|
+
*/
|
|
1196
|
+
async function repairFlowCommand(positional, flags, json, onProgress) {
|
|
1197
|
+
const file = positional[0];
|
|
1198
|
+
if (!file)
|
|
1199
|
+
fail('usage: repair <name.flow.ts> [--var k=v ...] [--out <file>] [--converge <n>] [--reset-cmd "<cmd>"] [--dry-run] [--model M] [--json]', 2);
|
|
1200
|
+
let source;
|
|
1201
|
+
try {
|
|
1202
|
+
source = fs.readFileSync(file, 'utf8');
|
|
1203
|
+
}
|
|
1204
|
+
catch (err) {
|
|
1205
|
+
return fail(`could not read ${file}: ${err.message}`, 2);
|
|
1206
|
+
}
|
|
1207
|
+
let before;
|
|
1208
|
+
try {
|
|
1209
|
+
before = liftFlowFile(source).spec;
|
|
1210
|
+
}
|
|
1211
|
+
catch (err) {
|
|
1212
|
+
if (err instanceof LiftError) {
|
|
1213
|
+
return fail(`this file was edited by hand or is not a sitelooper flow file; refusing to repair — ${err.message}`, 2);
|
|
1214
|
+
}
|
|
1215
|
+
throw err;
|
|
1216
|
+
}
|
|
1217
|
+
const vars = varFlags();
|
|
1218
|
+
const missingVars = before.vars.filter((v) => !(v in vars));
|
|
1219
|
+
if (missingVars.length)
|
|
1220
|
+
fail(`flow "${before.name}" needs --var for: ${missingVars.join(', ')}`, 2);
|
|
1221
|
+
const converge = flags.has('converge') ? Number(flags.get('converge')) : 1;
|
|
1222
|
+
if (!Number.isInteger(converge) || converge < 0)
|
|
1223
|
+
fail('--converge takes a non-negative integer', 2);
|
|
1224
|
+
const dryRun = flags.has('dry-run');
|
|
1225
|
+
const resetCmd = flags.get('reset-cmd') ? String(flags.get('reset-cmd')) : undefined;
|
|
1226
|
+
const outFile = flags.get('out') ? String(flags.get('out')) : file;
|
|
1227
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sitelooper-repair-'));
|
|
1228
|
+
const staged = stageRepair(before, dir);
|
|
1229
|
+
const stamp = Date.now().toString(36);
|
|
1230
|
+
// Progress is never silent, only redirected: under --json stdout has to stay
|
|
1231
|
+
// parseable, so every line goes to stderr instead of being dropped. sp4od's
|
|
1232
|
+
// repair.log had nothing but the final ticket lines because these were
|
|
1233
|
+
// discarded, and the per-step tier/status of each run is exactly what a
|
|
1234
|
+
// reader of that log needs.
|
|
1235
|
+
const say = (m) => {
|
|
1236
|
+
if (json)
|
|
1237
|
+
console.error(m);
|
|
1238
|
+
else
|
|
1239
|
+
console.log(m);
|
|
1240
|
+
};
|
|
1241
|
+
say(`repairing ${file} (${before.steps.length} step(s)) in ${dir}`);
|
|
1242
|
+
const runs = [];
|
|
1243
|
+
const noteRun = (label, r, ticketCount) => {
|
|
1244
|
+
runs.push({
|
|
1245
|
+
label,
|
|
1246
|
+
passed: r.passed,
|
|
1247
|
+
total: r.total,
|
|
1248
|
+
status: r.status,
|
|
1249
|
+
tickets: ticketCount,
|
|
1250
|
+
steps: r.steps.map((st) => ({
|
|
1251
|
+
id: st.id,
|
|
1252
|
+
status: st.status,
|
|
1253
|
+
tier: st.tier ?? null,
|
|
1254
|
+
...(st.summary ? { summary: st.summary } : {}),
|
|
1255
|
+
recovered: Boolean(st.recovered),
|
|
1256
|
+
...(st.fellBack ? { fellBack: st.fellBack } : {}),
|
|
1257
|
+
// WHICH procedure actually ran, not only how well it went. sp8od read
|
|
1258
|
+
// 9/9 tier A on a step whose pinned skill never ran at all, and the
|
|
1259
|
+
// run report had no field that could have said so.
|
|
1260
|
+
...(st.replayed ? { replayed: st.replayed } : {}),
|
|
1261
|
+
...(st.repinned ? { repinned: st.repinned } : {}),
|
|
1262
|
+
})),
|
|
1263
|
+
});
|
|
1264
|
+
};
|
|
1265
|
+
// Steps whose RECORDING is the problem — a demoted pin, or a pin the engine
|
|
1266
|
+
// silently replaced with another skill — by step id. Derived after run 1 and
|
|
1267
|
+
// after every converge run, because the pin can move under us and because
|
|
1268
|
+
// the evidence sentence names every run. See spec/repair.ts
|
|
1269
|
+
// `rerecordDiagnostics` for the rules and for the sp8od case that forced
|
|
1270
|
+
// them. Each one is printed FIRST, once, and refuses the write.
|
|
1271
|
+
const flagged = new Map();
|
|
1272
|
+
const refreshFlags = () => {
|
|
1273
|
+
let staleFlow;
|
|
1274
|
+
try {
|
|
1275
|
+
staleFlow = JSON.parse(fs.readFileSync(staged.flowFile, 'utf8'));
|
|
1276
|
+
}
|
|
1277
|
+
catch {
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
const fresh = rerecordDiagnostics({
|
|
1281
|
+
// The .flow.ts the reviewer owns, not the staged JSON the run used: the
|
|
1282
|
+
// fix command has to name a file that will still be there tomorrow.
|
|
1283
|
+
flowFile: file,
|
|
1284
|
+
steps: staleFlow.steps,
|
|
1285
|
+
runs,
|
|
1286
|
+
store: new SkillStore(staged.skillsDir),
|
|
1287
|
+
});
|
|
1288
|
+
for (const [id, d] of fresh) {
|
|
1289
|
+
const seen = flagged.get(id);
|
|
1290
|
+
flagged.set(id, d);
|
|
1291
|
+
// Once per step, however many runs re-observe it — but the LATEST
|
|
1292
|
+
// evidence wins, so the sentence names every run that has happened.
|
|
1293
|
+
if (!seen)
|
|
1294
|
+
say(formatDiagnostic(d));
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
// Every line the evidence codemod produced, across run 1 and every converge
|
|
1298
|
+
// run. Kept beside the IR diff rather than inside it because a retirement is
|
|
1299
|
+
// an observation ABOUT a chain, not a shape change the diff can see: a
|
|
1300
|
+
// candidate that dropped to the back of a chain reads, in the emitted file,
|
|
1301
|
+
// as a reordering with no reason attached.
|
|
1302
|
+
const evidenceLines = [];
|
|
1303
|
+
// One retirement, one line, however many runs re-observe it.
|
|
1304
|
+
const retirementsReported = new Set();
|
|
1305
|
+
runResetCmd(resetCmd, 'run 1', say);
|
|
1306
|
+
const { run, drained } = await runStagedFlow(staged, mintVars(vars, 0), `repair-${stamp}-0`, {
|
|
1307
|
+
headed: flags.has('headed'),
|
|
1308
|
+
onProgress,
|
|
1309
|
+
// The drain runs even under --dry-run, but as TRIAGE only: the summary is
|
|
1310
|
+
// what a dry run is FOR, and it cannot be described without classifying
|
|
1311
|
+
// the tickets. --dry-run governs the irreversible things — reordering a
|
|
1312
|
+
// stored chain, storing a variant, writing the user's `.flow.ts`.
|
|
1313
|
+
drain: { dryRun, model: flags.get('model') ? String(flags.get('model')) : undefined },
|
|
1314
|
+
});
|
|
1315
|
+
const tickets = run.driftTickets ?? [];
|
|
1316
|
+
const summary = drained ?? { promoted: [], patched: [], reRecord: [], skipped: [] };
|
|
1317
|
+
noteRun('run 1', run, tickets.length);
|
|
1318
|
+
say(`run 1: ${run.passed}/${run.total} step(s) ${run.status}, ${tickets.length} drift ticket(s)`);
|
|
1319
|
+
for (const st of run.steps) {
|
|
1320
|
+
say(` [${st.status === 'success' ? 'OK' : st.status.toUpperCase()}] ${st.id} (tier ${st.tier ?? 'none'})${st.fellBack ? ` — fell back: ${st.fellBack}` : ''}${st.status === 'success' ? '' : ` — ${st.summary ?? ''}`}`);
|
|
1321
|
+
}
|
|
1322
|
+
// A patch the daemon verified on the live page is a proposal about THIS
|
|
1323
|
+
// spec, not a candidate for some future store's lifecycle: fold it into the
|
|
1324
|
+
// chain before anything reads the IR back, or the variant compiles as an
|
|
1325
|
+
// extra segment beside the drifted one it was meant to replace. The
|
|
1326
|
+
// convergence gate below is what it has to earn its place against.
|
|
1327
|
+
const folded = dryRun ? [] : foldPatchedVariants(staged.store, summary.patched);
|
|
1328
|
+
for (const line of folded)
|
|
1329
|
+
say(` folded ${line}`);
|
|
1330
|
+
// The cheap, no-model half of "what the agent is allowed to change": bank
|
|
1331
|
+
// the misses this run's tickets prove for the chains replay banks nothing
|
|
1332
|
+
// about (a dead chain, or one a structural path won), then reorder every
|
|
1333
|
+
// chain by that evidence. Run 1 files the first miss; the converge runs
|
|
1334
|
+
// below file the second, which is what `retired` needs.
|
|
1335
|
+
if (!dryRun) {
|
|
1336
|
+
foldTicketEvidence(staged.store, tickets);
|
|
1337
|
+
evidenceLines.push(...reorderByEvidence(staged.store, retirementsReported));
|
|
1338
|
+
for (const line of evidenceLines)
|
|
1339
|
+
say(` evidence ${line}`);
|
|
1340
|
+
}
|
|
1341
|
+
// Diagnostics before counts, before the change list, before anything: a
|
|
1342
|
+
// step the engine covered with someone else's skill makes every number
|
|
1343
|
+
// below it mean something different.
|
|
1344
|
+
refreshFlags();
|
|
1345
|
+
let diff = diffSpecChanges(before, reloadStaged(staged).spec);
|
|
1346
|
+
const printChanges = (heading, d) => {
|
|
1347
|
+
if (json)
|
|
1348
|
+
return;
|
|
1349
|
+
console.log(heading);
|
|
1350
|
+
for (const line of d.lines)
|
|
1351
|
+
console.log(` ${line}`);
|
|
1352
|
+
for (const line of evidenceLines)
|
|
1353
|
+
console.log(` ${line}`);
|
|
1354
|
+
};
|
|
1355
|
+
if (!json) {
|
|
1356
|
+
console.log(`${tickets.length} drift ticket(s) → ${summary.promoted.length} promoted, ${summary.patched.length} patched, ${summary.reRecord.length} need re-record, ${summary.skipped.length} skipped`);
|
|
1357
|
+
for (const p of summary.patched)
|
|
1358
|
+
console.log(` patched ${p.skill} step ${p.step} → variant ${p.variant} (${p.locator}) on ${p.url}`);
|
|
1359
|
+
for (const p of summary.reRecord)
|
|
1360
|
+
console.log(` needs re-record ${p.skill} (${p.flow}/${p.step}): ${p.why}`);
|
|
1361
|
+
for (const p of summary.skipped) {
|
|
1362
|
+
// A no-proposal is only meaningful next to what the model was looking
|
|
1363
|
+
// at: 6 interactive rows means it was shown the login page, 40 means the
|
|
1364
|
+
// page really had nothing that fits.
|
|
1365
|
+
const seen = p.snapshotRows === undefined ? '' : ` [saw ${p.snapshotRows} interactive row(s), ${p.snapshotBytes} bytes, on ${p.url}; model replied ${JSON.stringify(p.modelReply)}]`;
|
|
1366
|
+
console.log(` skipped ${p.skill}${p.step ? ` step ${p.step}` : ''}: ${p.why}${seen}`);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
printChanges('--- changes ---', diff);
|
|
1370
|
+
// Filled in only by --check-spec, after the owned file is written: it is a
|
|
1371
|
+
// verdict about the EMITTED spec, which does not exist until then.
|
|
1372
|
+
let specCheck = null;
|
|
1373
|
+
const report = () => ({
|
|
1374
|
+
file,
|
|
1375
|
+
flow: before.name,
|
|
1376
|
+
workspace: dir,
|
|
1377
|
+
run: { status: run.status, passed: run.passed, total: run.total, drift: tickets.length },
|
|
1378
|
+
// Every run, not just run 1: the gate is a verdict about the LAST run, and
|
|
1379
|
+
// "which step needed the model, on which pass, and why" is unanswerable
|
|
1380
|
+
// from a single aggregate.
|
|
1381
|
+
runs,
|
|
1382
|
+
// Every problem this repair found, in the shape every surface reports one
|
|
1383
|
+
// (spec/diagnostics.ts) — present in EVERY JSON shape the command prints,
|
|
1384
|
+
// refusal, gate failure, dry run and success alike.
|
|
1385
|
+
diagnostics: [...flagged.values()],
|
|
1386
|
+
// The tickets themselves, not just how many: "15 drift ticket(s)" cannot be
|
|
1387
|
+
// acted on, and the one question a stuck converge loop asks is WHICH
|
|
1388
|
+
// locator keeps missing and what won instead.
|
|
1389
|
+
tickets,
|
|
1390
|
+
...summary,
|
|
1391
|
+
changes: [...diff.lines, ...evidenceLines],
|
|
1392
|
+
evidence: evidenceLines,
|
|
1393
|
+
droppedExpectations: diff.droppedExpectations,
|
|
1394
|
+
weakenedByVariant: diff.weakenedByVariant,
|
|
1395
|
+
specCheck,
|
|
1396
|
+
});
|
|
1397
|
+
// Never weaken an expectation: an assertion that no longer holds is a test
|
|
1398
|
+
// failure for a human, not drift (PLAN-self-updating-spec.md). The one
|
|
1399
|
+
// reported-not-refused case is a repair VARIANT — see SpecDiff for why that
|
|
1400
|
+
// is not a loophole.
|
|
1401
|
+
const gateExpectations = () => {
|
|
1402
|
+
for (const w of diff.weakenedByVariant)
|
|
1403
|
+
console.error(` review: ${w}`);
|
|
1404
|
+
if (!diff.droppedExpectations.length)
|
|
1405
|
+
return;
|
|
1406
|
+
if (json)
|
|
1407
|
+
console.log(JSON.stringify({ ...report(), wrote: null, refused: 'expectation dropped' }, null, 2));
|
|
1408
|
+
for (const d of diff.droppedExpectations)
|
|
1409
|
+
console.error(` expectation dropped: ${d}`);
|
|
1410
|
+
fail('refusing to write: the repair would drop an expectation — that is a test failure for a human, not drift', 1);
|
|
1411
|
+
};
|
|
1412
|
+
gateExpectations();
|
|
1413
|
+
/**
|
|
1414
|
+
* Refuse the write when a step's RECORDING is the problem.
|
|
1415
|
+
*
|
|
1416
|
+
* The step is not converged however clean its tier looked — sp8od went 9/9
|
|
1417
|
+
* tier A three times on a step whose demoted pin never ran once. No locator
|
|
1418
|
+
* edit reaches this, so there is nothing to write: the file stays as it was,
|
|
1419
|
+
* the exit code is the existing "needs re-record" 1, and what the reviewer
|
|
1420
|
+
* gets is the diagnostic block plus the command that fixes it.
|
|
1421
|
+
*/
|
|
1422
|
+
const gateRerecord = () => {
|
|
1423
|
+
if (!flagged.size)
|
|
1424
|
+
return;
|
|
1425
|
+
const ds = [...flagged.values()];
|
|
1426
|
+
if (json) {
|
|
1427
|
+
console.log(JSON.stringify({ ...report(), wrote: null, converged: false, refused: 'needs re-record', notConverged: [...flagged.keys()] }, null, 2));
|
|
1428
|
+
}
|
|
1429
|
+
else {
|
|
1430
|
+
// Printed once more here, next to the refusal, because the block above
|
|
1431
|
+
// scrolled past several runs ago — and it is the whole reason for it.
|
|
1432
|
+
for (const d of ds)
|
|
1433
|
+
console.error(formatDiagnostic(d));
|
|
1434
|
+
printChanges('--- changes (not written) ---', diff);
|
|
1435
|
+
console.error(`refusing to write ${outFile}: ${ds.map((d) => d.step).join(', ')} need re-recording, not repair — the run only passed because another skill covered the step`);
|
|
1436
|
+
}
|
|
1437
|
+
process.exit(1);
|
|
1438
|
+
};
|
|
1439
|
+
let changed = [...diff.lines.filter((l) => !l.endsWith(': no change')), ...evidenceLines];
|
|
1440
|
+
if (!changed.length && summary.reRecord.length) {
|
|
1441
|
+
if (json)
|
|
1442
|
+
console.log(JSON.stringify({ ...report(), wrote: null, refused: 'needs re-record' }, null, 2));
|
|
1443
|
+
else
|
|
1444
|
+
console.log('nothing could be repaired without re-recording — re-record the segment(s) listed above and compile again');
|
|
1445
|
+
process.exit(1);
|
|
1446
|
+
}
|
|
1447
|
+
for (let i = 1; i <= converge; i++) {
|
|
1448
|
+
runResetCmd(resetCmd, `converge ${i}/${converge}`, say);
|
|
1449
|
+
const { run: check } = await runStagedFlow(staged, mintVars(vars, i), `repair-${stamp}-${i}`, { headed: flags.has('headed'), onProgress });
|
|
1450
|
+
const checkTickets = check.driftTickets ?? [];
|
|
1451
|
+
// Fold and reorder BEFORE gating, not after: this run's misses are part of
|
|
1452
|
+
// the evidence this run is judged on. A candidate whose second miss lands
|
|
1453
|
+
// here is retired here, and the ticket that reported it is then exactly
|
|
1454
|
+
// what the codemod just recorded — a fact about the spec, not drift.
|
|
1455
|
+
if (!dryRun) {
|
|
1456
|
+
foldTicketEvidence(staged.store, checkTickets);
|
|
1457
|
+
const moved = reorderByEvidence(staged.store, retirementsReported);
|
|
1458
|
+
evidenceLines.push(...moved);
|
|
1459
|
+
for (const line of moved)
|
|
1460
|
+
say(` evidence ${line}`);
|
|
1461
|
+
}
|
|
1462
|
+
noteRun(`converge ${i}/${converge}`, check, checkTickets.length);
|
|
1463
|
+
for (const st of check.steps) {
|
|
1464
|
+
say(` [${st.status === 'success' ? 'OK' : st.status.toUpperCase()}] ${st.id} (tier ${st.tier ?? 'none'})${st.fellBack ? ` — fell back: ${st.fellBack}` : ''}${st.status === 'success' ? '' : ` — ${st.summary ?? ''}`}`);
|
|
1465
|
+
}
|
|
1466
|
+
refreshFlags();
|
|
1467
|
+
const bad = notConverged(check, dryRun ? undefined : staged.store, flagged);
|
|
1468
|
+
// A flagged step is not converged, but it is not a CONVERGENCE failure
|
|
1469
|
+
// either: no further run can clear it and no locator edit can repair it,
|
|
1470
|
+
// so it exits through gateRerecord (1) below rather than the gate's own
|
|
1471
|
+
// exit 3 — with every converge run's evidence in the sentence, which is
|
|
1472
|
+
// why the loop is allowed to finish.
|
|
1473
|
+
const others = bad.filter((line) => ![...flagged.keys()].some((id) => line.startsWith(`${id} (`)));
|
|
1474
|
+
say(`converge ${i}/${converge}: ${check.passed}/${check.total} step(s) ${check.status}, ${checkTickets.length} drift ticket(s)${bad.length ? '' : ' — clean'}`);
|
|
1475
|
+
if (others.length) {
|
|
1476
|
+
if (json)
|
|
1477
|
+
console.log(JSON.stringify({ ...report(), wrote: null, converged: false, notConverged: bad, convergeTickets: checkTickets }, null, 2));
|
|
1478
|
+
// The tickets, not just the step ids: a gate failure is only actionable
|
|
1479
|
+
// if it names the locator that missed and what resolved instead.
|
|
1480
|
+
for (const t of checkTickets) {
|
|
1481
|
+
console.error(` ticket: ${t.step} ${t.skill}${t.atStep ? `/${t.atStep}` : ''} ${t.key ?? ''}: ${t.missedLocator ?? t.reason ?? t.fellBack ?? 'recovered'}${t.fallbackUsed ? ` → used ${t.fallbackUsed}` : ' → nothing resolved'}`);
|
|
1482
|
+
}
|
|
1483
|
+
console.error(`not converged: ${bad.join(', ')}`);
|
|
1484
|
+
process.exit(3);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
// A convergence run is not only a check: a patch-segment variant is stored
|
|
1488
|
+
// PROVISIONAL and becomes the step's pin only when a run adopts it, so the
|
|
1489
|
+
// IR can legitimately move again between the drain and here. Re-diff rather
|
|
1490
|
+
// than emit a summary that predates the adoption.
|
|
1491
|
+
const finalSpec = reloadStaged(staged).spec;
|
|
1492
|
+
const finalDiff = diffSpecChanges(before, finalSpec);
|
|
1493
|
+
if (finalDiff.lines.join('\n') !== diff.lines.join('\n')) {
|
|
1494
|
+
diff = finalDiff;
|
|
1495
|
+
changed = [...diff.lines.filter((l) => !l.endsWith(': no change')), ...evidenceLines];
|
|
1496
|
+
printChanges('--- changes (after the convergence run(s) adopted what the repair proposed) ---', diff);
|
|
1497
|
+
gateExpectations();
|
|
1498
|
+
}
|
|
1499
|
+
// The last word before the write, and ahead of --dry-run's own report: a
|
|
1500
|
+
// dry run that says "0 change(s), nothing written" about a flow with a
|
|
1501
|
+
// demoted pin is the same silence sp8od shipped.
|
|
1502
|
+
gateRerecord();
|
|
1503
|
+
if (dryRun) {
|
|
1504
|
+
if (json)
|
|
1505
|
+
console.log(JSON.stringify({ ...report(), wrote: null, dryRun: true }, null, 2));
|
|
1506
|
+
else
|
|
1507
|
+
console.log(`dry run: ${changed.length} change(s), nothing written (would have written ${outFile})`);
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
// Re-emit from the repaired IR — the owned file is generated in full, every
|
|
1511
|
+
// time, so a promoted candidate shows up in the diff as a reordered chain in
|
|
1512
|
+
// both the FLOW constant and the generated step body.
|
|
1513
|
+
const emitted = emitFlowFile(finalSpec, { tier: 'plain' });
|
|
1514
|
+
fs.writeFileSync(outFile, emitted.source);
|
|
1515
|
+
if (!json) {
|
|
1516
|
+
for (const w of emitted.warnings)
|
|
1517
|
+
console.error(` warning: ${w}`);
|
|
1518
|
+
console.log(`wrote ${outFile} (${changed.length} change(s); the .spec.ts was not touched)`);
|
|
1519
|
+
}
|
|
1520
|
+
// The blind spot this closes: everything above ran the IR through the
|
|
1521
|
+
// DAEMON, so a defect in the emitter — a chain that lowers fine for replay
|
|
1522
|
+
// and transpiles to a Playwright call that never resolves — passes every
|
|
1523
|
+
// gate and still ships a spec that fails on the first run. The only way to
|
|
1524
|
+
// see it is to run the emitted spec the way a user will. It is one more real
|
|
1525
|
+
// run against the app, so it gets its own {n} slot and its own reset.
|
|
1526
|
+
if (flags.has('check-spec')) {
|
|
1527
|
+
specCheck = runSpecCheck({
|
|
1528
|
+
flowFile: outFile,
|
|
1529
|
+
vars: mintVars(vars, converge + 1),
|
|
1530
|
+
resetCmd,
|
|
1531
|
+
liveReplayPassed: true,
|
|
1532
|
+
// A failure at a step whose recording is the problem is NOT an emitter
|
|
1533
|
+
// defect, whatever the live replay reported (sp8od's --check-spec said
|
|
1534
|
+
// exactly that, and was wrong). Unreachable while gateRerecord refuses
|
|
1535
|
+
// the write, and passed anyway so the claim can never be made by accident.
|
|
1536
|
+
flagged,
|
|
1537
|
+
onProgress: (m) => (json ? console.error(m) : console.log(m)),
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
if (json)
|
|
1541
|
+
console.log(JSON.stringify({ ...report(), wrote: outFile, converged: true }, null, 2));
|
|
1542
|
+
else if (specCheck) {
|
|
1543
|
+
console.log(specCheck.verdict);
|
|
1544
|
+
for (const d of specCheck.drift)
|
|
1545
|
+
console.log(` ${d}`);
|
|
1546
|
+
if (specCheck.workspace)
|
|
1547
|
+
console.log(` workspace: ${specCheck.workspace}`);
|
|
1548
|
+
}
|
|
1549
|
+
// The file STAYS written — the diff is the reviewer's, and a repair that
|
|
1550
|
+
// adapted a locator correctly is not undone by the emitter mis-spelling it.
|
|
1551
|
+
// What changes is the exit code, so a script cannot mistake this for a clean
|
|
1552
|
+
// repair.
|
|
1553
|
+
if (specCheck?.ran && !specCheck.passed) {
|
|
1554
|
+
console.error(`the emitted spec fails under plain Playwright: ${specCheck.verdict}`);
|
|
1555
|
+
console.error(`${outFile} was still written — review the diff, then fix the emitter (not the app)`);
|
|
1556
|
+
process.exit(4);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
// --- rerecord one step of a saved flow (the recording is wrong, not the app) ---
|
|
1560
|
+
/**
|
|
1561
|
+
* `sitelooper rerecord <flow> <step>` — the fix half of the diagnostics.
|
|
1562
|
+
*
|
|
1563
|
+
* `compile` and `repair` can both now SAY that a step's recording is the
|
|
1564
|
+
* problem (a demoted pin, or a step that only passes because the engine
|
|
1565
|
+
* replays a different skill than the one it is pinned to). Neither could do
|
|
1566
|
+
* anything about it: repair adapts locators, and no amount of locator
|
|
1567
|
+
* adaptation fixes a procedure whose first action was recorded against a state
|
|
1568
|
+
* the flow no longer reaches. The only repair for a wrong recording is another
|
|
1569
|
+
* recording — this command takes one, for one step, without re-recording the
|
|
1570
|
+
* whole session by hand.
|
|
1571
|
+
*
|
|
1572
|
+
* It owns no cleverness of its own: it unpins the step (spec/rerecord.ts
|
|
1573
|
+
* `unpinStep`), replays the flow the ordinary way in learning mode, and lets
|
|
1574
|
+
* the store's re-pin rule decide. The verdict is the bar a compiled spec has
|
|
1575
|
+
* to clear — the last run replays the step at tier A, with the pin the
|
|
1576
|
+
* re-recording made.
|
|
1577
|
+
*/
|
|
1578
|
+
async function rerecordFlowCommand(positional, flags, json, onProgress) {
|
|
1579
|
+
const usage = 'usage: rerecord <flow-name-or-path> <step-id> [--instruction "<text>"] [--var k=v ...] [--runs n] [--reset-cmd "<cmd>"] [--json]';
|
|
1580
|
+
const [nameOrPath, stepId] = positional;
|
|
1581
|
+
if (!nameOrPath || !stepId)
|
|
1582
|
+
fail(usage, 2);
|
|
1583
|
+
const loaded = loadFlowFile(nameOrPath);
|
|
1584
|
+
if (!loaded)
|
|
1585
|
+
fail(`no flow "${nameOrPath}" — pass a path to a flow .json, or a name from "sitelooper flow list"`, 2);
|
|
1586
|
+
const { flow, file } = loaded;
|
|
1587
|
+
const runsWanted = flags.has('runs') ? Number(flags.get('runs')) : 2;
|
|
1588
|
+
if (!Number.isInteger(runsWanted) || runsWanted < 1)
|
|
1589
|
+
fail('--runs takes a positive integer', 2);
|
|
1590
|
+
const instruction = flags.get('instruction') ? String(flags.get('instruction')) : undefined;
|
|
1591
|
+
const resetCmd = flags.get('reset-cmd') ? String(flags.get('reset-cmd')) : undefined;
|
|
1592
|
+
// Everything that can refuse, refuses BEFORE a browser starts: an unknown
|
|
1593
|
+
// step id or a missing --var costs a daemon spawn and a sign-in otherwise,
|
|
1594
|
+
// and this command's runs are real runs against the app.
|
|
1595
|
+
const previous = flow.steps.find((s) => s.id === stepId);
|
|
1596
|
+
let patched;
|
|
1597
|
+
try {
|
|
1598
|
+
patched = unpinStep(flow, stepId, instruction);
|
|
1599
|
+
}
|
|
1600
|
+
catch (err) {
|
|
1601
|
+
if (err instanceof RerecordError)
|
|
1602
|
+
return fail(err.message, 2);
|
|
1603
|
+
throw err;
|
|
1604
|
+
}
|
|
1605
|
+
const vars = varFlags();
|
|
1606
|
+
const missingVars = flow.vars.filter((v) => !(v in vars));
|
|
1607
|
+
if (missingVars.length)
|
|
1608
|
+
fail(`flow "${flow.name}" needs --var for: ${missingVars.join(', ')}`, 2);
|
|
1609
|
+
const say = (m) => {
|
|
1610
|
+
if (json)
|
|
1611
|
+
console.error(m);
|
|
1612
|
+
else
|
|
1613
|
+
console.log(m);
|
|
1614
|
+
};
|
|
1615
|
+
const stamp = Date.now().toString(36);
|
|
1616
|
+
let backup;
|
|
1617
|
+
try {
|
|
1618
|
+
backup = backupFlowFile(file, stamp);
|
|
1619
|
+
}
|
|
1620
|
+
catch (err) {
|
|
1621
|
+
if (err instanceof RerecordError)
|
|
1622
|
+
return fail(err.message, 2);
|
|
1623
|
+
throw err;
|
|
1624
|
+
}
|
|
1625
|
+
saveFlow(patched, file);
|
|
1626
|
+
say(`re-recording ${flow.name} step ${stepId} (${runsWanted} run(s))`);
|
|
1627
|
+
say(` unpinned ${previous?.skill ?? '(no procedure)'}${instruction ? ', with a new instruction' : ''}; old recording kept at ${backup}`);
|
|
1628
|
+
const runs = [];
|
|
1629
|
+
for (let i = 0; i < runsWanted; i++) {
|
|
1630
|
+
const label = `run ${i + 1}`;
|
|
1631
|
+
runResetCmd(resetCmd, label, say);
|
|
1632
|
+
// The same path `sitelooper run` takes — daemon, recovery ladder, learning
|
|
1633
|
+
// mode — pointed at the REAL skill store, because the whole point is that
|
|
1634
|
+
// the procedure this records survives into it.
|
|
1635
|
+
// Whatever the daemon says about THIS step (a re-pin refusal above all)
|
|
1636
|
+
// is printed whether or not --progress is on, and kept for the verdict.
|
|
1637
|
+
const notes = [];
|
|
1638
|
+
const { run } = await runStagedFlow({ flowFile: file, skillsDir: skillsDir() }, mintVars(vars, i), `rerecord-${stamp}-${i}`, {
|
|
1639
|
+
headed: flags.has('headed'),
|
|
1640
|
+
onProgress: (m) => {
|
|
1641
|
+
const note = stepNote(m, stepId);
|
|
1642
|
+
if (note) {
|
|
1643
|
+
notes.push(note);
|
|
1644
|
+
say(` ${label}: ${stepId} ${note}`);
|
|
1645
|
+
}
|
|
1646
|
+
onProgress?.(m);
|
|
1647
|
+
},
|
|
1648
|
+
});
|
|
1649
|
+
const entry = { label, step: stepOf(run.steps, stepId), ...(notes.length ? { notes } : {}) };
|
|
1650
|
+
runs.push(entry);
|
|
1651
|
+
say(stepLine(stepId, entry));
|
|
1652
|
+
say(` ${run.flow}: ${run.passed}/${run.total} step(s) ${run.status}`);
|
|
1653
|
+
}
|
|
1654
|
+
const verdict = rerecordVerdict({ file, stepId, runs });
|
|
1655
|
+
// The daemon writes re-pins back into the flow file it was given, so the
|
|
1656
|
+
// authoritative answer to "what is this step pinned to now" is on disk.
|
|
1657
|
+
const after = loadFlowFile(file)?.flow.steps.find((s) => s.id === stepId);
|
|
1658
|
+
const pinned = after?.skill ?? verdict.pinned;
|
|
1659
|
+
const skill = pinned ? new SkillStore().get(pinned) : null;
|
|
1660
|
+
const payload = {
|
|
1661
|
+
flow: flow.name,
|
|
1662
|
+
file,
|
|
1663
|
+
step: stepId,
|
|
1664
|
+
backup,
|
|
1665
|
+
ok: verdict.ok,
|
|
1666
|
+
pinned: pinned ?? null,
|
|
1667
|
+
skill: skill ? { id: skill.id, status: skill.status, steps: skill.steps.length } : null,
|
|
1668
|
+
runs: runs.map((r) => ({
|
|
1669
|
+
label: r.label,
|
|
1670
|
+
status: r.step?.status ?? 'not-reached',
|
|
1671
|
+
tier: r.step?.tier ?? null,
|
|
1672
|
+
replayed: r.step?.replayed ?? null,
|
|
1673
|
+
repinned: r.step?.repinned ?? null,
|
|
1674
|
+
turns: r.step?.turns ?? null,
|
|
1675
|
+
})),
|
|
1676
|
+
diagnostics: verdict.ok ? [] : [verdict.diagnostic],
|
|
1677
|
+
};
|
|
1678
|
+
// Diagnostics first, before the counts and the file paths.
|
|
1679
|
+
if (!verdict.ok)
|
|
1680
|
+
say(formatRerecordDiagnostic(verdict.diagnostic));
|
|
1681
|
+
if (json)
|
|
1682
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1683
|
+
else if (verdict.ok) {
|
|
1684
|
+
say(`${stepId}: pinned ${pinned}${skill ? ` (${skill.status}, ${skill.steps.length} action(s))` : ''}`);
|
|
1685
|
+
say(`${file} updated — the previous recording is at ${backup}`);
|
|
1686
|
+
}
|
|
1687
|
+
else {
|
|
1688
|
+
say(`${file} still holds the re-recorded step; restore the old one with: cp ${backup} ${file}`);
|
|
1689
|
+
}
|
|
1690
|
+
process.exit(verdict.ok ? 0 : 1);
|
|
1691
|
+
}
|
|
1692
|
+
//# sourceMappingURL=cli.js.map
|