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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Restspace
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
# sitelooper
|
|
2
|
+
|
|
3
|
+
**Drive a web app from natural language once; replay it afterwards with no model in the loop.**
|
|
4
|
+
|
|
5
|
+
sitelooper is a Playwright CLI with an LLM agent inside it. You give it one instruction at a
|
|
6
|
+
time - "sign in as ops@example.com, create a ticket titled 'k7 Bench' and report its id" - and it
|
|
7
|
+
works the live browser for you, then hands back one structured, verified result. Nothing about
|
|
8
|
+
selectors, waits, dialogs or quoting reaches you or the outer agent that is calling it.
|
|
9
|
+
|
|
10
|
+
Then, every instruction that succeeds is compiled into a **stored procedure**, and a whole session can be exported as a **flow**. The next time the same job runs,
|
|
11
|
+
sitelooper replays the procedure deterministically — no model call, no tokens — and calls the
|
|
12
|
+
model only for a step the app has changed underneath. On the benchmark below, a converged flow
|
|
13
|
+
replays a seven-step ticket workflow in 17 seconds for $0.00, verified against what the app's own
|
|
14
|
+
database says happened.
|
|
15
|
+
|
|
16
|
+
> Package and command are both `sitelooper`. State lives under `~/.sitelooper/`, env vars are
|
|
17
|
+
> `SITELOOPER_*`. The project was previously called `sleep-walker`, and `browser-pilot` before
|
|
18
|
+
> that; the old env-var prefixes and home directories still work as aliases, but the old command names do not.
|
|
19
|
+
|
|
20
|
+
## Why agent-driven browser automation does not rerun, and what sitelooper does about it
|
|
21
|
+
|
|
22
|
+
Ask any browser agent to do a job and it will, mostly. Ask it to do the same job tomorrow and you'll
|
|
23
|
+
be paying the price again: the model re-reads every page, re-decides every click, and costs
|
|
24
|
+
$1–1.50 per run on a dense app. The obvious fix - have the agent write a script from what it did -
|
|
25
|
+
doesn't work. The reasons are structural, and can't be fixed with a better prompt:
|
|
26
|
+
|
|
27
|
+
- **The run's own values are baked in.** The record it created has an id, the url has a uid, the
|
|
28
|
+
title carries a run marker. A script quotes them literally, so on the next run it opens
|
|
29
|
+
yesterday's record — or, worse, works a *different* record to completion and reports success.
|
|
30
|
+
- **The page changes every time.** Ids in class names, positional selectors
|
|
31
|
+
(`tr:nth-of-type(3)`), a textbox named after the current minute, a heading that renders only
|
|
32
|
+
after a scroll. What the agent clicked was right once; the selector it left behind names a
|
|
33
|
+
position, not a thing.
|
|
34
|
+
- **The agent's waits were implicit.** Every observation turn was a pause the app needed. A
|
|
35
|
+
script has no turns, so it runs ahead of a list that refetches a second later.
|
|
36
|
+
- **Nobody checks the effect.** A click can "succeed" on the wrong element. A save can be refused
|
|
37
|
+
by a dialog the script never saw. Codegen replays report green while the database is untouched;
|
|
38
|
+
in this benchmark the strongest static script verified 14 of 48 objectives and confirmed an
|
|
39
|
+
empty sales order.
|
|
40
|
+
|
|
41
|
+
sitelooper's answer is to treat the recording as evidence to compile, not text to replay:
|
|
42
|
+
|
|
43
|
+
- **Durable locators with fallbacks.** Each action stores a chain of candidates — role and name,
|
|
44
|
+
label, test id, a structural path last — and records which ones actually resolved on each replay,
|
|
45
|
+
so a volatile candidate is retired by measurement, not by guesswork. A click on a table row is
|
|
46
|
+
retargeted to the record's own link, whose name is its identifier. Each chain ends with where
|
|
47
|
+
the element was: its box and the viewport. That box is the yardstick a positional guess is
|
|
48
|
+
measured against, and, when every name has failed, the element at that point is taken as a last
|
|
49
|
+
candidate only if it is the same kind of control. A locator, never a blind click.
|
|
50
|
+
- **Parameters, not literals.** Values you typed become slots. Values you *declared* (`var
|
|
51
|
+
runid=k7`) become `{{runid}}`. A value one step read back and a later step used becomes
|
|
52
|
+
`{{step.output}}`, threaded live between steps. A record id that first appeared in a url after a
|
|
53
|
+
save is recognised as minted by this run and re-read from the browser on replay. What cannot be
|
|
54
|
+
threaded is left blank and sent to recovery — never guessed.
|
|
55
|
+
- **Effect gates.** Every step records what changed on the page when it ran. On replay a step that
|
|
56
|
+
ran but did not produce its recorded effect — the new title never appeared as a heading, an alert
|
|
57
|
+
the recording never saw — stops the replay before the next step acts on the wrong state. An
|
|
58
|
+
identity guard refuses to run a procedure on a page showing a different record than the one it
|
|
59
|
+
was asked for.
|
|
60
|
+
- **Built for single-page apps.** The agent's observation turns were implicit waits; a replay has
|
|
61
|
+
none, so every step first lets the DOM go quiet, and a navigation is given time to hydrate before
|
|
62
|
+
its effects are checked. A click recorded to open a popup is skipped when that popup is already
|
|
63
|
+
showing, because on a React toggle the same click would close it. A click that changed nothing
|
|
64
|
+
at all while the recording shows an effect is retried once after the page settles. A fallback
|
|
65
|
+
locator that resolves to a link leaving the app's origin is never taken.
|
|
66
|
+
- **A ladder, not a cliff.** Per step: replay the pinned procedure with zero model calls; if it
|
|
67
|
+
cannot, recover on a cheap model with the partial replay in hand; escalate to the strong model
|
|
68
|
+
only if that reports blocked; halt with per-step state only if that fails too. A recovery that
|
|
69
|
+
validates is compiled and **re-pinned into the flow**, so a flow heals itself over runs.
|
|
70
|
+
- **Honest reports.** A replayed step reports only values it read back live or that came from your
|
|
71
|
+
parameters. A value the recording captured as a literal is struck, never echoed from memory.
|
|
72
|
+
- **Nothing app-specific in the tool.** No selectors, gestures or workflow assumptions for any app
|
|
73
|
+
live in sitelooper. App knowledge goes in a per-session briefing you supply; every mechanism
|
|
74
|
+
above is described in terms any web app satisfies. This is the design boundary that keeps a fix
|
|
75
|
+
for one app from being a hack for it.
|
|
76
|
+
|
|
77
|
+
## Getting started
|
|
78
|
+
|
|
79
|
+
Requires Node 20+, an installed Chrome or Edge (or `SITELOOPER_EXECUTABLE`), and an API key for
|
|
80
|
+
one OpenAI-compatible provider.
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
npm install -g sitelooper # or, from a checkout: npm install && npm link
|
|
84
|
+
export NOVITA_API_KEY=... # any preset: zhipu, novita, openrouter, openai (see Providers)
|
|
85
|
+
sitelooper config set provider novita
|
|
86
|
+
sitelooper doctor # node, browser, provider, key — no daemon needed
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Drive a page:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
sitelooper open https://demo.playwright.dev/todomvc
|
|
93
|
+
sitelooper do "Add two todos: 'write the report' and 'send it'. Tick the first one off, then report how many items the footer counter shows as left."
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`do` returns `{status, summary, evidence}`; the counter it reports was read back from the page.
|
|
97
|
+
Add `--verbose` to watch the agent, `--headed` to watch the browser.
|
|
98
|
+
|
|
99
|
+
Record a flow and replay it:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
# 1. record: one --learn session, the caller deciding each step as it goes
|
|
103
|
+
sitelooper --session run1 --learn open http://app.local/
|
|
104
|
+
sitelooper --session run1 var runid=k7 # what will differ next time → {{runid}}
|
|
105
|
+
sitelooper --session run1 do "sign in as ops@example.com / {{env:APP_PASSWORD}} and create a ticket titled 'k7 Bench'; report its id"
|
|
106
|
+
sitelooper --session run1 do "on that ticket add a part 'k7 Part A' cost 100 markup 25; report the price"
|
|
107
|
+
sitelooper --session run1 stop --save-flow ticket-flow
|
|
108
|
+
|
|
109
|
+
# 2. replay: no caller, new value, fresh app
|
|
110
|
+
sitelooper run ticket-flow --var runid=m3 --progress
|
|
111
|
+
# [OK] 01-signin (replay) ← pinned procedure, zero model calls
|
|
112
|
+
# [OK] 02-add (replay)
|
|
113
|
+
# ticket-flow: 2/2 steps, 8s — success
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Compile to a Playwright spec
|
|
117
|
+
|
|
118
|
+
`sitelooper compile <flow-name-or-path> [--out <dir>] [--force]` takes a saved flow whose steps
|
|
119
|
+
have converged into stored procedures and emits two files: an owned `<name>.flow.ts` that carries
|
|
120
|
+
the flow as a `FLOW` constant plus one generated `async` step function per `FlowStep` (durable
|
|
121
|
+
locators, expectations, and parameter threading compiled to literal Playwright calls — no
|
|
122
|
+
sitelooper process, daemon, or model call involved), and a `<name>.spec.ts` scaffold that imports
|
|
123
|
+
`runFlow` and is written once and never touched again — it's yours to add assertions to. Re-running
|
|
124
|
+
`compile` regenerates the `.flow.ts` file (with `repair` able to patch it against a live page later)
|
|
125
|
+
but leaves an existing `.spec.ts` alone unless you pass `--force`.
|
|
126
|
+
|
|
127
|
+
Be honest about what this loses relative to a live `run`: this is Tier 2, compile-time only,
|
|
128
|
+
generated from the locator evidence a session already recorded — it does not measure anything
|
|
129
|
+
against the live page at compile time. A step whose stored procedure has no converged locator chain
|
|
130
|
+
compiles to a `throw` with a `TODO` rather than a guess, and `compile` exits 2 when any step is not
|
|
131
|
+
compilable. Point candidates (a last-resort click by screen position) are not expressible as a
|
|
132
|
+
Playwright locator and are dropped with a comment. And unlike `run`, a compiled spec has no runtime
|
|
133
|
+
recovery: if a locator has drifted since it was recorded, the spec fails outright instead of the
|
|
134
|
+
agent reasoning its way to the moved control — you get speed and zero cost per run in exchange for
|
|
135
|
+
giving up the LLM safety net.
|
|
136
|
+
|
|
137
|
+
`sitelooper flow list | show <name>` and `sitelooper skills list | show <id>` show what was
|
|
138
|
+
kept; flows are plain JSON under `~/.sitelooper/flows/`. A `run` prints per-step tier (A = zero
|
|
139
|
+
model), turns spent and drift tickets, and `--json` returns all of it.
|
|
140
|
+
|
|
141
|
+
**Diagnostics.** `compile` and `repair` both print problems as **diagnostics, first** — before
|
|
142
|
+
counts, file lists or change lists — each as a `what` / `why` / `fix` block, and both carry the
|
|
143
|
+
same list under `diagnostics` in `--json`. The point is that a broken *recording* and a broken
|
|
144
|
+
*app* look completely different once said this way, instead of spreading across four surfaces as
|
|
145
|
+
partial, sometimes contradictory hints. A step pinned to a **demoted** skill is an error: nothing
|
|
146
|
+
is written, because the recording is what's wrong, not the app —
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
$ SITELOOPER_SKILLS_DIR=fwod34-skills sitelooper compile fwod34.json --out out/
|
|
150
|
+
error 08-open: it is pinned to the demoted skill s_c86522 — the compiled spec inherits a procedure
|
|
151
|
+
whose last replays failed at the same step
|
|
152
|
+
why: s_c86522 is demoted: 1 of 4 replays succeeded; replay failed at step 1 on 3 of them; the
|
|
153
|
+
demotion was two consecutive failures at step 1; last used 2026-09-04T14:38:34.394Z.
|
|
154
|
+
fix: sitelooper rerecord fwod34.json 08-open
|
|
155
|
+
|
|
156
|
+
nothing written: the error(s) above are about the RECORDING, not the app — a compiled spec would
|
|
157
|
+
fail at a locator and read as drift.
|
|
158
|
+
re-record the step(s) with the fix command above, or pass --force to compile the demoted pin anyway.
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`--force` compiles it anyway (it already overwrites an existing `.spec.ts`; this is its second
|
|
162
|
+
meaning) — the emitted file then carries the same diagnostic as a comment above the step, and
|
|
163
|
+
appended to that step's own locator-failure message, so a red CI run points straight back here
|
|
164
|
+
instead of reading as ordinary drift.
|
|
165
|
+
|
|
166
|
+
**Goal-state steps.** A recording that mutates the app also reads its own outcome back, so a
|
|
167
|
+
step's compiled procedure carries a `goal`: the visible text ("Cancelled") that was NOT on the
|
|
168
|
+
page when the instruction began and IS once it succeeded — the positive counterpart of
|
|
169
|
+
`preconditions.requireText`. A goal is derived only when the instruction stayed on one page
|
|
170
|
+
template, because only then is the pre-state the same page the outcome was read from. Both the replay engine (before it ever tries the zero-model path)
|
|
171
|
+
and a compiled spec (a guard at the top of the step body) check identity AND every goal text
|
|
172
|
+
against the LIVE page before a mutating step acts (and only on the page template the goal was
|
|
173
|
+
read on, so a list row's "Cancelled" for some other order never counts); when both already hold, the step succeeds
|
|
174
|
+
having done nothing, publishing the same values its read-backs would have from the stored report
|
|
175
|
+
template:
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
[flow fwod34] 08-open: already satisfied — page shows "Cancelled" for "S00021"; nothing to do
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`run`'s per-step line prints `satisfied` where it would otherwise print `replay` or `agent`. The
|
|
182
|
+
emitted spec's guard is the same check, inlined at compile time so no model is needed to run it:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
// goal: the page already showing "Cancelled" for this record means the step's work is done —
|
|
186
|
+
// the same check replay makes before it acts (goalSatisfied, src/skills/replay.ts).
|
|
187
|
+
if (await satisfied(page, [p.v1], ["Cancelled"])) {
|
|
188
|
+
console.log('[sitelooper satisfied] 08-open — page shows "Cancelled"; nothing to do');
|
|
189
|
+
outputs['08-open.order_status'] = 'Cancelled';
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
This is what turns a retry step harmless: fwod34's 08-open was recorded asking to cancel an order
|
|
195
|
+
06-open had already cancelled; on replay 06-open's cancel lands cleanly and 08-open, told the same
|
|
196
|
+
thing, now finds the goal already showing instead of hunting for a Cancel button that is not
|
|
197
|
+
there. A false negative just runs the step as before — the check never skips work that has not
|
|
198
|
+
actually happened.
|
|
199
|
+
|
|
200
|
+
**Contradicted steps.** The same recording carries the fact that would have caught this at export
|
|
201
|
+
time, if 06-open's cancel had *reported* landing when it did not: a read-only step immediately
|
|
202
|
+
after a mutating one is a free check on that mutating step's own report. `buildFlow` compares
|
|
203
|
+
them directly — same label (or, failing that, any label that names a status/state field) — and
|
|
204
|
+
when neither value's first line contains the other, it is a real contradiction, not just a
|
|
205
|
+
fuller status bar next to a short one:
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
warning 07-open: 07-open read a value that contradicts what the previous step reported
|
|
209
|
+
why: contradicted-step: 07-open read order_status "Sales Order" right after 06-open reported
|
|
210
|
+
"Cancelled"; 06-open's change may not have landed and a later step may be retrying it.
|
|
211
|
+
Re-record 06-open.
|
|
212
|
+
fix: sitelooper rerecord fwod34.json 06-open
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Like a `noop-step`, this is a record-time fact riding on `flow.warnings` that `compile`
|
|
216
|
+
re-surfaces as a `contradicted-step` diagnostic, `fix` pointing at re-recording the mutating step
|
|
217
|
+
— the one whose recording, not the app, needs another look.
|
|
218
|
+
|
|
219
|
+
**The loop.** Once compiled, the `.spec.ts` runs under plain `@playwright/test` — no sitelooper
|
|
220
|
+
process, no model, nothing but the two generated files and Playwright itself. Each locator call is
|
|
221
|
+
a `pick()` fallthrough over the candidates recorded at compile time, tried in recorded order; if
|
|
222
|
+
the primary misses and a later candidate resolves, that's drift, not failure — the test still
|
|
223
|
+
passes, but `pick()` prints a `[sitelooper drift] ...` line and appends it to the `.flow.ts`'s
|
|
224
|
+
exported `DRIFT` array, so a CI report or your own `.spec.ts` assertion can surface it without
|
|
225
|
+
grepping stderr.
|
|
226
|
+
|
|
227
|
+
When drift shows up (or the spec goes red outright), `sitelooper repair <name.flow.ts> --var k=v
|
|
228
|
+
[--converge n]` closes the loop: it lifts the owned file back to its IR, replays it against the
|
|
229
|
+
*live* app in an isolated temp store (never touching `~/.sitelooper`), and lets the recovery
|
|
230
|
+
ladder adapt it — a resolved fallback is promoted with a pure codemod, no model; a chain that's
|
|
231
|
+
gone dead gets one new locator proposed and verified by the model on the live page; a segment that
|
|
232
|
+
needs re-recording is reported, never attempted. It then prints a reviewer-readable change list
|
|
233
|
+
("candidate promoted", "new locator", "chain reordered"), and only if `--converge n` further real
|
|
234
|
+
runs come back as clean tier-A replays with no drift does it rewrite the `.flow.ts` — the
|
|
235
|
+
`.spec.ts` is never touched. A record-creating flow needs a fresh identity each of those runs;
|
|
236
|
+
`{n}` in a `--var` value is replaced by the run number (`--var runid=fix-{n}` becomes `fix-0`,
|
|
237
|
+
`fix-1`, ...). That gives each run its own records but not its own *app* — everything the
|
|
238
|
+
previous run left behind is still there — so `--reset-cmd "<shell command>"` runs a command of
|
|
239
|
+
your choosing before run 1 and before every converge run (`--reset-cmd "curl -s -X POST
|
|
240
|
+
http://127.0.0.1:4180/__reset"`). It runs through a shell and a non-zero exit aborts the
|
|
241
|
+
repair: a converge pass over an app that was not reset is a verdict about nothing.
|
|
242
|
+
|
|
243
|
+
Repair also folds each run's evidence back into the chains as a pure codemod, no model: a
|
|
244
|
+
candidate that has never resolved and has now missed on two runs is sorted to the *back* of its
|
|
245
|
+
chain and reported as `candidate retired: <expr> — missed 2 run(s), never hit; now last`.
|
|
246
|
+
Evidence outranks kind, with one exception — a structural css path never floats over an
|
|
247
|
+
identity or handle candidate that has actually resolved. Once a candidate is retired this way
|
|
248
|
+
the fallthrough that names it stops counting against `--converge`: the spec now records that
|
|
249
|
+
fact, so re-observing it is not new drift. Without that rule one chronically volatile locator
|
|
250
|
+
keeps the gate from ever clearing.
|
|
251
|
+
|
|
252
|
+
One thing `repair` cannot see on its own: every gate above runs the *IR* through the daemon, so a
|
|
253
|
+
defect in the **emitter** — a locator that lowers fine for replay and transpiles to a Playwright
|
|
254
|
+
call that never resolves — passes convergence and still ships a red spec. (That is exactly what
|
|
255
|
+
happened on kanboard: "converged, 5/5, no changes", file written, spec failing deterministically
|
|
256
|
+
under plain Playwright.) `--check-spec` closes it: after the owned file is written, the sibling
|
|
257
|
+
`.spec.ts` is run **once** under plain `@playwright/test` — a minimal generated config, headless,
|
|
258
|
+
one worker, 60 s per test, `--var` values passed in as `process.env.<VAR>` the way the scaffold
|
|
259
|
+
reads them, the same `--reset-cmd` first — and the JSON report is turned into one line:
|
|
260
|
+
|
|
261
|
+
```
|
|
262
|
+
spec check: passed in 8 s, 0 drift
|
|
263
|
+
spec check: FAILED at @step 01-open s_8d7c18/2 — Error: none of 1 recorded locators resolved:
|
|
264
|
+
getByTestId('field-nonsense-broken') — this is an emitter defect, not drift: the live replay
|
|
265
|
+
passed this step
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
A failed check does **not** un-write the file — the repair may well have adapted the locator
|
|
269
|
+
correctly, and the diff is still yours to review — but the exit code becomes `4`. When
|
|
270
|
+
`@playwright/test` can't be resolved from the project the check says so and is skipped, never
|
|
271
|
+
failed. The same run is available on its own as `sitelooper check <name.flow.ts> --var k=v
|
|
272
|
+
[--reset-cmd "<cmd>"] [--json]`, which needs no daemon and no model; `--json` puts the whole
|
|
273
|
+
verdict under `specCheck`, in `repair`'s report too.
|
|
274
|
+
|
|
275
|
+
That "emitter defect" wording is only correct when the failing step is clean. When repair's own
|
|
276
|
+
convergence runs already flagged the anchor step with a `needs-rerecord` diagnostic (see below),
|
|
277
|
+
`--check-spec` says so instead:
|
|
278
|
+
|
|
279
|
+
```
|
|
280
|
+
spec check: FAILED at @step 08-open — the step's recording is the problem, not the emitter:
|
|
281
|
+
08-open only passes because the engine replays s_fcb896 (read-only) instead of its demoted pin
|
|
282
|
+
s_c86522; a compiled spec halts here
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**When the recording, not the app, is what's wrong.** Sometimes every converge run passes —
|
|
286
|
+
`repair` reports "9/9 tier A, no change" — only because the replay engine is quietly covering a
|
|
287
|
+
broken pin with some *other* learned skill (it refuses to re-pin a read-only skill over a
|
|
288
|
+
mutating one, `canAdoptPin`). A compiled spec has no such fallback, so it still halts. `repair`
|
|
289
|
+
now catches this: a step whose pin is demoted, or whose converge runs are covered end-to-end by a
|
|
290
|
+
skill other than its pin, gets a `needs-rerecord` diagnostic instead of counting as converged —
|
|
291
|
+
printed first, `wrote: null`, exit `1` — instead of a silent, misleading "no change".
|
|
292
|
+
|
|
293
|
+
Exit codes matter here: `2` means the file was hand-edited or otherwise refused outright (not a
|
|
294
|
+
sitelooper flow file, or a missing `--var`); `3` means the repair itself worked but the convergence
|
|
295
|
+
gate didn't hold; `4` means it converged and the file was written but the emitted `.spec.ts` failed
|
|
296
|
+
its `--check-spec` run; `1` covers both "the repair would have dropped an expectation" (refused — an
|
|
297
|
+
assertion that no longer holds is a test failure for a human, not drift) and "nothing could be
|
|
298
|
+
repaired without re-recording". The intended workflow is a pull request, not a background daemon:
|
|
299
|
+
CI runs the spec and fails loud on drift; a developer, or a scheduled agent picking up the failure,
|
|
300
|
+
runs `repair` and opens the diff for review.
|
|
301
|
+
|
|
302
|
+
```
|
|
303
|
+
$ npx playwright test fwrd42.spec.ts
|
|
304
|
+
✓ fwrd42 (6.0s)
|
|
305
|
+
$ npx playwright test fwrd42.spec.ts # after the app renamed a button
|
|
306
|
+
✓ fwrd42 (6.1s)
|
|
307
|
+
[sitelooper drift] 02-add s_05e528/1 target: primary getByTestId('add-part') missed; used #2 getByRole('button', { name: 'Add part', exact: true })
|
|
308
|
+
$ sitelooper repair fwrd42.flow.ts --var runid=fix-{n} --converge 1 \
|
|
309
|
+
--reset-cmd "curl -s -X POST http://127.0.0.1:4180/__reset"
|
|
310
|
+
02-add: candidate promoted: page.getByRole('button', { name: 'Add part', exact: true }) now primary (was #1)
|
|
311
|
+
candidate retired: page.getByText('{{v4}}', { exact: true }) — missed 2 run(s), never hit; now last — s_640d6e step 4 target
|
|
312
|
+
wrote fwrd42.flow.ts (14 change(s); the .spec.ts was not touched)
|
|
313
|
+
$ sitelooper repair fwrd42.flow.ts --var runid=fix-{n} --converge 1 --check-spec \
|
|
314
|
+
--reset-cmd "curl -s -X POST http://127.0.0.1:4180/__reset"
|
|
315
|
+
...
|
|
316
|
+
wrote fwrd42.flow.ts (14 change(s); the .spec.ts was not touched)
|
|
317
|
+
spec check: passed in 8 s, 0 drift
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Be honest about what the loop still doesn't give back, even after `repair`: this stays Tier 2 —
|
|
321
|
+
point candidates (a last-resort click by screen position) are still unexpressible and dropped at
|
|
322
|
+
compile time; there's no live chain measurement, so the spec learns from a run only when `repair`
|
|
323
|
+
is invoked, never continuously; there's no loop cursor across records; and a moved control still
|
|
324
|
+
fails the *run that discovered it* before repair can act — recovery in a compiled spec is a
|
|
325
|
+
follow-up PR, never a live save.
|
|
326
|
+
|
|
327
|
+
**Re-recording one step.** When a diagnostic's `fix` says `sitelooper rerecord`, that step's
|
|
328
|
+
*recording* is the problem — not a locator, a candidate to promote, or the app — so `repair`'s
|
|
329
|
+
locator-level machinery can't help. `sitelooper rerecord <flow-name-or-path> <step-id>
|
|
330
|
+
[--instruction "<text>"] [--var k=v ...] [--runs n] [--reset-cmd "<cmd>"] [--json]` re-records just
|
|
331
|
+
that one step: it backs the flow file up as `<file>.bak-<stamp>.json`, throws away the step's pin,
|
|
332
|
+
params and recorded values (keeping its declared outputs), and replays the flow `--runs` times
|
|
333
|
+
(default 2) in learning mode so the agent records the step afresh and the store's own re-pin rule
|
|
334
|
+
decides whether to keep the new procedure. It only succeeds when the *last* run replays the step at
|
|
335
|
+
tier A on the new pin; each run is a real run against the app, so mint per-run values with `{n}` or
|
|
336
|
+
reset the app with `--reset-cmd`, same as `repair --converge`. Whatever the daemon says about the
|
|
337
|
+
step while a run is on it — above all a re-pin refusal such as `not re-pinning s_04d970 — slot(s)
|
|
338
|
+
v2 identify the record but carry no origin to rebind from` — is printed under the run's line whether
|
|
339
|
+
or not `--progress` is on, and quoted in the `needs-rerecord` diagnostic when nothing was pinned, so
|
|
340
|
+
a refused re-record says *why* rather than just "no procedure".
|
|
341
|
+
|
|
342
|
+
When the re-recorded step turns out to be a procedure another step of the same flow already pins
|
|
343
|
+
(08-open, re-recorded as a read-only check, is covered by 07-open's status check), the new pin
|
|
344
|
+
shares that skill and inherits the sibling step's bindings for any slot the store recorded no
|
|
345
|
+
origin for. Read-only skills may be shared between steps; a mutating skill is still exclusive to
|
|
346
|
+
one step, and a step whose instruction asks for a change never adopts a read.
|
|
347
|
+
|
|
348
|
+
fwod34's step 08-open is exactly the demoted-pin case above: its instruction — written by the
|
|
349
|
+
recording orchestrator — asks to cancel an order that step 06 had already cancelled, so the
|
|
350
|
+
skill's first action clicks a Cancel button that is never there on replay. `--instruction` replaces
|
|
351
|
+
the recorded ask with a read-only one, so re-recording produces a check instead of repeating the
|
|
352
|
+
mistake:
|
|
353
|
+
|
|
354
|
+
```
|
|
355
|
+
$ sitelooper rerecord fwod34.json 08-open \
|
|
356
|
+
--instruction "Open the sales order {{v1}} and report its current status; do not change it."
|
|
357
|
+
re-recording fwod34 step 08-open (2 run(s))
|
|
358
|
+
unpinned s_c86522, with a new instruction; old recording kept at fwod34.json.bak-lz3x9k.json
|
|
359
|
+
run 1: 08-open agent (6 turns) re-pinned s_fcb9a1
|
|
360
|
+
fwod34: 6/6 step(s) success
|
|
361
|
+
run 2: 08-open replay tier A (s_fcb9a1)
|
|
362
|
+
fwod34: 6/6 step(s) success
|
|
363
|
+
08-open: pinned s_fcb9a1 (active, 1 action(s))
|
|
364
|
+
fwod34.json updated — the previous recording is at fwod34.json.bak-lz3x9k.json
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
**Sizing an instruction.** One `do` is one logical, verifiable step: a goal plus the check that it
|
|
368
|
+
worked. Several UI actions inside one instruction is normal — that is the point. Too big (several
|
|
369
|
+
unrelated goals) stalls on planning; too small (one click) pays an agent loop for what `peek` gives
|
|
370
|
+
free.
|
|
371
|
+
|
|
372
|
+
**Briefing.** Everything the DOM will not tell an agent about your app goes in a page of markdown
|
|
373
|
+
loaded with `brief <file.md>`: where things are, house conventions ("Apply only previews, Save
|
|
374
|
+
persists"), credentials as `{{env:NAME}}` markers, what not to touch.
|
|
375
|
+
|
|
376
|
+
### The full command set
|
|
377
|
+
|
|
378
|
+
```sh
|
|
379
|
+
sitelooper open <url> | brief <file.md> | note "<text>" | peek [--selector css] | screenshot [path]
|
|
380
|
+
sitelooper do "<instruction>" [--json] [--progress] [--max-turns N] [--timeout S] [--no-escalate]
|
|
381
|
+
sitelooper var <name>=<value> # declare a run variable (learning session)
|
|
382
|
+
sitelooper skills list | show <id> | rm <id> | repair --drift <run-drift.json>
|
|
383
|
+
sitelooper flow list | show <name>
|
|
384
|
+
sitelooper run <flow> [--var k=v ...] [--json] [--progress]
|
|
385
|
+
sitelooper script [out.spec.ts] # emit a plain Playwright spec from the recorded actions
|
|
386
|
+
sitelooper compile <flow-name-or-path> [--out <dir>] [--force] [--json]
|
|
387
|
+
# compile a converged flow to a standalone spec;
|
|
388
|
+
# diagnostics (what/why/fix) print first; a step
|
|
389
|
+
# pinned to a demoted skill refuses to write unless
|
|
390
|
+
# --force
|
|
391
|
+
sitelooper repair <name.flow.ts> [--var k=v ...] [--out <file>] [--converge <n>]
|
|
392
|
+
[--reset-cmd "<shell command>"] [--check-spec] [--dry-run]
|
|
393
|
+
[--model M] [--json]
|
|
394
|
+
# replay a compiled flow against the live app and
|
|
395
|
+
# fold the adaptation back into the owned .flow.ts;
|
|
396
|
+
# --reset-cmd runs before run 1 and every converge run;
|
|
397
|
+
# --check-spec then runs the emitted .spec.ts once
|
|
398
|
+
# under plain Playwright (exit 4 if it fails); a step
|
|
399
|
+
# whose recording, not the app, is broken gets a
|
|
400
|
+
# needs-rerecord diagnostic and exit 1 instead
|
|
401
|
+
sitelooper rerecord <flow-name-or-path> <step-id> [--instruction "<text>"] [--var k=v ...]
|
|
402
|
+
[--runs n] [--reset-cmd "<cmd>"] [--json]
|
|
403
|
+
# re-record ONE step whose pin is demoted or
|
|
404
|
+
# covered by another skill; unpins it, optionally
|
|
405
|
+
# replaces its instruction, and replays --runs
|
|
406
|
+
# times (default 2) in learning mode
|
|
407
|
+
sitelooper check <name.flow.ts> [--var k=v ...] [--reset-cmd "<cmd>"] [--json]
|
|
408
|
+
# run the emitted .spec.ts once under plain
|
|
409
|
+
# @playwright/test and report the verdict
|
|
410
|
+
sitelooper session list | stop [--all] [--save-flow <name>]
|
|
411
|
+
sitelooper doctor | config | config set <key> <value>
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Global flags: `--session <name>` (one daemon and browser per session), `--learn`, `--headed`,
|
|
415
|
+
`--record` (webm per tab), `--script`, `--verbose`, `--progress`, `--json`. Exit codes: `0`
|
|
416
|
+
succeeded, `1` failed or blocked, `2` infrastructure (no key, no browser, LLM unreachable).
|
|
417
|
+
|
|
418
|
+
## Current matrix
|
|
419
|
+
|
|
420
|
+
Two questions decide whether the tool earns its place. **First contact**: given a goal it has
|
|
421
|
+
never seen, how does sitelooper compare with the incumbents? **Every run after that**: once the
|
|
422
|
+
flow is known, what does repeating it cost, and does it stay correct? Success is always the
|
|
423
|
+
app-side verifier's count (mutation log, JSON-RPC or HTTP API state), never an arm's self-report.
|
|
424
|
+
All cells are cloud runs on identical hardware, one box per target; full detail in
|
|
425
|
+
[bench/MATRIX-SUMMARY.md](bench/MATRIX-SUMMARY.md).
|
|
426
|
+
|
|
427
|
+
**Matrix 1 — first contact.** sitelooper: set 26 (2026-09-03, build e048128; glm-5.3
|
|
428
|
+
orchestrator, deepseek-v4-flash inner with glm-5.3 escalation). agent-browser: set 17, same era,
|
|
429
|
+
glm-5.3.
|
|
430
|
+
|
|
431
|
+
| target | sitelooper | agent-browser |
|
|
432
|
+
|---|---|---|
|
|
433
|
+
| repairdesk (in-repo SPA) | 7/7 · $0.07 · 1212s (set 28; set 26: 7/7 · $0.09 · 819s) | 6/6 · $0.19 · 67s |
|
|
434
|
+
| kanboard (PHP, drag-and-drop) | 6/6 · $0.21 · 1078s (set 28; set 26: 6/6 · $0.04 · 385s) | **2/6 (turn-cap)** · $0.77 · 118s |
|
|
435
|
+
| grafana (React SPA) | 6/6 · $0.14 · 1381s (set 28; set 26: 6/6 · $0.48 · 2037s) | 6/6 · $1.05 · 448s |
|
|
436
|
+
| odoo (dense CRUD) | 6/6 · $0.38 · 1451s (set 28d; set 26: 6/6 · $0.59 · 1651s) | 6/6 · $1.51 · 302s |
|
|
437
|
+
| atelyr (private React app, local) | 2/2 checkable · $0.76 · 2557s (set 28e; set 28: 6 reported, 2/2 checkable · $1.43 · 3043s) | — |
|
|
438
|
+
|
|
439
|
+
On first contact sitelooper is the slowest arm on every target, by design: it drives a cheap
|
|
440
|
+
inner model and spends the extra time recording verified locators, value provenance and effect
|
|
441
|
+
expectations. What that buys is the lowest cost on every target (2–19× cheaper), a 25/25 objective
|
|
442
|
+
record including the board that turn-capped agent-browser at 2/6, and the recording that makes
|
|
443
|
+
Matrix 2 exist.
|
|
444
|
+
|
|
445
|
+
**Matrix 2 — every run after the first.** The same four flows repeated: sitelooper replays (set
|
|
446
|
+
24, two replays each) against re-running the agent, against a Playwright script the agent authored
|
|
447
|
+
from its own run, against literal codegen from the recording, and against **Tier 2 spec** — the
|
|
448
|
+
same recording compiled by `sitelooper compile` into a standalone `@playwright/test` spec with no
|
|
449
|
+
sitelooper runtime in the loop at all, then replayed under the real Playwright test runner
|
|
450
|
+
(`bench/spec-replay.mjs`).
|
|
451
|
+
|
|
452
|
+
| target | sitelooper replay (r1, r2) | agent re-run | authored script | codegen | Tier 2 spec |
|
|
453
|
+
|---|---|---|---|---|---|
|
|
454
|
+
| repairdesk | **7/7, 7/7** · $0.00, $0.00 · 25s, 25s (set 31, m4rd on d28346a; every step at tier A, zero model turns; set 30 on a7f0c6e: 55s, 55s before the late-navigation fix; set 28: 24s, 23s) | 6/6 · $0.19 · 67s every time | 1/6, 1/6 · $0 | 6/6, 6/6 · $0 | **6/6, 6/6** · $0.00 · 15s, 15s (set 31, m4rd; 0 drift; repair converged with 4 candidate promotions, spec check passed in 14s; repaired spec 6/6 in 14s) |
|
|
455
|
+
| kanboard | **4/4 checkable, same** · $0.00, $0.00 · 27s, 27s (set 31, m4kb on d28346a; all five steps at tier A, zero turns; two objectives are report-based and a zero-model replay writes no report; set 30: 45s, 45s; set 28: 23s, 23s) | 2/6 · $0.77 · 118s every time | 5/6, 5/6 · $0 | 4/4 (+2 n/a) · $0 | **4/4 checkable, same** · $0.00 · 15s, 14s (set 31, m4kb; 0 drift; repair converged with no change, spec check passed in 13s; repaired spec 4/4 in 14s) |
|
|
456
|
+
| grafana | **6/6, 6/6** · $0.00, $0.00 · 54s, 54s (set 31, m4gr on d28346a; every step at tier A, zero model turns; set 30: 79s, 79s; set 28: 47s, 47s) | 6/6 · $1.05 · 448s every time | 0/6, 0/6 · $0 | 0/6, 0/6 · $0 | **4/6, 4/6** · $0.00 · 33s, 33s (set 31, m4gr; 0 drift; objectives 1 and 6 unverifiable by design, the spec arm writes no finalText; repair converged with 6 changes: 3 promotions, 1 model-proposed heading locator, 2 never-hit point candidates retired; spec check passed in 33s; repaired spec 4/6 in 34s) |
|
|
457
|
+
| odoo | **6/6, 6/6** · $0.02, $0.01 · 787s, 556s (set 31, m4od2 on d28346a, flow fwod34r3; model-bound, so the click-wait fix barely shows: 06-open fell back to the model on both replays, 24 then 39 turns, because its pinned skill's precondition names the order LIST page while the flow arrives on the order FORM, a store defect in 06-open's recording; r1 also lost 16 turns to a one-off sign-in fallback; set 30: 258s, 568s; set 28d on fwod34: 664s, 243s) | 6/6 · $1.51 · 302s every time | 1/6, 1/6 · $0 | 0/6, 0/6 · $0 | **6/6, 6/6** · $0.00 · 79s, 79s (set 31, m4od2; 0 drift both runs; repair 9/9 ×3 at tier A with no change, spec check passed in 77s; repaired spec 6/6 in 78s; set 30: 77s, 77s) |
|
|
458
|
+
| atelyr | 12/12 flow steps · $0.13, $0.43 · 710s, 1002s (set 28e; 114 then 134 model turns; nine of twelve steps at zero turns on the second replay, the three re-pinned steps among them) | — | — | — | not yet run |
|
|
459
|
+
|
|
460
|
+
**Set 31 (d28346a), the current build.** The set 30 routine rerun on one fix. Set 30's zero-model
|
|
461
|
+
replays had run at roughly twice set 28's wall clock; bisected locally on the repairdesk recording to
|
|
462
|
+
93ac1f7, where the recorder's "give a click's late navigation a moment" made every click, press and
|
|
463
|
+
select in learning mode wait 1.5s unless the url moved, and replays run in learning mode. d28346a
|
|
464
|
+
ends that wait as soon as the page has no request in flight. Same boxes, same recordings, same
|
|
465
|
+
scores, and the replay wall clocks came back: repairdesk 55s → 25s, kanboard 45s → 27s, grafana
|
|
466
|
+
79s → 54s, all at zero model turns. Odoo did not move (258s, 568s → 787s, 556s) because its
|
|
467
|
+
replays are model-bound: 06-open falls back to the model on every run of fwod34r3 (its recorded
|
|
468
|
+
precondition names the order list page and the flow arrives on the order form), so the wall clock
|
|
469
|
+
is model turns, not click waits; the first set 31 odoo box also ran the wrong flow (fwod34, whose
|
|
470
|
+
demoted 08-open needs the model) because fwod34r3 was only on a results branch, so it was
|
|
471
|
+
published on this branch at 16f9a4b and the target relaunched as m4od2. Every compiled spec passed
|
|
472
|
+
both runs with 0 drift at the same scores as set 30, every repair converged with its spec check
|
|
473
|
+
passing, and every repaired spec passed again. No goal-state guard fired and no diagnostic
|
|
474
|
+
appeared: the published stores predate goals, so no skill carries one yet.
|
|
475
|
+
|
|
476
|
+
| target | sitelooper replay r1, r2 (set 30 → set 31) | verifier | compiled spec a, b | repair | repaired spec | agent-browser, every run |
|
|
477
|
+
|---|---|---|---|---|---|---|
|
|
478
|
+
| repairdesk | 55s → **25s, 25s** · 0 turns · $0 | 7/7, 7/7 | 6/6, 6/6 · 15s, 15s | converged, 4 promotions, check passed | 6/6 · 14s | 6/6 · $0.19 · 67s |
|
|
479
|
+
| kanboard | 45s → **27s, 27s** · 0 turns · $0 | 4/4 checkable ×2 | 4/4 ×2 · 15s, 14s | no change, check passed | 4/4 · 14s | 2/6 · $0.77 · 118s |
|
|
480
|
+
| grafana | 79s → **54s, 54s** · 0 turns · $0 | 6/6, 6/6 | 4/6 ×2 · 33s, 33s | converged, 6 changes, check passed | 4/6 · 34s | 6/6 · $1.05 · 448s |
|
|
481
|
+
| odoo | 258s, 568s → 787s, 556s · 40, 39 turns · $0.02, $0.01 | 6/6, 6/6 | 6/6, 6/6 · 79s, 79s | no change, check passed | 6/6 · 78s | 6/6 · $1.51 · 302s |
|
|
482
|
+
|
|
483
|
+
Read across a row: the replay is the daemon re-running the recording with no orchestrator, the
|
|
484
|
+
compiled spec is the same recording under plain Playwright with no sitelooper runtime and no model
|
|
485
|
+
at all, and agent-browser is what it costs to have an agent do the task again from scratch. On the
|
|
486
|
+
three targets whose replays need no model turns, the replay beats agent-browser by 2.7×, 4.4× and
|
|
487
|
+
8.3× on wall clock at zero cost, and the compiled spec by 4.5×, 8.4× and 13.6×. Odoo's replay is
|
|
488
|
+
the one still paying for model turns, and its compiled spec runs the same flow in 79s.
|
|
489
|
+
|
|
490
|
+
**Tier 2 spec, status.** `bench/spec-replay.mjs` compiles a published flow + skill store
|
|
491
|
+
(`sitelooper compile <flow> --out <tmp>` with `SITELOOPER_SKILLS_DIR` pointing at the store) and
|
|
492
|
+
runs the emitted `<name>.spec.ts` under `npx playwright test`, scored by the same app-side
|
|
493
|
+
verifiers as every other arm (`<tag>-spec-result.json`, `arm: "spec"`). Repairdesk ran locally
|
|
494
|
+
(`fwrd42` store/flowrun, verified 6/6 with the clean-run mutation log); kanboard, grafana, and
|
|
495
|
+
odoo ran on the bench's cloud environment across sets 1-8, with results published to
|
|
496
|
+
`origin/results/sp<N><target>` branches (`sp3kb`, `sp7gr`, `sp8od`, `sp11od`). Kanboard's compiled
|
|
497
|
+
spec passed 4/4 checkable objectives on both runs and repair converged with 0 tickets. Grafana's
|
|
498
|
+
compiled spec ran 1/1 with 0 drift on both runs (verifier 4/6, the other two objectives
|
|
499
|
+
unverifiable by design since the spec arm writes no report) and repair converged in two rounds
|
|
500
|
+
to a spec that still passes and still scores 4/6. Odoo's compiled spec passes end to end on the
|
|
501
|
+
cloud (`sp11od`, f838bec): 1/1 with 0 drift and 6/6 on both runs in 78s each, repair 9/9 at tier
|
|
502
|
+
A on all three runs with nothing to change and its spec check passing, and the repaired spec
|
|
503
|
+
1/1 and 6/6 again. That took eight sets of emitter fixes (`sp8od` verified 6/6 but the test
|
|
504
|
+
halted at 08-open) and then one `sitelooper rerecord` of 08-open, whose recording asked to cancel
|
|
505
|
+
an order 06-open had already cancelled: the step is now pinned to 07-open's validated read-only
|
|
506
|
+
status check, which the store lets two steps share.
|
|
507
|
+
|
|
508
|
+
Set 24 also caught two engine regressions of its own (kanboard's replays at 22 and 37 turns
|
|
509
|
+
where set 15 needed none; grafana's replays losing objective 1 and recovering one step at 19 and
|
|
510
|
+
44 turns). Every cause was a testable engine rule — a clock-stamped textbox name in an
|
|
511
|
+
expectation, a trailing space in an identity marker, an expectation-only value promoted to a
|
|
512
|
+
required parameter, a heading that renders only on scroll — and all are fixed on build f727c89.
|
|
513
|
+
The clean A/B is to replay the same set-24 flows and stores on the fixed build (set 24b):
|
|
514
|
+
|
|
515
|
+
| target | set 24 replays (b9ccbca) | set 24b replays (f727c89) |
|
|
516
|
+
|---|---|---|
|
|
517
|
+
| kanboard | 22 and 37 turns · 272s, 555s | **0 and 0 turns · 56s, 56s** · 4/4 app-state objectives both |
|
|
518
|
+
| grafana | 4/6, 5/6 · 19 and 44 turns | **6/6, 6/6** · 29 and 44 turns · 151s, 272s on 08cf104, with the same recording's flow re-exported by the fixed engine (one export rule needed that) and paired with its replay-refined store |
|
|
519
|
+
| odoo (set 26 recording) | 6/6, 6/6 · 91 and 35 turns | **6/6 · 31 turns · 213s** on 6ad5cde with the same pairing; the rest is the app's own url state varying between runs |
|
|
520
|
+
|
|
521
|
+
The grafana row shows the shape of most of this work: the set-24 grafana
|
|
522
|
+
cell as recorded was 4/6 and 5/6, and each miss was a rule in the engine
|
|
523
|
+
(a read discounted as an echo of a recorded scroll; a flow that referenced
|
|
524
|
+
a typed value as another step's output). Fixing the rules and re-exporting
|
|
525
|
+
the same recording gives 6/6 on both replays. Fresh recordings since then
|
|
526
|
+
(fwgr24, fwgr25, fwgr26) each added a rule of the same kind — an accidental
|
|
527
|
+
"Discard changes?" dialog, a dialog opened and cancelled, transient status
|
|
528
|
+
and alert lines — until fwgr26 compiled clean and instead lost every replay to an
|
|
529
|
+
error page. Five runs were spent finding out why: the sign-in skill carried
|
|
530
|
+
a recorded stray click on a `target=_blank` link to grafana.com, the box has
|
|
531
|
+
no network, the new tab landed on a browser error page, and the daemon
|
|
532
|
+
adopted that tab as the page to work on. The replay now keeps its page
|
|
533
|
+
whatever tabs open, a tab that lands on an error page is closed, and a
|
|
534
|
+
fallback that resolves to a link leaving the recorded origin is never
|
|
535
|
+
taken. Full detail, including the
|
|
536
|
+
runs that did not work, is in [bench/MATRIX-SUMMARY.md](bench/MATRIX-SUMMARY.md).
|
|
537
|
+
|
|
538
|
+
Reading it: static scripts are free and mostly wrong; re-running the agent is reliable and costs
|
|
539
|
+
the full price forever; sitelooper's repeat cost trends to zero without the correctness trending
|
|
540
|
+
anywhere, and where it does not, the cause has so far always been a specific engine rule rather
|
|
541
|
+
than the app.
|
|
542
|
+
|
|
543
|
+
## Reference
|
|
544
|
+
|
|
545
|
+
### Providers
|
|
546
|
+
|
|
547
|
+
The LLM layer is a generic OpenAI-compatible adapter with presets; any endpoint works by setting
|
|
548
|
+
`baseUrl` and `model` directly.
|
|
549
|
+
|
|
550
|
+
| Preset | Base URL | Default model | Escalation model | Key env var |
|
|
551
|
+
|---|---|---|---|---|
|
|
552
|
+
| `zhipu` (default) | `https://api.z.ai/api/paas/v4` | `glm-5.2` | — | `GLM_API_KEY` / `ZHIPU_API_KEY` |
|
|
553
|
+
| `novita` | `https://api.novita.ai/openai` | `deepseek/deepseek-v4-flash` | `zai-org/glm-5.3` | `NOVITA_API_KEY` |
|
|
554
|
+
| `openrouter` | `https://openrouter.ai/api/v1` | `z-ai/glm-5.2` | — | `OPENROUTER_API_KEY` |
|
|
555
|
+
| `openai` | `https://api.openai.com/v1` | `gpt-5-mini` | — | `OPENAI_API_KEY` |
|
|
556
|
+
|
|
557
|
+
Every field resolves **flag > env > config file > preset**: `--provider`, `--model`,
|
|
558
|
+
`--base-url`, `--fallback-model`; `SITELOOPER_PROVIDER`, `SITELOOPER_MODEL`,
|
|
559
|
+
`SITELOOPER_FALLBACK_MODEL`, `SITELOOPER_BASE_URL`, `SITELOOPER_API_KEY`;
|
|
560
|
+
`sitelooper config set <provider|model|fallbackModel|baseUrl|apiKey> <value>` →
|
|
561
|
+
`~/.sitelooper/config.json`. Prefer env for the key. The benchmark stack is
|
|
562
|
+
`SITELOOPER_PROVIDER=openrouter`, model `deepseek/deepseek-v4-flash`, fallback `z-ai/glm-5.3`.
|
|
563
|
+
|
|
564
|
+
**Escalation on blocked.** An instruction the routine model reports as `blocked` is retried once
|
|
565
|
+
on the escalation model, on the same browser and history, told it is resuming so it re-checks
|
|
566
|
+
state before repeating anything that could double-apply. A verified `failure` is not retried, nor
|
|
567
|
+
is an operator stop. Both attempts are billed into the returned `turns` and `usage`; the report's
|
|
568
|
+
`escalation` object says whether the retry rescued it. `--no-escalate`, or a fallback model of
|
|
569
|
+
`none`, turns it off.
|
|
570
|
+
|
|
571
|
+
### Configuration
|
|
572
|
+
|
|
573
|
+
| Env / flag | Default | |
|
|
574
|
+
|---|---|---|
|
|
575
|
+
| `SITELOOPER_CHANNEL` | `chrome` → `msedge` → bundled | browser channel |
|
|
576
|
+
| `SITELOOPER_EXECUTABLE` | — | explicit browser binary |
|
|
577
|
+
| `SITELOOPER_HEADED=1`, `--headed` | headless | visible window (first call of a session) |
|
|
578
|
+
| `SITELOOPER_HOME` | `~/.sitelooper` | sessions, skills, flows, config |
|
|
579
|
+
| `SITELOOPER_SKILLS=1`, `--learn` | off | learning mode; `SITELOOPER_SKILLS_DIR` relocates the store |
|
|
580
|
+
| `SITELOOPER_FLOWS_DIR` | `~/.sitelooper/flows` | flow files |
|
|
581
|
+
| `SITELOOPER_RECORD=1`, `--record` | off | webm per tab; paths printed by `stop` |
|
|
582
|
+
| `SITELOOPER_SCRIPT=1`, `--script` | off | record every action as a replayable Playwright step |
|
|
583
|
+
| `--max-turns` | 30 | agent turn cap per instruction |
|
|
584
|
+
| `--timeout` | 300 | wall-clock seconds per instruction |
|
|
585
|
+
| `--turn-timeout` | 90 | seconds for one LLM call before it is aborted and nudged |
|
|
586
|
+
|
|
587
|
+
### What the outer agent sees
|
|
588
|
+
|
|
589
|
+
`do` prints a one-line result, or with `--json` the full
|
|
590
|
+
`{report: {status, summary, details?, evidence?}, turns, usage, model}`. On a turn or time cap
|
|
591
|
+
the result also carries `actions`, the ordered tool calls that ran, so a caller can verify state
|
|
592
|
+
before resuming rather than repeat a mutation. Nothing else lands in the caller's context: the
|
|
593
|
+
agent's snapshots, retries and tool chatter stay inside the daemon.
|
|
594
|
+
|
|
595
|
+
### What it will not do
|
|
596
|
+
|
|
597
|
+
- **Canvas-rendered content** (charts, drawn grids, images) has no DOM to read or verify; the
|
|
598
|
+
agent reports blocked and says so.
|
|
599
|
+
- **Anti-bot evasion, CAPTCHA solving, crawling** are out of scope. sitelooper is for testing
|
|
600
|
+
and driving apps you operate or are authorised to test.
|
|
601
|
+
- **Vision**: the agent is text-only; it reads the accessibility tree and DOM. Screenshots are
|
|
602
|
+
for you.
|
|
603
|
+
- **Guessing credentials**: a rejected or missing credential is an immediate blocked report,
|
|
604
|
+
never a retry loop. `{{env:NAME}}` markers are how you supply them.
|
|
605
|
+
|
|
606
|
+
### Claude Code skill
|
|
607
|
+
|
|
608
|
+
`skills/sitelooper/SKILL.md` is the canonical copy of the bundled skill:
|
|
609
|
+
|
|
610
|
+
```sh
|
|
611
|
+
mkdir -p ~/.claude/skills/sitelooper
|
|
612
|
+
cp skills/sitelooper/SKILL.md ~/.claude/skills/sitelooper/SKILL.md
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
### Development
|
|
616
|
+
|
|
617
|
+
```sh
|
|
618
|
+
npm run build # tsc -> dist/
|
|
619
|
+
npm test # unit tests
|
|
620
|
+
BP_BROWSER_TESTS=1 npx vitest run # + browser-backed replay and perturbation tests (needs Chrome/Edge)
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
The recording-path regression gate (`test/rebuild.test.ts`) recompiles real published
|
|
624
|
+
recordings and pins what they compile to; it runs the built engine, so build before testing.
|
|
625
|
+
Benchmark procedure, arms, targets and the cloud runbook live under `bench/`.
|