verikun 0.4.1 → 0.7.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/README.md +145 -27
- package/dist/agent/cost.js +21 -10
- package/dist/agent/engine.js +8 -8
- package/dist/agent/openai.js +243 -0
- package/dist/agent/remote.js +166 -0
- package/dist/args.js +2 -0
- package/dist/bin/verikun.js +0 -0
- package/dist/cli.js +425 -108
- package/dist/drivers/adb.js +12 -0
- package/dist/drivers/index.js +5 -5
- package/dist/drivers/ios.js +358 -0
- package/dist/report.js +93 -3
- package/dist/rpc.js +46 -0
- package/dist/run.js +101 -1
- package/dist/server.js +349 -0
- package/dist/suite.js +152 -0
- package/dist/ui/ios-parse.js +149 -0
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# verikun
|
|
2
2
|
|
|
3
|
-
Drive a connected Android device/emulator
|
|
3
|
+
Drive a connected Android device/emulator or iOS simulator the way
|
|
4
4
|
Puppeteer drives a browser — **tap, type, swipe, screenshot**, and most
|
|
5
5
|
importantly **inspect the UI hierarchy by semantic identifiers** so an AI agent
|
|
6
6
|
can act and then *verify* what happened.
|
|
7
7
|
|
|
8
|
-
It is a thin, deterministic, zero-runtime-dependency wrapper over `adb` (
|
|
9
|
-
`xcrun simctl`
|
|
10
|
-
|
|
8
|
+
It is a thin, deterministic, zero-runtime-dependency wrapper over `adb` (Android)
|
|
9
|
+
and `idb` + `xcrun simctl` (iOS) that turns the raw `uiautomator` dump (Android)
|
|
10
|
+
or `idb ui describe-all` accessibility tree (iOS) into a compact, token-efficient
|
|
11
|
+
list of meaningful elements addressable by `resource-id` / accessibility id,
|
|
11
12
|
visible text, accessibility label, or class.
|
|
12
13
|
|
|
13
14
|
```
|
|
@@ -78,7 +79,8 @@ vk screenshot # -> ./.verikun/screen.png
|
|
|
78
79
|
| `swipe --from x,y --to x,y [--duration ms]` | Explicit swipe between two points. |
|
|
79
80
|
| `screenshot [--out path] [--more] [--max px] [--full] [--json]` | Save a PNG (default `./.verikun/screen.png`); prints the path. [Downscaled](#screenshots) to a 700px longest edge by default to save tokens; `--more` bumps detail, `--max px` sets an exact cap, `--full` keeps the original. |
|
|
80
81
|
| `launch <app> [--clear] [--no-restart]` / `stop <app>` | App lifecycle by package id (Android) / bundle id (iOS). `launch` **restarts by default** — it force-stops the app first (a no-op if it isn't running) so a rerun starts fresh instead of resurfacing a still-running instance's current screen; `--no-restart` skips that. `--clear` also wipes the app's local data (login/session, prefs, cache) for a fresh-install start. |
|
|
81
|
-
| `clear <app>` | Wipe the app's locally stored data — login/session, preferences, caches — resetting it to a just-installed state (Android `pm clear`, which also force-stops the app). iOS
|
|
82
|
+
| `clear <app>` | Wipe the app's locally stored data — login/session, preferences, caches — resetting it to a just-installed state (Android `pm clear`, which also force-stops the app). iOS unsupported: there is no per-app data reset. |
|
|
83
|
+
| `install <app.apk\|.ipa> [--server url]` | Install a build on the device (`adb install -r` / `idb install`). With `--server`, the file is uploaded to a remote [`vk server`](#remote-devices--vk-server) started with `--allow-install` (single-file `.apk`/`.ipa`, sha256-verified). |
|
|
82
84
|
|
|
83
85
|
### Batch
|
|
84
86
|
| Command | Description |
|
|
@@ -88,7 +90,13 @@ vk screenshot # -> ./.verikun/screen.png
|
|
|
88
90
|
### AI
|
|
89
91
|
| Command | Description |
|
|
90
92
|
|---|---|
|
|
91
|
-
| `ai <file> [--model m] [--max-cost-usd n] [--timeout dur] [--cost-override in/out] [--effort e] [--package pkg] [--app-build id] [--show-plan] [--recompile] [--json]` | Run a plain-English test: compile it to a deterministic plan once, replay it model-free, and self-heal failures via the model. Needs `ANTHROPIC_API_KEY
|
|
93
|
+
| `ai <file> [--model m] [--max-cost-usd n] [--timeout dur] [--cost-override in/out] [--effort e] [--package pkg] [--app-build id] [--server url] [--show-plan] [--recompile] [--json]` | Run a plain-English test: compile it to a deterministic plan once, replay it model-free, and self-heal failures via the model. Needs `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` (per model). See [AI](#ai--natural-language-tests). |
|
|
94
|
+
| `suite <dir> [--app <id>] [--name n] [--server url] [--json]` (+ all `ai` flags) | Run every `*.md` in `<dir>` as one sequential suite with an overview report and a non-zero exit on failure — the CI gate. See [Suites](#suites--run-a-directory-of-tests). |
|
|
95
|
+
|
|
96
|
+
### Remote
|
|
97
|
+
| Command | Description |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `server [--bind addr] [--port n] [--auth-key k] [--allow-install] [--allow-unsafe-anonymous]` | Expose this machine's connected device to remote verikun clients (`ai`/`suite`/`install --server`). Auth is mandatory unless explicitly disabled; only verikun's validated command grammar is executable. See [Remote devices](#remote-devices--vk-server). |
|
|
92
100
|
|
|
93
101
|
### Environment
|
|
94
102
|
| Command | Description |
|
|
@@ -189,7 +197,7 @@ printf 'launch com.example.app\nassert @home_tab\nrun archive smoke\n' | vk batc
|
|
|
189
197
|
with no model calls on the happy path**. The model is woken only to *repair* a step
|
|
190
198
|
whose selector stops resolving; a green run persists the repaired plan, so the next
|
|
191
199
|
run is free again. That is what keeps a CI suite's steady-state token cost near zero.
|
|
192
|
-
Needs `ANTHROPIC_API_KEY
|
|
200
|
+
Needs `ANTHROPIC_API_KEY` (Claude models) or `OPENAI_API_KEY` (OpenAI models).
|
|
193
201
|
|
|
194
202
|
```sh
|
|
195
203
|
# onboarding.md (plain English):
|
|
@@ -217,12 +225,110 @@ a hard iteration cap and stop early if the screen stops changing.
|
|
|
217
225
|
$3)** or the wall-clock passes **`--timeout` (default 15m)** — so a runaway loop or
|
|
218
226
|
repair can't spend or hang without limit. `--cost-override <input/output>` overrides
|
|
219
227
|
the bundled per-1M price table if it drifts.
|
|
220
|
-
- **`--model`** picks the model (`claude-haiku-4-5` ·
|
|
221
|
-
`claude-opus-4-8` · `claude-fable-5`)
|
|
228
|
+
- **`--model`** picks the model and its provider — Anthropic (`claude-haiku-4-5` ·
|
|
229
|
+
`claude-sonnet-4-6` (default) · `claude-opus-4-8` · `claude-fable-5`) or OpenAI
|
|
230
|
+
(`gpt-5.4-mini` · `gpt-5.4` · `gpt-5.5`), each read from its own key
|
|
231
|
+
(`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`); **`--recompile`** ignores the cache.
|
|
222
232
|
- An `ai` run records like any other flow, so it produces the same JUnit + HTML report —
|
|
223
233
|
with the cost line and any **suggested test improvements** (workarounds the model
|
|
224
234
|
applied, which you can fold back into the prose to stabilize the test and cut tokens).
|
|
225
235
|
|
|
236
|
+
## Suites — run a directory of tests
|
|
237
|
+
|
|
238
|
+
`vk suite <dir>` runs every `*.md` in a directory through the [`vk ai`](#ai--natural-language-tests)
|
|
239
|
+
engine, sequentially, against one shared device (local or [remote](#remote-devices--vk-server)):
|
|
240
|
+
|
|
241
|
+
```sh
|
|
242
|
+
vk suite tests/ --app com.example.app # local device
|
|
243
|
+
vk suite tests/ --app com.example.app --server "$VERIKUN_SERVER" # remote device
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
- **Ordering is lexicographic** — prefix files `01-…`, `02-…` to sequence them.
|
|
247
|
+
`README.md` is skipped (it documents the suite, it isn't a test).
|
|
248
|
+
- **Isolation between tests:** with `--app <id>`, the app's data is cleared before
|
|
249
|
+
each test (`pm clear`; iOS degrades to a force-stop since it has no per-app
|
|
250
|
+
reset). Without `--app`, make each test self-isolating (start with
|
|
251
|
+
`launch <pkg> --clear` in the prose).
|
|
252
|
+
- **Each test is a full `vk ai` run** — plan cache, self-healing, cost budget, and
|
|
253
|
+
its own archived JUnit + HTML report under `./.verikun/runs/<id>/`. A test that
|
|
254
|
+
fails (or errors) doesn't stop the suite; the rest still run.
|
|
255
|
+
- **The suite writes an overview** to `./.verikun/suites/<id>/`:
|
|
256
|
+
- **`index.json`** — a stable, `schemaVersion`ed manifest: per-test pass/fail,
|
|
257
|
+
steps, model repairs, cost, duration, and the run id, plus suite totals. This
|
|
258
|
+
is the **output contract for reporting** — upload/publish steps compose over
|
|
259
|
+
it (see the [CI recipe](#ci-recipe)) instead of verikun growing upload plugins.
|
|
260
|
+
- **`index.html`** — a summary page linking every test's `report.html`.
|
|
261
|
+
- **Exit code is the CI gate:** `1` if any test failed, `0` all green, `2` bad/empty
|
|
262
|
+
directory. All `ai` flags (`--model`, `--max-cost-usd`, `--timeout`, …) apply to
|
|
263
|
+
every test; the model's key (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`) is checked
|
|
264
|
+
up front.
|
|
265
|
+
|
|
266
|
+
## Remote devices — `vk server`
|
|
267
|
+
|
|
268
|
+
CI runners don't have your phone plugged into them. `vk server` exposes a
|
|
269
|
+
locally-connected device to remote verikun clients so a **disposable CI runner**
|
|
270
|
+
can drive it — without a self-hosted runner executing arbitrary PR code on the
|
|
271
|
+
machine that owns the device:
|
|
272
|
+
|
|
273
|
+
```sh
|
|
274
|
+
# On the machine with the device attached:
|
|
275
|
+
export VERIKUN_SERVER_AUTH_KEY=$(openssl rand -base64 32) # or let vk generate one
|
|
276
|
+
vk server --allow-install # 127.0.0.1:8391 by default
|
|
277
|
+
vk server --bind 100.64.0.7 --allow-install # expose on a tailnet IP
|
|
278
|
+
|
|
279
|
+
# From anywhere that can reach it:
|
|
280
|
+
export VERIKUN_SERVER=http://100.64.0.7:8391
|
|
281
|
+
export VERIKUN_SERVER_AUTH_KEY=<the same key>
|
|
282
|
+
vk install ./app-debug.apk --server "$VERIKUN_SERVER"
|
|
283
|
+
vk ai onboarding.md --server "$VERIKUN_SERVER"
|
|
284
|
+
vk suite tests/ --app com.example.app --server "$VERIKUN_SERVER"
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
**Split execution.** The client runs the whole `vk ai` engine — compile, plan
|
|
288
|
+
cache, repairs, the Anthropic key, run recording, suite aggregation — and only
|
|
289
|
+
**validated device commands** cross the network, one HTTP round-trip per command
|
|
290
|
+
(selector auto-wait polls on the server, next to the device). Each step's detail
|
|
291
|
+
(selector, heal tier, resolved element, failure screenshot + hierarchy) returns
|
|
292
|
+
with the response and is spliced into the client's run, so the archived report is
|
|
293
|
+
identical to a local run's.
|
|
294
|
+
|
|
295
|
+
**Security model** (the server is the boundary, not the transport):
|
|
296
|
+
|
|
297
|
+
- **Auth is mandatory.** Pass a key via `--auth-key` / `VERIKUN_SERVER_AUTH_KEY`
|
|
298
|
+
(the env var keeps it out of `ps`), or one is generated and printed at startup.
|
|
299
|
+
Clients send it as a bearer token; comparison is constant-time.
|
|
300
|
+
`--allow-unsafe-anonymous` disables auth loudly — only for networks that are
|
|
301
|
+
themselves the boundary (e.g. a private tailnet), and it cannot be combined
|
|
302
|
+
with a key.
|
|
303
|
+
- **Only the validated grammar runs.** Every `/v1/exec` request passes the same
|
|
304
|
+
`validateNode` gate that guards `vk ai` model repairs: action verbs only
|
|
305
|
+
(`tap`/`text`/`assert`/`launch`/…), never `ui`/`log`, never a shell. The
|
|
306
|
+
device and platform are fixed when the server starts — client flags cannot
|
|
307
|
+
repoint them.
|
|
308
|
+
- **Installs are opt-in.** `POST /v1/install` requires `--allow-install` (a
|
|
309
|
+
read-only server refuses builds), accepts only single-file `.apk`/`.ipa`
|
|
310
|
+
uploads to a server-generated temp path (never a client path), and verifies a
|
|
311
|
+
sha256 of the body.
|
|
312
|
+
- **One run at a time.** A run-token holds the device lock; a second concurrent
|
|
313
|
+
caller gets `409`. The lock is released when the command finishes (so
|
|
314
|
+
`vk install` then `vk suite` chain seamlessly), and an idle lock (5 min
|
|
315
|
+
silent) is taken over, so a crashed CI job can't wedge the device.
|
|
316
|
+
- **Bind is loopback by default.** `--bind <addr>` opts into exposure. For a
|
|
317
|
+
NAT'd box, [Tailscale](https://tailscale.com) is the recommended transport; for
|
|
318
|
+
a public host, terminate TLS in front (the server itself speaks plain HTTP).
|
|
319
|
+
- Failure evidence (screenshots, UI hierarchies) crosses the authenticated
|
|
320
|
+
channel like the rest — same caveat as `vk log`: device output is not redacted.
|
|
321
|
+
|
|
322
|
+
### CI recipe
|
|
323
|
+
|
|
324
|
+
[`.github/workflows/suite.yml`](.github/workflows/suite.yml) is a working
|
|
325
|
+
reference: a plain `ubuntu-latest` job builds verikun, installs the app build on
|
|
326
|
+
the remote device (`vk install --server`), runs `vk suite --server`, uploads
|
|
327
|
+
`.verikun/suites` + `.verikun/runs` as artifacts, and **fails the job when any
|
|
328
|
+
test fails** (the suite's exit code). Publishing anywhere else is a composable
|
|
329
|
+
step over the `index.json` manifest — the workflow includes commented `rclone`
|
|
330
|
+
(Google Drive) and `aws s3 cp` examples.
|
|
331
|
+
|
|
226
332
|
## Selectors
|
|
227
333
|
|
|
228
334
|
```
|
|
@@ -280,6 +386,8 @@ condition as a step in its own right.
|
|
|
280
386
|
| `-d, --device <serial>` | Target a specific device (or `VERIKUN_DEVICE` / `ANDROID_SERIAL`). |
|
|
281
387
|
| `-p, --platform <android\|ios>` | Platform (default `android`). `--ios` / `--android` are shortcuts. |
|
|
282
388
|
| `-j, --json` | Machine-readable output (also serializes errors). |
|
|
389
|
+
| `--server <url>` | For `ai`/`suite`/`install`: run against a remote [`vk server`](#remote-devices--vk-server) (or `VERIKUN_SERVER`). The server's device/platform apply. |
|
|
390
|
+
| `--auth-key <k>` | Key for `--server` (or `VERIKUN_SERVER_AUTH_KEY`, which keeps it out of `ps`). |
|
|
283
391
|
| `--` | End flag parsing, so text/arguments may start with `-`. |
|
|
284
392
|
|
|
285
393
|
## Exit codes
|
|
@@ -319,9 +427,10 @@ Failure-evidence captures in test-run reports stay full-resolution for debugging
|
|
|
319
427
|
## How it works
|
|
320
428
|
|
|
321
429
|
```
|
|
322
|
-
cli.ts ──> drivers/ ──> adb /
|
|
430
|
+
cli.ts ──> drivers/ ──> adb (Android) / idb + simctl (iOS) (platform I/O)
|
|
323
431
|
│ └─ produces normalized Element[]
|
|
324
432
|
├─ ui/android-parse.ts uiautomator XML -> Element[]
|
|
433
|
+
├─ ui/ios-parse.ts idb describe-all JSON -> Element[]
|
|
325
434
|
├─ ui/selector.ts @id / text: / desc: / class: matching
|
|
326
435
|
└─ ui/format.ts compact / tree / json rendering
|
|
327
436
|
```
|
|
@@ -332,30 +441,39 @@ selector, formatting, and command layers operate only on the normalized
|
|
|
332
441
|
|
|
333
442
|
- **Android** (`drivers/adb.ts`): `uiautomator dump` for the hierarchy,
|
|
334
443
|
`screencap -p` for screenshots, `input tap/text/swipe/keyevent`, `wm size`.
|
|
335
|
-
- **iOS** (`drivers/
|
|
336
|
-
`
|
|
337
|
-
|
|
444
|
+
- **iOS** (`drivers/ios.ts`): `idb ui describe-all` for the hierarchy,
|
|
445
|
+
`idb ui tap/text/swipe/key/button` for interaction, `idb describe` for screen
|
|
446
|
+
size; simulator `screenshot`/`launch`/`stop`/`log` stay on `xcrun simctl` (no
|
|
447
|
+
idb needed for those). See [iOS setup](#ios-setup) below.
|
|
338
448
|
|
|
339
449
|
Run artifacts (screenshots, dumps) are written under `./.verikun/` (gitignored).
|
|
340
450
|
|
|
341
|
-
## iOS
|
|
451
|
+
## iOS setup
|
|
342
452
|
|
|
343
|
-
|
|
344
|
-
|
|
453
|
+
`vk --ios` reaches feature parity with Android — `ui`/`find`, `tap`, `text`/`type`,
|
|
454
|
+
`swipe`, `key`, `assert`, `wait`, `screenshot`, `launch`/`stop`, plus `vk batch`,
|
|
455
|
+
`vk ai`, and the JUnit/HTML reports — on both simulators and physical devices.
|
|
345
456
|
|
|
346
|
-
|
|
347
|
-
(
|
|
348
|
-
|
|
349
|
-
is running, `vk` will drive it over HTTP and the command layer stays unchanged.
|
|
457
|
+
Everything interactive is powered by **[`idb`](https://github.com/facebook/idb)**
|
|
458
|
+
(Facebook's iOS Development Bridge), a CLI shelled one-shot exactly like `adb`, so
|
|
459
|
+
verikun stays zero-runtime-dependency and one-process-per-command. Install it once:
|
|
350
460
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
4. `vk --ios tap`, `vk --ios ui`, etc. will work automatically
|
|
461
|
+
```sh
|
|
462
|
+
brew tap facebook/fb && brew install idb-companion # the companion daemon
|
|
463
|
+
pip install fb-idb # the idb CLI (needs Python 3.6+)
|
|
464
|
+
```
|
|
356
465
|
|
|
357
|
-
|
|
358
|
-
with
|
|
466
|
+
Then boot a simulator (Simulator.app, or `xcrun simctl boot <name>`) and check
|
|
467
|
+
your setup with `vk doctor --ios`. `vk --ios tap`, `vk --ios ui`, etc. then work.
|
|
468
|
+
|
|
469
|
+
**Caveats (documented limitations, not bugs):**
|
|
470
|
+
- `clear` is unsupported — iOS has no per-app data reset (uninstall + reinstall is
|
|
471
|
+
the manual equivalent, but it removes the app too). Exits 3 with an explanation.
|
|
472
|
+
- `current` returns `(unknown)` — iOS exposes no reliable foreground-app query.
|
|
473
|
+
- `swipe` duration is not honored (idb has no millisecond duration knob).
|
|
474
|
+
- `log` capture is simulator-only (via `log show`); for a physical device use
|
|
475
|
+
Console.app or `idb log` directly.
|
|
476
|
+
- `--tree` renders flat — idb's accessibility list has no nesting depth.
|
|
359
477
|
|
|
360
478
|
## Using it from an AI agent
|
|
361
479
|
|
package/dist/agent/cost.js
CHANGED
|
@@ -1,23 +1,34 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.CostTracker = exports.DEFAULT_MAX_COST_USD = exports.DEFAULT_MODEL = exports.ALLOWED_MODELS = exports.MODEL_PRICES = void 0;
|
|
4
|
+
exports.providerFor = providerFor;
|
|
4
5
|
exports.resolveModel = resolveModel;
|
|
5
6
|
exports.parseCostOverride = parseCostOverride;
|
|
6
7
|
exports.priceFor = priceFor;
|
|
7
8
|
exports.estimateCostUsd = estimateCostUsd;
|
|
8
9
|
const errors_1 = require("../errors");
|
|
9
|
-
// Per-1M-token prices
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
'claude-
|
|
17
|
-
'claude-
|
|
10
|
+
// Per-1M-token prices + owning provider — the SINGLE source of truth. MODEL_PRICES,
|
|
11
|
+
// ALLOWED_MODELS and providerFor all derive from this, so the --model allowlist, its
|
|
12
|
+
// price and its backend can never disagree. Prices WILL drift between releases
|
|
13
|
+
// (Anthropic cached 2026-05-26; OpenAI 2026-07-02) — `--cost-override <input/output>`
|
|
14
|
+
// is the escape hatch and is authoritative when supplied. Every model here bills cached
|
|
15
|
+
// input at ~0.1x (Anthropic + OpenAI gpt-5.x alike), matching CACHE_READ_MULT below.
|
|
16
|
+
const MODELS = {
|
|
17
|
+
'claude-haiku-4-5': { input: 1, output: 5, provider: 'anthropic' },
|
|
18
|
+
'claude-sonnet-4-6': { input: 3, output: 15, provider: 'anthropic' },
|
|
19
|
+
'claude-opus-4-8': { input: 5, output: 25, provider: 'anthropic' },
|
|
20
|
+
'claude-fable-5': { input: 10, output: 50, provider: 'anthropic' },
|
|
21
|
+
'gpt-5.4-mini': { input: 0.75, output: 4.5, provider: 'openai' },
|
|
22
|
+
'gpt-5.4': { input: 2.5, output: 15, provider: 'openai' },
|
|
23
|
+
'gpt-5.5': { input: 5, output: 30, provider: 'openai' },
|
|
18
24
|
};
|
|
19
|
-
exports.
|
|
25
|
+
exports.MODEL_PRICES = Object.fromEntries(Object.entries(MODELS).map(([m, s]) => [m, { input: s.input, output: s.output }]));
|
|
26
|
+
exports.ALLOWED_MODELS = Object.keys(MODELS);
|
|
20
27
|
exports.DEFAULT_MODEL = 'claude-sonnet-4-6';
|
|
28
|
+
/** Resolve which provider backend serves a model (unknown → anthropic, the default). */
|
|
29
|
+
function providerFor(model) {
|
|
30
|
+
return MODELS[model]?.provider ?? 'anthropic';
|
|
31
|
+
}
|
|
21
32
|
/** Default total-run cost ceiling for `vk ai` when --max-cost-usd is not given, so a
|
|
22
33
|
* runaway compile/repair loop can't spend unbounded tokens. */
|
|
23
34
|
exports.DEFAULT_MAX_COST_USD = 3;
|
package/dist/agent/engine.js
CHANGED
|
@@ -31,15 +31,15 @@ async function runPlan(plan, deps) {
|
|
|
31
31
|
// A UI dump can fail transiently on a real device (uiautomator throws). Treat
|
|
32
32
|
// that as "empty screen" rather than letting it abort the whole run — the rest
|
|
33
33
|
// of the codebase treats dumps as recoverable (resolveOneWaiting re-polls).
|
|
34
|
-
const safeElements = () => {
|
|
34
|
+
const safeElements = async () => {
|
|
35
35
|
try {
|
|
36
|
-
return deps.getElements();
|
|
36
|
+
return await deps.getElements();
|
|
37
37
|
}
|
|
38
38
|
catch {
|
|
39
39
|
return [];
|
|
40
40
|
}
|
|
41
41
|
};
|
|
42
|
-
const present = (selector) => {
|
|
42
|
+
const present = async (selector) => {
|
|
43
43
|
// Re-fetch on a dump FAILURE (uiautomator can throw transiently) so a flaky dump at
|
|
44
44
|
// a guard check doesn't silently read as "absent" and skip a body that should run.
|
|
45
45
|
// Once a dump SUCCEEDS (even if empty) we trust it — no slow re-poll, so a genuinely
|
|
@@ -47,7 +47,7 @@ async function runPlan(plan, deps) {
|
|
|
47
47
|
let els;
|
|
48
48
|
for (let i = 0; i < 2 && els === undefined; i++) {
|
|
49
49
|
try {
|
|
50
|
-
els = deps.getElements();
|
|
50
|
+
els = await deps.getElements();
|
|
51
51
|
}
|
|
52
52
|
catch {
|
|
53
53
|
/* transient dump failure — retry once before concluding "absent" */
|
|
@@ -92,7 +92,7 @@ async function runPlan(plan, deps) {
|
|
|
92
92
|
failedStep: current,
|
|
93
93
|
reason,
|
|
94
94
|
candidates,
|
|
95
|
-
hierarchy: safeElements(),
|
|
95
|
+
hierarchy: await safeElements(),
|
|
96
96
|
});
|
|
97
97
|
deps.cost.add(usage, 'repair');
|
|
98
98
|
// The model may DECLINE (null) when the current screen has no element serving
|
|
@@ -149,7 +149,7 @@ async function runPlan(plan, deps) {
|
|
|
149
149
|
case 'command':
|
|
150
150
|
return execLeaf(node, where, replace);
|
|
151
151
|
case 'if-present': {
|
|
152
|
-
if (present(node.selector)) {
|
|
152
|
+
if (await present(node.selector)) {
|
|
153
153
|
deps.log(`[ai] ${where}: if-present '${node.selector}' → present, running ${node.body.length} step(s)`);
|
|
154
154
|
return walkBody(node.body, where);
|
|
155
155
|
}
|
|
@@ -163,11 +163,11 @@ async function runPlan(plan, deps) {
|
|
|
163
163
|
deps.log(`[ai] ${where}: run timeout reached — stopping repeat after ${i} iteration(s)`);
|
|
164
164
|
return { status: 'timeout' };
|
|
165
165
|
}
|
|
166
|
-
if (present(node.selector)) {
|
|
166
|
+
if (await present(node.selector)) {
|
|
167
167
|
deps.log(`[ai] ${where}: repeat reached '${node.selector}' after ${i} iteration(s)`);
|
|
168
168
|
return { status: 'ok' };
|
|
169
169
|
}
|
|
170
|
-
const hash = structuralHash(safeElements());
|
|
170
|
+
const hash = structuralHash(await safeElements());
|
|
171
171
|
if (i > 0 && hash === prevHash) {
|
|
172
172
|
deps.log(`[ai] ${where}: repeat made no progress (screen unchanged) — stopping after ${i} iteration(s)`);
|
|
173
173
|
return { status: 'ok' };
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OpenAiProvider = void 0;
|
|
4
|
+
exports.mapUsage = mapUsage;
|
|
5
|
+
exports.toStrictSchema = toStrictSchema;
|
|
6
|
+
const errors_1 = require("../errors");
|
|
7
|
+
const format_1 = require("../ui/format");
|
|
8
|
+
const ir_1 = require("./ir");
|
|
9
|
+
const grammar_1 = require("./grammar");
|
|
10
|
+
// The OpenAI provider: OpenAI's Chat Completions API over Node's built-in fetch — no
|
|
11
|
+
// SDK, honoring the repo's zero-runtime-dependency rule (a sibling to claude.ts). The
|
|
12
|
+
// LLM ecosystem converged on this /chat/completions shape, so this same class drives any
|
|
13
|
+
// OpenAI-compatible endpoint (Groq, xAI, Together, DeepSeek, Gemini-compat) by pointing
|
|
14
|
+
// `baseUrl` at it — that is the whole point of doing it here rather than per vendor.
|
|
15
|
+
//
|
|
16
|
+
// Structured output: response_format json_schema with strict:true, so generation is
|
|
17
|
+
// HARD-CONSTRAINED to the plan/repair schema. json_object mode is not enough — a weaker
|
|
18
|
+
// model (e.g. gpt-5.4-mini) emits flags as a map instead of the {name,value}[] the IR
|
|
19
|
+
// requires, which parsePlan then rejects. The shared ir.ts schemas are adapted to
|
|
20
|
+
// OpenAI's strict dialect at call time by toStrictSchema (no duplication, no ir.ts
|
|
21
|
+
// change); engine.ts's parsePlan/validateNode stays the execution trust boundary
|
|
22
|
+
// regardless. The model runs here ONLY on compile + repair, never on replay.
|
|
23
|
+
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
|
|
24
|
+
// Per-request wall-clock cap so a stalled connection can't hang the run past its
|
|
25
|
+
// --timeout deadline (the engine checks the deadline only between calls, not during one).
|
|
26
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
|
|
27
|
+
// gpt-5.x are reasoning models: they take reasoning_effort, require max_completion_tokens
|
|
28
|
+
// (not max_tokens), and reject a custom temperature. Map verikun's effort scale onto
|
|
29
|
+
// OpenAI's (whose ceiling is 'high'), and only send it when the caller asked for one.
|
|
30
|
+
// INVARIANT: every OpenAI model in cost.ts's registry is a reasoning model that accepts
|
|
31
|
+
// reasoning_effort. If a non-reasoning model is ever added there, gate this send behind an
|
|
32
|
+
// allowlist like claude.ts's EFFORT_MODELS (else it 400s → exit 2 with no retry).
|
|
33
|
+
const EFFORT_MAP = {
|
|
34
|
+
low: 'low',
|
|
35
|
+
medium: 'medium',
|
|
36
|
+
high: 'high',
|
|
37
|
+
xhigh: 'high',
|
|
38
|
+
max: 'high',
|
|
39
|
+
};
|
|
40
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
41
|
+
const backoffMs = (attempt) => Math.min(1000 * 2 ** (attempt - 1), 15000);
|
|
42
|
+
/** Map OpenAI's usage onto the normalized (Anthropic-shaped) Usage the CostTracker
|
|
43
|
+
* prices. OpenAI's prompt_tokens INCLUDES the cached tokens, so subtract them out:
|
|
44
|
+
* uncached prompt → input_tokens (full price), cached → cache_read_input_tokens (0.1x).
|
|
45
|
+
* OpenAI has no separate cache-write charge, so cache_creation is 0. completion_tokens
|
|
46
|
+
* already includes reasoning tokens on gpt-5.x, so output is priced correctly. A response
|
|
47
|
+
* with no usage block maps to all-zeros (cost under-counts) — acceptable because the
|
|
48
|
+
* repair loop is also bounded by maxRepairs and the --timeout deadline, not cost alone. */
|
|
49
|
+
function mapUsage(u) {
|
|
50
|
+
const cached = u?.prompt_tokens_details?.cached_tokens ?? 0;
|
|
51
|
+
const prompt = u?.prompt_tokens ?? 0;
|
|
52
|
+
return {
|
|
53
|
+
input_tokens: Math.max(0, prompt - cached),
|
|
54
|
+
output_tokens: u?.completion_tokens ?? 0,
|
|
55
|
+
cache_read_input_tokens: cached,
|
|
56
|
+
cache_creation_input_tokens: 0,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Adapt a JSON Schema to OpenAI's strict Structured-Outputs dialect: every object must
|
|
60
|
+
* set additionalProperties:false and list ALL its properties in `required`, so a
|
|
61
|
+
* previously-optional field is kept but made nullable. Non-mutating — lets the shared
|
|
62
|
+
* ir.ts schemas stay the single source of truth while OpenAI hard-constrains generation. */
|
|
63
|
+
function toStrictSchema(schema) {
|
|
64
|
+
if (Array.isArray(schema))
|
|
65
|
+
return schema.map(toStrictSchema);
|
|
66
|
+
if (!schema || typeof schema !== 'object')
|
|
67
|
+
return schema;
|
|
68
|
+
const src = schema;
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const [k, v] of Object.entries(src))
|
|
71
|
+
out[k] = toStrictSchema(v);
|
|
72
|
+
const props = out.properties;
|
|
73
|
+
if (props && typeof props === 'object' && !Array.isArray(props)) {
|
|
74
|
+
const p = props;
|
|
75
|
+
const keys = Object.keys(p);
|
|
76
|
+
const wasRequired = new Set(Array.isArray(out.required) ? out.required : []);
|
|
77
|
+
out.additionalProperties = false;
|
|
78
|
+
out.required = keys;
|
|
79
|
+
for (const k of keys)
|
|
80
|
+
if (!wasRequired.has(k))
|
|
81
|
+
p[k] = makeNullable(p[k]);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
/** Widen a property schema to also admit null (how strict mode expresses "optional"). */
|
|
86
|
+
function makeNullable(prop) {
|
|
87
|
+
if (!prop || typeof prop !== 'object')
|
|
88
|
+
return prop;
|
|
89
|
+
const p = prop;
|
|
90
|
+
if (Array.isArray(p.anyOf))
|
|
91
|
+
return { ...p, anyOf: [...p.anyOf, { type: 'null' }] };
|
|
92
|
+
if (typeof p.type === 'string') {
|
|
93
|
+
const next = { ...p, type: [p.type, 'null'] };
|
|
94
|
+
if (Array.isArray(p.enum))
|
|
95
|
+
next.enum = [...p.enum, null];
|
|
96
|
+
return next;
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(p.type)) {
|
|
99
|
+
return { ...p, type: [...p.type.filter((t) => t !== 'null'), 'null'] };
|
|
100
|
+
}
|
|
101
|
+
return { anyOf: [prop, { type: 'null' }] };
|
|
102
|
+
}
|
|
103
|
+
class OpenAiProvider {
|
|
104
|
+
opts;
|
|
105
|
+
constructor(opts) {
|
|
106
|
+
this.opts = opts;
|
|
107
|
+
}
|
|
108
|
+
async compile(input) {
|
|
109
|
+
const parts = [];
|
|
110
|
+
if (input.pkg)
|
|
111
|
+
parts.push(`App package: ${input.pkg}`);
|
|
112
|
+
parts.push(`Platform: ${input.platform}`);
|
|
113
|
+
if (input.seed) {
|
|
114
|
+
parts.push('A plan compiled for a PREVIOUS build of this app follows. Reuse it where the test still holds; ' +
|
|
115
|
+
'change only what the test now requires. PRIOR PLAN:\n' +
|
|
116
|
+
JSON.stringify(input.seed, null, 2));
|
|
117
|
+
}
|
|
118
|
+
parts.push('NATURAL-LANGUAGE TEST:\n' + input.nl);
|
|
119
|
+
// Generous completion budget: on reasoning models the plan JSON shares this ceiling
|
|
120
|
+
// with reasoning tokens, so leave headroom (a 'length' finish is surfaced as an error).
|
|
121
|
+
const { json, usage } = await this.call(grammar_1.GRAMMAR, parts.join('\n\n'), ir_1.PLAN_JSON_SCHEMA, 16384);
|
|
122
|
+
return { plan: (0, ir_1.parsePlan)(json), usage };
|
|
123
|
+
}
|
|
124
|
+
async repair(ctx) {
|
|
125
|
+
const parts = ['FAILED STEP: ' + JSON.stringify(ctx.failedStep), 'FAILURE: ' + ctx.reason];
|
|
126
|
+
if (ctx.candidates && ctx.candidates.length) {
|
|
127
|
+
parts.push(`The selector matched ${ctx.candidates.length} elements (ambiguous) — pick a more specific selector for the SAME intended element, or give_up if none of them is it.`);
|
|
128
|
+
}
|
|
129
|
+
parts.push('CURRENT SCREEN:\n' + (0, format_1.formatCompact)(ctx.hierarchy));
|
|
130
|
+
const { json, usage } = await this.call(grammar_1.REPAIR_GRAMMAR, parts.join('\n\n'), ir_1.REPAIR_DECISION_JSON_SCHEMA, 4096);
|
|
131
|
+
const decision = (json ?? {});
|
|
132
|
+
if (decision.decision === 'give_up') {
|
|
133
|
+
return {
|
|
134
|
+
replaceStep: null,
|
|
135
|
+
declineReason: decision.reason?.trim() || 'no element on the current screen matches the step intent',
|
|
136
|
+
usage,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// Hand the proposed leaf back UNVALIDATED — engine.ts validates every repair against
|
|
140
|
+
// the grammar before splicing (it is the execution trust boundary and can't assume a
|
|
141
|
+
// provider validated). A missing/invalid step is rejected there as a failed repair.
|
|
142
|
+
return { replaceStep: (decision.step ?? null), usage };
|
|
143
|
+
}
|
|
144
|
+
async call(system, user, schema, maxTokens) {
|
|
145
|
+
const body = {
|
|
146
|
+
model: this.opts.model,
|
|
147
|
+
max_completion_tokens: maxTokens,
|
|
148
|
+
// Hard-constrain generation to the schema (strict Structured Outputs), adapting the
|
|
149
|
+
// shared ir.ts schema to OpenAI's strict dialect. parsePlan/validateNode still gates.
|
|
150
|
+
response_format: {
|
|
151
|
+
type: 'json_schema',
|
|
152
|
+
json_schema: { name: 'verikun_output', strict: true, schema: toStrictSchema(schema) },
|
|
153
|
+
},
|
|
154
|
+
messages: [
|
|
155
|
+
{ role: 'system', content: system },
|
|
156
|
+
{ role: 'user', content: user },
|
|
157
|
+
],
|
|
158
|
+
};
|
|
159
|
+
if (this.opts.effort) {
|
|
160
|
+
const mapped = EFFORT_MAP[this.opts.effort];
|
|
161
|
+
if (mapped)
|
|
162
|
+
body.reasoning_effort = mapped;
|
|
163
|
+
}
|
|
164
|
+
const res = await this.fetchWithRetry(body);
|
|
165
|
+
const choice = res.choices?.[0];
|
|
166
|
+
if (choice?.message?.refusal) {
|
|
167
|
+
throw new errors_1.CliError(`Model refused the request: ${choice.message.refusal}`, 1);
|
|
168
|
+
}
|
|
169
|
+
if (choice?.finish_reason === 'length') {
|
|
170
|
+
throw new errors_1.CliError('Model output was truncated (finish_reason=length) before a complete result — raise the budget or shorten the test.', 1);
|
|
171
|
+
}
|
|
172
|
+
if (choice?.finish_reason === 'content_filter') {
|
|
173
|
+
throw new errors_1.CliError('Model output was blocked by the content filter.', 1);
|
|
174
|
+
}
|
|
175
|
+
const text = (choice?.message?.content ?? '').trim();
|
|
176
|
+
if (!text)
|
|
177
|
+
throw new errors_1.CliError('Model returned an empty response.', 1);
|
|
178
|
+
let json;
|
|
179
|
+
try {
|
|
180
|
+
json = JSON.parse(text);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
throw new errors_1.CliError('Model output was not valid JSON.', 1);
|
|
184
|
+
}
|
|
185
|
+
return { json, usage: mapUsage(res.usage) };
|
|
186
|
+
}
|
|
187
|
+
async fetchWithRetry(body) {
|
|
188
|
+
const maxRetries = this.opts.maxRetries ?? 4;
|
|
189
|
+
const timeoutMs = this.opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
190
|
+
const doFetch = this.opts.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
191
|
+
const doSleep = this.opts.sleepImpl ?? sleep;
|
|
192
|
+
const url = (this.opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '') + '/chat/completions';
|
|
193
|
+
let attempt = 0;
|
|
194
|
+
for (;;) {
|
|
195
|
+
// Abort a stalled request after timeoutMs so it can't hang forever; the abort is
|
|
196
|
+
// caught below and retried like any other network error (bounded by maxRetries).
|
|
197
|
+
const controller = new AbortController();
|
|
198
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
199
|
+
let res;
|
|
200
|
+
try {
|
|
201
|
+
res = await doFetch(url, {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
headers: {
|
|
204
|
+
'content-type': 'application/json',
|
|
205
|
+
authorization: `Bearer ${this.opts.apiKey}`,
|
|
206
|
+
},
|
|
207
|
+
body: JSON.stringify(body),
|
|
208
|
+
signal: controller.signal,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
if (attempt++ >= maxRetries)
|
|
213
|
+
throw new errors_1.CliError(`OpenAI API request failed: ${e.message}`, 3);
|
|
214
|
+
await doSleep(backoffMs(attempt));
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
}
|
|
220
|
+
if (res.ok) {
|
|
221
|
+
try {
|
|
222
|
+
return (await res.json());
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// A 2xx with a non-JSON body (proxy/API corruption) — map to the env exit code
|
|
226
|
+
// rather than letting a raw SyntaxError escape as an unhandled throw.
|
|
227
|
+
throw new errors_1.CliError('OpenAI returned a non-JSON success body.', 3);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Retry 429 + 5xx with backoff, honoring Retry-After (no SDK to do it for us).
|
|
231
|
+
if ((res.status === 429 || res.status >= 500) && attempt++ < maxRetries) {
|
|
232
|
+
const retryAfter = Number(res.headers.get('retry-after'));
|
|
233
|
+
await doSleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : backoffMs(attempt));
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const errText = await res.text().catch(() => '');
|
|
237
|
+
// 401/403 = auth/permission (env); 400 = bad request (usage); else env.
|
|
238
|
+
const code = res.status === 401 || res.status === 403 ? 3 : res.status === 400 ? 2 : 3;
|
|
239
|
+
throw new errors_1.CliError(`OpenAI API error ${res.status}: ${errText.slice(0, 500)}`, code);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
exports.OpenAiProvider = OpenAiProvider;
|