ravensight-playtest 0.2.3 → 0.2.4

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 CHANGED
@@ -465,3 +465,29 @@ import { createClient, packs, uploadRunDir } from 'ravensight-playtest';
465
465
  ## License
466
466
 
467
467
  MIT. Copyright Reality Software Entertainment.
468
+
469
+ ## Ravensight Playtester action selection
470
+
471
+ The optional `--controller jev` mode uses the existing persona run. A screenshot
472
+ observer proposes actions, Ravensight selects one on the server, and the local
473
+ driver executes only that choice. Ambiguous controls stop the run for review.
474
+ Reports, screenshots and findings use the normal upload and review flow.
475
+
476
+ ```sh
477
+ ravensight-playtest run --driver playwright_web --controller jev --build-url https://your-game.example/ --max-actions 12 --headed
478
+ ```
479
+
480
+ Sign in, initialize the project and save its expectations brief first. The server
481
+ must advertise this controller in its content pack; otherwise the command stops
482
+ before registering a paid job. No provider key belongs in the CLI. Selection costs
483
+ share the existing persona-run budget and do not create another wallet charge.
484
+ Resume preserves the controller recorded in the job journal.
485
+
486
+ For Jump Bunny, use `https://reality-software-entertainment.github.io/poc-jump-bunny/`
487
+ and the public player label **Ravensight Playtester**. This is a local browser run,
488
+ not a hosted browser service. Human playtesting is still needed for subjective
489
+ feedback and timing-sensitive gameplay.
490
+
491
+ ## Build adapters
492
+
493
+ Browser and Godot runners share the paid playtest lifecycle with desktop, Unity/Unreal export, Android/iOS and custom JSON-RPC adapters. Native adapters require a locally configured automation server and remain experimental until validated against your build. See [adapter setup and validation boundaries](docs/ADAPTERS.md).
@@ -0,0 +1,77 @@
1
+ # Connect a game to Ravensight Playtester
2
+
3
+ Run the CLI on the machine that can access your build. Sign in, link the project with `ravensight-playtest init --game <gameId>`, and complete its expectations brief. `run` quotes the existing persona price before registration. Adapters share the same prepaid job, action limits, model budget, cancellation, reports and uploads.
4
+
5
+ | Adapter | Target | Validation |
6
+ | --- | --- | --- |
7
+ | `playwright_web` | Browser URL, including canvas/WebGL exports | Automated tests and live Jump Bunny gameplay |
8
+ | `godot_driver` | Godot project with the playtest addon | Existing addon protocol and runner tests |
9
+ | `desktop_driver` | Local W3C/Appium session configuration | Protocol tested; experimental on physical builds |
10
+ | `unity_driver`, `unreal_driver` | Desktop exports through that same W3C interface | Engine-neutral screen controls, not editor plugins; experimental |
11
+ | `android_driver`, `ios_driver` | Appium device/emulator/simulator configuration | Protocol tested; hardware/build validation still required |
12
+ | `cli_stdio` | Developer-owned JSON-RPC bridge | Real child-process protocol test |
13
+
14
+ No adapter promises every game is autonomously solvable. Fast action games, unusual controls, anti-cheat, hardware access and protected platforms may require a game-specific bridge. Console tooling and authorization remain the developer's responsibility. The runner cannot bypass platform access restrictions.
15
+
16
+ ## Browser and Godot
17
+
18
+ ```sh
19
+ ravensight-playtest run --driver playwright_web --build-url https://your-game.example/ --headed
20
+ ravensight-playtest run --driver godot_driver --godot-project ./your-game
21
+ ```
22
+
23
+ `--controller jev` enables structured action selection when the server supports it. Screen interpretation proposes controls and the selector chooses a supplied action. Uncertain decisions stop for review. Provider credentials stay on Ravensight. A completed playtest report is evidence for human review, not proof that a game is bug-free.
24
+
25
+ ## Desktop and mobile
26
+
27
+ Install and configure an Appium-compatible driver for your platform. Start its server locally. Supply `ravensight-driver.json` with a loopback endpoint and capabilities appropriate to the installed driver:
28
+
29
+ ```json
30
+ {
31
+ "endpoint": "http://127.0.0.1:4723",
32
+ "capabilities": {
33
+ "platformName": "Android",
34
+ "appium:automationName": "UiAutomator2",
35
+ "appium:deviceName": "YOUR_LOCAL_DEVICE",
36
+ "appium:app": "/absolute/path/to/game.apk"
37
+ }
38
+ }
39
+ ```
40
+
41
+ ```sh
42
+ ravensight-playtest run --driver android_driver --driver-config ./ravensight-driver.json
43
+ ```
44
+
45
+ For iOS, use `ios_driver`, `platformName: iOS`, your XCUITest driver and a provisioned device or simulator. For desktop, use the capabilities required by your installed desktop driver. Unity/Unreal exports use `unity_driver`/`unreal_driver`; no engine source is uploaded by this configuration. Configuration files are local and should not contain provider credentials. Do not expose the automation server publicly.
46
+
47
+ The runner creates a W3C session, reads page source plus PNG screenshots, and executes bounded coordinate clicks, keys, text and single-pointer swipes. It releases input after each action and closes the session when done. One persona uses the device at a time. Native adapters currently capture screenshots, not video or engine logs. Browser navigation is unavailable on these adapters. Model-visible text is limited to 12,000 characters; responses are bounded to 8 MB and commands time out after 30 seconds.
48
+
49
+ ## Custom bridge
50
+
51
+ Use this for text games or your own engine/hardware integration. `--driver-config` points to:
52
+
53
+ ```json
54
+ {
55
+ "command": "/absolute/path/to/bridge-executable",
56
+ "args": ["--game", "/absolute/path/to/build"],
57
+ "cwd": "/absolute/path/to/project"
58
+ }
59
+ ```
60
+
61
+ The configured executable is trusted local code chosen by the developer. The model cannot change it. It runs without a shell. Standard output must contain newline-delimited JSON-RPC 2.0 responses. Put diagnostics on stderr. Respond with the request ID and `result`, or an `error`:
62
+
63
+ ```json
64
+ {"jsonrpc":"2.0","id":1,"method":"launch","params":{"protocolVersion":1}}
65
+ {"jsonrpc":"2.0","id":1,"result":{"ok":true}}
66
+ ```
67
+
68
+ Required methods:
69
+
70
+ - `launch`: initialize/reset the game; return `{ "ok": true }`.
71
+ - `observe`: return `{ "text": "visible state" }`, optionally `screenshotBase64` containing PNG data.
72
+ - `act`: accept a `kind` of `key`, `type`, `click` or `wait`; return `{ "ok": true }` after execution. Apply your own game-specific legality checks. Never interpret model text as shell code.
73
+ - `screenshot`: return `{ "pngBase64": "..." }`, or a JSON-RPC error if unavailable. Text-only games can still produce text reports without screenshots.
74
+
75
+ Requests expire after 30 seconds. Oversized or malformed responses stop the bridge. The runner terminates its child when the session ends; the bridge must clean up any game processes it owns on termination. No custom protocol can establish compatibility with untested hardware by itself.
76
+
77
+ Protocol references: [W3C WebDriver](https://www.w3.org/TR/webdriver/), [Appium capabilities](https://appium.io/docs/en/3.2/guides/caps/).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ravensight-playtest",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "AI personas play your game and file reports to Ravensight.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ravensight.io/docs/#playtest",
@@ -25,6 +25,7 @@
25
25
  "bin",
26
26
  "src",
27
27
  "addons",
28
+ "docs/ADAPTERS.md",
28
29
  "README.md",
29
30
  "LICENSE",
30
31
  "!src/**/*.test.js",
package/src/cli.js CHANGED
@@ -53,10 +53,11 @@ async function loadRunner(name, exportName) {
53
53
  const RUNNER_HELP = `
54
54
  Run options (parsed by the runner, see src/run/args.js):
55
55
  --personas <list> comma separated persona slugs, default the pack's set
56
- --driver <name> playwright_web or godot_driver
56
+ --driver <name> playwright_web, godot_driver, desktop_driver, unity_driver, unreal_driver, android_driver, ios_driver or cli_stdio
57
+ --controller <name> standard or jev action selection (default standard)
57
58
  --build-url <url> the web build to play
58
59
  --godot-project <dir> the Godot project directory
59
- --cli <command> the command that starts a text build
60
+ --driver-config <file> local WebDriver or JSON-RPC bridge configuration
60
61
  --max-actions <n> cap the actions one persona may take
61
62
  --concurrency <n> personas at once, default 2
62
63
  --upload-video upload session.webm as well as the reports
@@ -10,6 +10,8 @@ import { createHeartbeat } from '../run/heartbeat.js';
10
10
  import { createUsageLedger } from '../run/usage.js';
11
11
  import { createModelClient, ModelUnavailableError, ProxyError } from '../run/model.js';
12
12
  import { jobDir, runDir as runDirFor, JOB_FILES, RUN_FILES } from '../run/paths.js';
13
+ import { readBridgeConfig } from "../run/drivers/stdio.js";
14
+ import { WEBDRIVER_NAMES, readDriverConfig } from '../run/drivers/webdriver.js';
13
15
  import { runPersona } from '../run/personaLoop.js';
14
16
  import * as stateFile from '../run/state.js';
15
17
  import { emitRunEvents } from '../run/synthetic.js';
@@ -86,10 +88,11 @@ async function run(flags, injected, deps, log) {
86
88
  if (setup.code !== undefined) return setup.code;
87
89
  const { context, gameId, repoRoot, apiUrl, cliVersion, api } = setup;
88
90
 
91
+ if (options.controller && !['standard', 'jev'].includes(options.controller)) { log('Choose --controller standard or --controller jev.'); return EXIT.ENVIRONMENT; }
89
92
  const driverName = options.driver || (context.config && context.config.driver) || 'playwright_web';
90
93
  const target = resolveTarget(options, context.config, driverName);
91
94
  if (!target) {
92
- log('Point the run at a build: --build-url for a web build, or --godot-project for a Godot project.');
95
+ log('Point the run at a build: --build-url for a web build, --godot-project for Godot, or --driver-config for a native or text bridge.');
93
96
  return EXIT.ENVIRONMENT;
94
97
  }
95
98
 
@@ -97,8 +100,11 @@ async function run(flags, injected, deps, log) {
97
100
  // `aggregate.*` steps are spendable from, and it is free (the price list has
98
101
  // no unit for it), so asking costs nothing and not asking costs the judged
99
102
  // dedup and success-criteria passes.
103
+ if (WEBDRIVER_NAMES.includes(driverName)) await readDriverConfig(target);
104
+ if (driverName === "cli_stdio") await readBridgeConfig(target);
100
105
  const modules = ['persona_playtest', 'aggregate_report'];
101
106
  const pack = await deps.packs.get({ api, gameId, modules });
107
+ if (options.controller === 'jev' && !pack.routing?.action_controllers?.includes('jev')) { log('This server does not currently support the requested action controller. No job was registered.'); return EXIT.ENVIRONMENT; }
102
108
  const personaSlugs = resolvePersonas(options, context.config, pack);
103
109
  const personas = personaSlugs.map(slug => {
104
110
  const found = (pack.personas || []).find(persona => persona.slug === slug);
@@ -164,7 +170,7 @@ async function run(flags, injected, deps, log) {
164
170
  cli_version: cliVersion,
165
171
  confirm_price_cents: estimate.estimate_cents,
166
172
  brief_version: brief.version ?? null,
167
- build: { kind: driverName === 'playwright_web' ? 'url' : 'godot_project', ref: target },
173
+ build: { kind: driverName === 'playwright_web' ? 'url' : (WEBDRIVER_NAMES.includes(driverName) || driverName === 'cli_stdio') ? 'driver_config' : 'godot_project', ref: target },
168
174
  repo: { commit: (context.config && context.config.commit) || '', dirty: Boolean(context.config && context.config.dirty) },
169
175
  max_actions: Number.isInteger(options.maxActions) ? options.maxActions : null,
170
176
  pack_versions: { pack: pack.pack_version }
@@ -191,6 +197,7 @@ async function run(flags, injected, deps, log) {
191
197
  priceCents: registered.price && registered.price.price_cents,
192
198
  options: {
193
199
  maxActions: options.maxActions ?? null,
200
+ controller: options.controller || 'standard',
194
201
  concurrency: options.concurrency ?? DEFAULT_CONCURRENCY,
195
202
  uploadVideo: Boolean(options.uploadVideo),
196
203
  uploadTranscript: Boolean(options.uploadTranscript)
@@ -349,7 +356,7 @@ export async function playJob({
349
356
 
350
357
  const results = [];
351
358
  const queue = personaRuns.filter(entry => !stateFile.isRunDone(journal.runs[entry.run_id]));
352
- const concurrency = Math.max(1, Math.min(Number(options.concurrency) || DEFAULT_CONCURRENCY, queue.length || 1));
359
+ const concurrency = WEBDRIVER_NAMES.includes(driverName) ? 1 : Math.max(1, Math.min(Number(options.concurrency) || DEFAULT_CONCURRENCY, queue.length || 1));
353
360
 
354
361
  /**
355
362
  * Runs the process is in the middle of. A Ctrl-C has to move these to
@@ -516,6 +523,7 @@ export async function playPersonaRun({
516
523
  try {
517
524
  if (inFlight) inFlight.set(runId, { state: 'launching' });
518
525
  outcome = await runPersona({
526
+ controller: options.controller || 'standard',
519
527
  client,
520
528
  api,
521
529
  gameId,
@@ -985,6 +993,7 @@ export function resolveMaxActions(options, persona, pack) {
985
993
  }
986
994
 
987
995
  export function resolveTarget(options, config, driverName) {
996
+ if (WEBDRIVER_NAMES.includes(driverName) || driverName === 'cli_stdio') return options.driverConfig || (config?.build?.kind === 'driver_config' ? config.build.ref : null);
988
997
  const build = (config && config.build) || {};
989
998
  if (driverName === 'playwright_web') return options.buildUrl || (build.kind === 'url' ? build.ref : null) || null;
990
999
  if (driverName === 'godot_driver') return options.godotProject || (build.kind === 'godot_project' ? build.ref : null) || null;
@@ -0,0 +1,47 @@
1
+ import { ProxyError, STEPS } from './model.js';
2
+ export const CONTROLLERS = ['standard', 'jev'];
3
+ export const JEV_CONTROLLER_PROMPT = '\nRavensight Playtester action-selection mode: describe the currently visible screen briefly in text, including each proposed target and its screenshot coordinates, then propose one to three ALTERNATIVE next actions as tool calls. Only one will be executed after a separate selector chooses. These are alternatives, never a sequential plan. Use only observed controls. For a canvas game without accessible DOM controls, include a target label describing the visible control AND screenshot x/y coordinates; the driver uses those coordinates. Never invent selectors such as textbox or button names. When the brief identifies a field as a public player label, use Ravensight Playtester. Credentials or private access codes require human review. When the game is loading or transitioning, propose a bounded wait tool call. Do not place secrets in proposals. After their tool results, observe the resulting state before proposing again.';
4
+ export function describeAction(tool) {
5
+ const input = tool.input || {};
6
+ const descriptions = {
7
+ click: input.target ? `Click the visible ${input.target}${Number.isFinite(input.x) && Number.isFinite(input.y) ? ` at (${input.x}, ${input.y})` : ''}.` : `Click the observed screenshot position (${input.x}, ${input.y}).`,
8
+ type: `Type the prepared text into the focused field${input.submit ? ' and press Enter' : ''}.`,
9
+ key: `Press the ${input.key} key.`,
10
+ wait: `Wait ${input.ms} milliseconds for the screen to finish transitioning.`,
11
+ snapshot: 'Observe the current screen again.',
12
+ screenshot: 'Save a screenshot of the current screen for the report.',
13
+ navigate: `Navigate to the supplied build URL ${input.url}.`
14
+ };
15
+ return String(descriptions[tool.name] || `${tool.name}: ${JSON.stringify(input)}`).slice(0, 300);
16
+ }
17
+ export async function selectAction({ api, jobId, runId, persona, observation, toolUses, recentActions = [], intentNotes = [] }) {
18
+ const proposals = toolUses.slice(0, 8);
19
+ // Observation does not input anything into the game. Keep it in the existing
20
+ // bounded driver loop; Jev selects game inputs, not whether a frame may load.
21
+ if (proposals.length && proposals.every(tool => ['wait', 'snapshot', 'screenshot'].includes(tool.name))) {
22
+ return { status: 'selected', toolId: proposals[0].id, source: 'observation' };
23
+ }
24
+ const body = {
25
+ objective: ('Play the game according to the supplied persona, exploring visible controls and gathering evidence. Developer scope: ' + intentNotes.map(note => note.text || '').join(' ')).slice(0, 1000),
26
+ // The observer already applies the full persona. The selector needs its identity,
27
+ // current visible state and proposed inputs, not a second personality prompt.
28
+ persona: String(persona.name || persona.slug || 'Playtester').slice(0, 200),
29
+ observation: String(observation).slice(0, 6000),
30
+ actions: proposals.map((tool, index) => ({ id: `action_${index}`, description: describeAction(tool) })),
31
+ recentActions: recentActions.slice(-10).map(value => String(value).slice(0, 100))
32
+ };
33
+ let result;
34
+ try {
35
+ result = (await api.request('POST', '/api/v1/playtest/model/v1/decisions', {
36
+ body, idempotent: false,
37
+ headers: { 'X-Playtest-Job': jobId, 'X-Playtest-Run': runId, 'X-Playtest-Step': STEPS.personaAct }
38
+ })).body;
39
+ } catch (error) {
40
+ if (error.status === 402 || error.status === 409) throw new ProxyError({ status: error.status, code: error.code || 'decision_refused', message: 'The action selector stopped because this run is no longer spendable.' });
41
+ return { status: 'review', reason: 'decision_unavailable' };
42
+ }
43
+ if (result.status !== 'suggested') return result;
44
+ const index = body.actions.findIndex(action => action.id === result.actionId);
45
+ if (index < 0 || !Number.isFinite(result.confidence) || result.confidence < 0.75) return { status: 'review', reason: result.actionId === 'stop' ? 'selector_stopped' : 'uncertain_action' };
46
+ return { status: 'selected', toolId: proposals[index].id, confidence: result.confidence, budget: result.budget };
47
+ }
package/src/run/args.js CHANGED
@@ -14,6 +14,8 @@
14
14
  const VALUED = new Map([
15
15
  ['--personas', 'personas'],
16
16
  ['--driver', 'driver'],
17
+ ['--driver-config', 'driverConfig'],
18
+ ['--controller', 'controller'],
17
19
  ['--build-url', 'buildUrl'],
18
20
  ['--build-dir', 'buildDir'],
19
21
  ['--godot-project', 'godotProject'],
@@ -1,9 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
 
3
3
  /**
4
- * The driver interface. One shape, three implementations (spec 03's
4
+ * The driver interface. One shared shape across implementations (spec 03's
5
5
  * "Driver interface (all drivers)"): `playwright_web` here in ./web.js,
6
- * `godot_driver` in ./godot.js, and `cli_stdio` later.
6
+ * `godot_driver` in ./godot.js, plus native and custom bridge adapters.
7
7
  *
8
8
  * A driver is the ONLY thing in this CLI that touches the game. The model
9
9
  * never gets a shell, a file write outside the run directory, or a network
@@ -138,7 +138,10 @@ export function assertDriver(driver, name) {
138
138
  export function observationHash(observation) {
139
139
  const text = typeof observation === 'string' ? observation : String((observation && observation.text) || '');
140
140
  const normalized = text.replace(/\s+/g, ' ').trim();
141
- return createHash('sha256').update(normalized, 'utf8').digest('hex').slice(0, 32);
141
+ const hash = createHash('sha256').update(normalized, 'utf8');
142
+ // Canvas games keep the same DOM text while their visible state changes.
143
+ if (observation && observation.screenshotBase64) hash.update(observation.screenshotBase64, 'base64');
144
+ return hash.digest('hex').slice(0, 32);
142
145
  }
143
146
 
144
147
  /**
@@ -1,16 +1,18 @@
1
1
  import { assertDriver, DriverError } from './driver.js';
2
2
  import { createWebDriver } from './web.js';
3
+ import { createWebDriverAdapter, WEBDRIVER_NAMES } from './webdriver.js';
4
+ import { createStdioDriver } from './stdio.js';
3
5
 
4
6
  /**
5
7
  * The driver registry. `playwright_web` is here; `godot_driver` lives in
6
8
  * ./godot.js and is imported lazily so this package still loads on a
7
- * machine with no Godot and no addon, and `cli_stdio` is not in v1.
9
+ * machine with no Godot and no addon, and native/custom adapters use local developer-owned connections.
8
10
  *
9
11
  * The names are the server's `PlaytestRun.driver` enum and the report
10
12
  * schema's `driver` enum, not ours to invent.
11
13
  */
12
14
 
13
- export const DRIVERS = Object.freeze(['playwright_web', 'godot_driver', 'cli_stdio']);
15
+ export const DRIVERS = Object.freeze(['playwright_web', 'godot_driver', 'cli_stdio', ...WEBDRIVER_NAMES]);
14
16
 
15
17
  /**
16
18
  * @param {string} name
@@ -19,6 +21,7 @@ export const DRIVERS = Object.freeze(['playwright_web', 'godot_driver', 'cli_std
19
21
  * @returns {Promise<Object>} a driver satisfying ./driver.js
20
22
  */
21
23
  export async function createDriver(name, options = {}) {
24
+ if (WEBDRIVER_NAMES.includes(name)) return assertDriver(createWebDriverAdapter({...options,name}),name);
22
25
  const load = options.importer || (specifier => import(specifier));
23
26
  switch (name) {
24
27
  case 'playwright_web':
@@ -40,10 +43,7 @@ export async function createDriver(name, options = {}) {
40
43
  return assertDriver(await factory(options), name);
41
44
  }
42
45
  case 'cli_stdio':
43
- throw new DriverError(
44
- 'The cli_stdio driver is not in this release. In v1 a text build is played through its web front end with --driver playwright_web.',
45
- { fatal: true }
46
- );
46
+ return assertDriver(createStdioDriver(options), name);
47
47
  default:
48
48
  throw new DriverError(`unknown driver "${name}"; one of: ${DRIVERS.join(', ')}`, { fatal: true });
49
49
  }
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { DriverError, observationHash, screenshotName, truncateObservation } from './driver.js';
5
+
6
+ // A developer-owned bridge implements newline-delimited JSON-RPC. The model
7
+ // receives game controls only, never command arguments or process access.
8
+ export async function readBridgeConfig(file) {
9
+ if ((await fs.stat(file)).size > 64000) throw new DriverError('Bridge configuration exceeds 64 KB.');
10
+ const config = JSON.parse(await fs.readFile(file, 'utf8'));
11
+ if (typeof config.command !== 'string' || !path.isAbsolute(config.command) ||
12
+ (config.args !== undefined && (!Array.isArray(config.args) || config.args.some(v => typeof v !== 'string'))) ||
13
+ (config.cwd !== undefined && !path.isAbsolute(config.cwd))) {
14
+ throw new DriverError('Bridge config needs an absolute command, string arguments and an optional absolute cwd.');
15
+ }
16
+ await fs.access(config.command, fs.constants.X_OK);
17
+ return config;
18
+ }
19
+
20
+ export function createStdioDriver({ spawn: spawnImpl = spawn, timeoutMs = 30000 } = {}) {
21
+ let child, buffer = '', nextId = 0, runDirectory, step = 0, shots = 0, lastHash;
22
+ const pending = new Map();
23
+ const fail = message => {
24
+ for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new DriverError(message, { fatal: true })); }
25
+ pending.clear();
26
+ };
27
+ function request(method, params = {}) {
28
+ if (!child || child.exitCode !== null || child.killed) return Promise.reject(new DriverError('Game bridge is not running.', { fatal: true }));
29
+ return new Promise((resolve, reject) => {
30
+ const id = ++nextId;
31
+ const timer = setTimeout(() => { pending.delete(id); reject(new DriverError('Game bridge timed out.', { fatal: true })); child.kill(); }, timeoutMs);
32
+ pending.set(id, { resolve, reject, timer });
33
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n', error => {
34
+ if (error) { clearTimeout(timer); pending.delete(id); reject(new DriverError('Game bridge input failed.')); }
35
+ });
36
+ });
37
+ }
38
+ async function observe() {
39
+ const value = await request('observe');
40
+ if (!value || typeof value.text !== 'string') throw new DriverError('Bridge observation needs text.');
41
+ const observation = { step: ++step, text: truncateObservation(value.text), elapsedMs: 0 };
42
+ if (typeof value.screenshotBase64 === 'string' && value.screenshotBase64.startsWith('iVBORw0KGgo')) {
43
+ observation.screenshotBase64 = value.screenshotBase64;
44
+ observation.screenshotMediaType = 'image/png';
45
+ }
46
+ lastHash = observationHash(observation);
47
+ return observation;
48
+ }
49
+ return {
50
+ name: 'cli_stdio', supportedActions: ['type', 'key', 'click', 'wait'],
51
+ async launch({ target, runDir }) {
52
+ const config = await readBridgeConfig(target);
53
+ runDirectory = runDir;
54
+ child = spawnImpl(config.command, config.args || [], { cwd: config.cwd || path.dirname(path.resolve(target)), shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
55
+ child.stdout.setEncoding('utf8');
56
+ child.stdout.on('data', chunk => {
57
+ buffer += chunk;
58
+ if (Buffer.byteLength(buffer) > 8 * 1024 * 1024) { fail('Game bridge response exceeded 8 MB.'); child.kill(); return; }
59
+ let end;
60
+ while ((end = buffer.indexOf('\n')) !== -1) {
61
+ const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
62
+ let value;
63
+ try { value = JSON.parse(line); } catch { fail('Game bridge returned invalid JSON.'); child.kill(); return; }
64
+ const entry = pending.get(value?.id);
65
+ if (!entry) continue;
66
+ pending.delete(value.id); clearTimeout(entry.timer);
67
+ if (value.jsonrpc !== '2.0' || value.error) entry.reject(new DriverError('Game bridge command failed.'));
68
+ else entry.resolve(value.result);
69
+ }
70
+ });
71
+ // Drain diagnostics without copying arbitrary game output into reports.
72
+ child.stderr.resume();
73
+ child.stdin.on('error', () => fail('Game bridge input failed.'));
74
+ child.on('error', () => fail('Game bridge could not start.'));
75
+ child.on('exit', () => fail('Game bridge exited.'));
76
+ await fs.mkdir(path.join(runDir, 'screenshots'), { recursive: true });
77
+ await request('launch', { protocolVersion: 1 });
78
+ },
79
+ observe,
80
+ async act(action) {
81
+ if (!['type', 'key', 'click', 'wait'].includes(action.kind)) return { ok: false, changed: false, error: 'Unsupported bridge action.' };
82
+ const before = lastHash;
83
+ const safe = action.kind === 'type' ? { kind: 'type', text: String(action.text || '').slice(0, 2000), submit: Boolean(action.submit) }
84
+ : action.kind === 'key' ? { kind: 'key', key: String(action.key || '').slice(0, 64) }
85
+ : action.kind === 'wait' ? { kind: 'wait', ms: Math.min(5000, Math.max(0, Number(action.ms) || 0)) }
86
+ : { kind: 'click', target: typeof action.target === 'string' ? action.target.slice(0, 500) : action.target };
87
+ const answer = await request('act', safe);
88
+ if (answer?.ok !== true) return { ok: false, changed: false, error: 'Game bridge refused this action.' };
89
+ return { ok: true, changed: observationHash(await observe()) !== before };
90
+ },
91
+ async screenshot(reason) {
92
+ const image = await request('screenshot');
93
+ if (typeof image?.pngBase64 !== 'string' || !image.pngBase64.startsWith('iVBORw0KGgo')) throw new DriverError('This bridge did not return a PNG screenshot.');
94
+ const relativePath = path.join('screenshots', screenshotName(++shots, reason));
95
+ const file = path.join(runDirectory, relativePath);
96
+ await fs.writeFile(file, Buffer.from(image.pngBase64, 'base64'));
97
+ return { path: file, relativePath };
98
+ },
99
+ async consoleErrors() { return []; },
100
+ async stop() {
101
+ if (child && child.exitCode === null && !child.killed) {
102
+ child.kill('SIGTERM');
103
+ const process = child;
104
+ const timer = setTimeout(() => { if (process.exitCode === null) process.kill('SIGKILL'); }, 1000);
105
+ timer.unref();
106
+ }
107
+ fail('Game bridge stopped.');
108
+ return { videoPath: null };
109
+ }
110
+ };
111
+ }
@@ -293,7 +293,13 @@ export function createWebDriver({ importer, headless = true } = {}) {
293
293
  try {
294
294
  // The same truncation observe() applies, so the two hashes are
295
295
  // comparable. Without it a long screen is always "changed".
296
- changed = observationHash(truncateObservation(await snapshotText(), OBSERVATION_MAX_CHARS)) !== before;
296
+ const text = truncateObservation(await snapshotText(), OBSERVATION_MAX_CHARS);
297
+ const after = { text };
298
+ if (text.trim().length < THIN_OBSERVATION_CHARS) {
299
+ const frame = await current.screenshot({ type: 'png', timeout: ACTION_TIMEOUT_MS });
300
+ after.screenshotBase64 = frame.toString('base64');
301
+ }
302
+ changed = observationHash(after) !== before;
297
303
  } catch {
298
304
  changed = true;
299
305
  }
@@ -0,0 +1,67 @@
1
+ // Native desktop and mobile adapters use the W3C session/actions protocol.
2
+ // The developer supplies a local Appium/WebDriver server and a reviewed config;
3
+ // game observations cannot choose servers, capabilities, executables or scripts.
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { DriverError, observationHash, screenshotName, truncateObservation } from './driver.js';
7
+
8
+ export const WEBDRIVER_NAMES = ['desktop_driver', 'unity_driver', 'unreal_driver', 'android_driver', 'ios_driver'];
9
+ const KEYS = { Enter:'\uE007', Escape:'\uE00C', Space:' ', Tab:'\uE004', Backspace:'\uE003', ArrowUp:'\uE013', ArrowDown:'\uE015', ArrowLeft:'\uE012', ArrowRight:'\uE014' };
10
+ const MAX_BYTES = 8 * 1024 * 1024;
11
+ export async function readDriverConfig(file) {
12
+ const stat=await fs.stat(file); if(stat.size>64000) throw new DriverError('Driver configuration exceeds 64 KB.',{fatal:true});
13
+ const config=JSON.parse(await fs.readFile(file,'utf8'));
14
+ const url=new URL(config.endpoint);
15
+ if(!['http:','https:'].includes(url.protocol)||!['localhost','127.0.0.1','[::1]'].includes(url.hostname)||url.username||url.password||url.search||url.hash) throw new DriverError('The device server must use a loopback URL without credentials.',{fatal:true});
16
+ if(!config.capabilities||Array.isArray(config.capabilities)||typeof config.capabilities!=='object'||typeof config.capabilities.platformName!=='string') throw new DriverError('Supply W3C capabilities with platformName.',{fatal:true});
17
+ return {...config,endpoint:url.href.replace(/\/$/,'')};
18
+ }
19
+ export function createWebDriverAdapter({name='desktop_driver',fetch:fetchImpl=globalThis.fetch,sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms))}={}) {
20
+ let config,session,runDirectory,step=0,shots=0,lastHash=null;
21
+ async function request(method,route,body) {
22
+ const response=await fetchImpl(config.endpoint+route,{method,redirect:'error',signal:AbortSignal.timeout(30000),headers:{'Content-Type':'application/json'},...(body?{body:JSON.stringify(body)}:{})});
23
+ if(response.status===204) return null;
24
+ if(!response.body) throw new DriverError('Device response was empty.');
25
+ const reader=response.body.getReader();const chunks=[];let size=0;
26
+ try {for(;;){const part=await reader.read();if(part.done)break;size+=part.value.length;if(size>MAX_BYTES){await reader.cancel();throw new DriverError('Device response exceeded the limit.');}chunks.push(Buffer.from(part.value));}}finally{reader.releaseLock();}
27
+ const data=JSON.parse(Buffer.concat(chunks).toString());
28
+ if(!response.ok||data.value?.error)throw new DriverError('Device command failed: '+String(data.value?.error||response.status));
29
+ return data.value;
30
+ }
31
+ const route=tail=>{if(!session)throw new DriverError('No device session is active.',{fatal:true});return '/session/'+encodeURIComponent(session)+tail;};
32
+ async function frame(){const data=await request('GET',route('/screenshot'));if(typeof data!=='string'||!data.startsWith('iVBORw0KGgo'))throw new DriverError('Device screenshot is not a PNG.');return data;}
33
+ async function observe(){const [source,image]=await Promise.all([request('GET',route('/source')),frame()]);const observation={step:++step,text:truncateObservation(typeof source==='string'?source:JSON.stringify(source)),screenshotBase64:image,screenshotMediaType:'image/png',elapsedMs:0};lastHash=observationHash(observation);return observation;}
34
+ async function actions(type,values,pointerType='mouse'){await request('POST',route('/actions'),{actions:[{type,id:'ravensight',...(type==='pointer'?{parameters:{pointerType}}:{}),actions:values}]});}
35
+ const driver={name,supportedActions:['click','type','key','wait','swipe'],
36
+ async launch({target,runDir}){
37
+ config=await readDriverConfig(target);runDirectory=runDir;
38
+ const platform=config.capabilities.platformName.toLowerCase();
39
+ if(name==='android_driver'&&platform!=='android'||name==='ios_driver'&&platform!=='ios')throw new DriverError('The device platform does not match the selected adapter.',{fatal:true});
40
+ const answer=await request('POST','/session',{capabilities:{alwaysMatch:config.capabilities,firstMatch:[{}]}});
41
+ if(!answer||typeof answer.sessionId!=='string')throw new DriverError('The device server did not create a session.',{fatal:true});
42
+ session=answer.sessionId;await fs.mkdir(path.join(runDir,'screenshots'),{recursive:true});
43
+ },observe,
44
+ async act(action){
45
+ const before=lastHash;const touch=['android_driver','ios_driver'].includes(name)?'touch':'mouse';
46
+ const point=value=>{if(!value||!Number.isFinite(value.x)||!Number.isFinite(value.y)||value.x<0||value.y<0||value.x>32768||value.y>32768)throw new DriverError('Supply bounded screenshot coordinates.');return {x:Math.round(value.x),y:Math.round(value.y)};};
47
+ try {
48
+ if(action.kind==='wait')await sleep(Math.min(5000,Math.max(0,Number(action.ms)||0)));
49
+ else if(action.kind==='click'){
50
+ const p=point(action.target);await actions('pointer',[{type:'pointerMove',duration:0,origin:'viewport',...p},{type:'pointerDown',button:0},{type:'pointerUp',button:0}],touch);
51
+ }else if(action.kind==='swipe'){
52
+ const from=point(action.from),to=point(action.to);await actions('pointer',[{type:'pointerMove',duration:0,origin:'viewport',...from},{type:'pointerDown',button:0},{type:'pointerMove',duration:Math.min(2000,Math.max(100,Number(action.durationMs)||500)),origin:'viewport',...to},{type:'pointerUp',button:0}],touch);
53
+ }else if(action.kind==='key'||action.kind==='type'){
54
+ const chars=action.kind==='type'?Array.from(String(action.text||'').slice(0,2000)):[KEYS[action.key]||action.key];
55
+ if(chars.some(key=>typeof key!=='string'||Array.from(key).length!==1))throw new DriverError('Unsupported device key.');
56
+ if(action.submit)chars.push(KEYS.Enter);
57
+ await actions('key',chars.flatMap(value=>[{type:'keyDown',value},{type:'pause',duration:Math.min(2000,Math.max(0,Number(action.durationMs)||0))},{type:'keyUp',value}]));
58
+ }else throw new DriverError('This device adapter does not support that action.');
59
+ return {ok:true,changed:observationHash(await observe())!==before};
60
+ }catch(error){return {ok:false,changed:false,error:error instanceof DriverError?error.message:'Device input failed.'};}
61
+ finally {if(session)await request('DELETE',route('/actions')).catch(()=>{});}
62
+ },
63
+ async screenshot(reason){const relativePath=path.join('screenshots',screenshotName(++shots,reason));const file=path.join(runDirectory,relativePath);await fs.writeFile(file,Buffer.from(await frame(),'base64'));return {path:file,relativePath,reason};},
64
+ async consoleErrors(){return [];},
65
+ async stop(){if(session){const current=route('');session=null;await request('DELETE',current);}return {videoPath:null};}
66
+ };return driver;
67
+ }
@@ -51,6 +51,55 @@ export const PLAN_MAX_TURNS = 6;
51
51
  */
52
52
  export const NUDGE_EVERY = 8;
53
53
 
54
+ /** One breakpoint, frozen so no caller can hand a mutated one to the next turn. */
55
+ const CACHE_BREAKPOINT = Object.freeze({ type: 'ephemeral' });
56
+
57
+ /**
58
+ * Move a cache breakpoint onto the newest turn of a conversation.
59
+ *
60
+ * `fixSystem` already marks the end of the system prompt, but a breakpoint
61
+ * there only ever caches the skill and the repository facts. The tokens on
62
+ * this path are in `messages`: a `read_file` result is a whole source file,
63
+ * and the apply loop accumulates dozens of them over up to sixty turns, every
64
+ * one of which re-sends the whole conversation. Without a breakpoint in
65
+ * `messages` that conversation is processed at full price on every turn, which
66
+ * is quadratic in the number of turns and is where `fix.apply`'s bill comes
67
+ * from.
68
+ *
69
+ * The prefix written on turn N is exactly the prefix read on turn N+1, because
70
+ * these two loops only ever APPEND to `messages`. Nothing rewrites an earlier
71
+ * turn, so the bytes ahead of the marker are identical from one call to the
72
+ * next. (`personaLoop` deliberately does not do this: `prepareMessages`
73
+ * rewrites older observations into stubs as a run goes on, so its prefix is not
74
+ * stable and a breakpoint in its messages would pay the write premium instead
75
+ * of earning a read.)
76
+ *
77
+ * Applied to a COPY, leaving the stored conversation unmarked, for two
78
+ * reasons. A request may carry at most four breakpoints, so a marker left
79
+ * behind on each turn would be refused by the fifth. And the bytes this call
80
+ * sends for an earlier turn have to be the bytes the last call sent, which a
81
+ * marker written into the history would change. A marker that moves is not an
82
+ * invalidator; one that accumulates is.
83
+ *
84
+ * No breakpoint can leak anything across runs or across customers. A cache
85
+ * read requires the caller to present the identical prefix bytes, so the only
86
+ * thing an entry can ever serve back is a prompt whoever asked for it already
87
+ * held. The proxy passes `messages` through by reference, so nothing about
88
+ * this needs the server's field allowlist widened.
89
+ *
90
+ * @param {Array<Object>} messages the conversation so far, unmarked
91
+ * @returns {Array<Object>} the conversation to send, with one moving marker
92
+ */
93
+ export function withRollingCacheBreakpoint(messages) {
94
+ const last = messages.at(-1);
95
+ if (!last || !Array.isArray(last.content) || last.content.length === 0) return messages;
96
+ const block = last.content.at(-1);
97
+ if (!block || typeof block !== 'object') return messages;
98
+ const content = [...last.content];
99
+ content[content.length - 1] = { ...block, cache_control: CACHE_BREAKPOINT };
100
+ return [...messages.slice(0, -1), { ...last, content }];
101
+ }
102
+
54
103
  /** The text of a tool result, whichever of the two content shapes it uses. */
55
104
  function resultText(result) {
56
105
  if (!result) return '';
@@ -213,7 +262,7 @@ export async function planFix({
213
262
  runId,
214
263
  step: STEPS.fixPlan,
215
264
  system,
216
- messages,
265
+ messages: withRollingCacheBreakpoint(messages),
217
266
  tools: repo ? repo.tools : undefined,
218
267
  maxTokens: PLAN_MAX_TOKENS
219
268
  });
@@ -337,7 +386,7 @@ export async function applyFix({
337
386
  runId,
338
387
  step: STEPS.fixApply,
339
388
  system,
340
- messages,
389
+ messages: withRollingCacheBreakpoint(messages),
341
390
  tools: repo.tools,
342
391
  maxTokens: APPLY_MAX_TOKENS
343
392
  });
@@ -486,4 +535,4 @@ export function clamp(value, limit) {
486
535
  return text.length <= limit ? text : text.slice(0, limit);
487
536
  }
488
537
 
489
- export default { planFix, applyFix, summariseFix, resolveFixMaxActions, fixSystem, planContent, normalisePlan };
538
+ export default { planFix, applyFix, summariseFix, resolveFixMaxActions, fixSystem, planContent, normalisePlan, withRollingCacheBreakpoint };
@@ -1,3 +1,4 @@
1
+ import { selectAction, JEV_CONTROLLER_PROMPT } from './actionSelector.js';
1
2
  import { mkdir, writeFile } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { prepareMessages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
@@ -6,7 +7,7 @@ import { callStepWithRetry, ProxyError, STEPS } from './model.js';
6
7
  import { RUN_FILES } from './paths.js';
7
8
  import { checkEvidence, createReportValidator, lintMarkdownSections, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
8
9
  import { assertNoSecrets } from './secretScan.js';
9
- import { ACTION_TOOLS, PLAY_TOOLS, REPORT_TOOLS, executeTool } from './tools.js';
10
+ import { ACTION_TOOLS, PLAY_TOOLS, SWIPE_TOOL, REPORT_TOOLS, executeTool } from './tools.js';
10
11
  import { createTranscript } from './transcript.js';
11
12
  import { createUsageLedger, writeUsageFile } from './usage.js';
12
13
 
@@ -114,6 +115,7 @@ export async function runPersona(args) {
114
115
  job_id: jobId,
115
116
  persona: persona.slug,
116
117
  driver: driverName,
118
+ controller: args.controller || 'standard',
117
119
  target,
118
120
  prompt_version: promptVersionOf(skill),
119
121
  brief_id: brief ? brief.brief_id || null : null,
@@ -248,6 +250,8 @@ export async function runPersona(args) {
248
250
  * @returns {Promise<Object>}
249
251
  */
250
252
  async function playLoop({
253
+ api,
254
+ controller = 'standard',
251
255
  client,
252
256
  jobId,
253
257
  runId,
@@ -270,6 +274,25 @@ async function playLoop({
270
274
  log = () => {}
271
275
  }) {
272
276
  const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions });
277
+ if (controller === 'jev') system.push({ type: 'text', text: JEV_CONTROLLER_PROMPT });
278
+ const availableTools = driver.supportedActions
279
+ ? [...PLAY_TOOLS, ...(driver.supportedActions.includes('swipe') ? [SWIPE_TOOL] : [])].filter(tool => !ACTION_TOOLS.includes(tool.name) || driver.supportedActions.includes(tool.name))
280
+ : PLAY_TOOLS;
281
+ const playTools = controller === 'jev' ? availableTools.map(tool => ({
282
+ ...tool,
283
+ input_schema: {
284
+ ...tool.input_schema,
285
+ required: [...(tool.input_schema.required || []), 'visible_state', ...(tool.name === 'click' ? ['target'] : [])],
286
+ properties: {
287
+ ...tool.input_schema.properties,
288
+ visible_state: { type: 'string', description: 'Describe what is visibly on the current screenshot, including this action target and its coordinates. This observation is required even when the assistant text is empty. Do not claim unobserved state.' },
289
+ ...(tool.name === 'click' ? { target: { type: 'string', description: 'Label of the visible control. For canvas also supply x and y; the driver uses those coordinates.' } } : {})
290
+ }
291
+ }
292
+ })) : availableTools;
293
+ const recentActions = [];
294
+ let latestObservation = '';
295
+ let visualObservation = false;
273
296
  const messages = [];
274
297
  let actions = 0;
275
298
  let turns = 0;
@@ -283,6 +306,8 @@ async function playLoop({
283
306
  const maxTurns = maxActions * 3 + 20;
284
307
 
285
308
  const first = await driver.observe();
309
+ latestObservation = first.text;
310
+ visualObservation = Boolean(first.screenshotBase64);
286
311
  transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
287
312
  const landing = await safeScreenshot(driver, 'landing', screenshots, transcript);
288
313
  messages.push({
@@ -317,7 +342,7 @@ async function playLoop({
317
342
  step: STEPS.personaAct,
318
343
  system,
319
344
  messages,
320
- tools: PLAY_TOOLS,
345
+ tools: playTools,
321
346
  maxTokens: ACT_MAX_TOKENS,
322
347
  escalate: shouldEscalate(persona),
323
348
  imageBudget,
@@ -370,9 +395,23 @@ async function playLoop({
370
395
  break;
371
396
  }
372
397
 
398
+ let selection = null;
399
+ if (controller === 'jev') {
400
+ try {
401
+ selection = await selectAction({ api, jobId, runId, persona, observation: `${visualObservation ? '' : latestObservation}\nScreen interpretation: ${answer.toolUses.map(tool => tool.input?.visible_state || '').filter(Boolean).join('\n') || answer.text}`, toolUses: answer.toolUses, recentActions, intentNotes });
402
+ } catch (error) {
403
+ if (error instanceof ProxyError && error.isBudget) { budgetExceeded = true; quitReason = 'cost_cap'; quitDetail = error.message; break; }
404
+ if (error instanceof ProxyError && error.isClosed) { canceled = true; quitDetail = error.message; break; }
405
+ throw error;
406
+ }
407
+ transcript.write({ kind: 'action_selection', source: selection.source || 'jev', status: selection.status, confidence: selection.confidence, selected_tool_id: selection.toolId, reason: selection.reason });
408
+ if (selection.status !== 'selected') { quitReason = 'persona_quit'; quitDetail = 'Ravensight Playtester stopped for review: ' + selection.reason; break; }
409
+ if (selection.budget === 'warn') { budgetWarned = true; wrapUpLeft = Math.min(wrapUpLeft, WRAP_UP_TURNS); }
410
+ }
373
411
  messages.push({ role: 'assistant', content: assistantContent(answer) });
374
412
  const results = [];
375
413
  for (const toolUse of answer.toolUses) {
414
+ if (selection && toolUse.id !== selection.toolId) { results.push({ type: 'tool_result', tool_use_id: toolUse.id, content: 'Alternative not selected. No action executed.' }); continue; }
376
415
  const executed = await executeTool({
377
416
  driver,
378
417
  toolUse,
@@ -402,6 +441,10 @@ async function playLoop({
402
441
  actions
403
442
  });
404
443
  if (executed.observation) {
444
+ latestObservation = executed.observation.text;
445
+ visualObservation = Boolean(executed.observation.screenshotBase64);
446
+ recentActions.push(`${toolUse.name}: ${executed.changed ? 'screen changed' : 'no observed change'}`);
447
+ if (recentActions.length > 10) recentActions.shift();
405
448
  transcript.write({ kind: 'observation', step: executed.observation.step, text: executed.observation.text, url: executed.observation.url });
406
449
  // Only an input to the game can tell us anything about being stuck. A
407
450
  // `snapshot` or a `screenshot` never changes the screen by design, so
package/src/run/resume.js CHANGED
@@ -153,6 +153,7 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
153
153
 
154
154
  const resumeOptions = {
155
155
  ...options,
156
+ controller: journal.options?.controller || options.controller || 'standard',
156
157
  maxActions: (journal.options && journal.options.maxActions) ?? options.maxActions,
157
158
  concurrency: options.concurrency ?? (journal.options && journal.options.concurrency),
158
159
  uploadVideo: options.uploadVideo ?? (journal.options && journal.options.uploadVideo),
package/src/run/tools.js CHANGED
@@ -29,7 +29,16 @@ export const NOTE_KINDS = Object.freeze(['bug', 'confusing', 'good', 'suggestion
29
29
  export const SEVERITIES = Object.freeze(['blocker', 'major', 'minor', 'cosmetic', 'praise']);
30
30
 
31
31
  /** Tools that spend an action. */
32
- export const ACTION_TOOLS = Object.freeze(['click', 'type', 'key', 'navigate']);
32
+ export const ACTION_TOOLS = Object.freeze(['click', 'type', 'key', 'navigate', 'swipe']);
33
+
34
+ export const SWIPE_TOOL = {
35
+ name: 'swipe',
36
+ description: 'Drag from one screenshot coordinate to another. Spends one action.',
37
+ input_schema: {
38
+ type: 'object', additionalProperties: false, required: ['x', 'y', 'toX', 'toY'],
39
+ properties: Object.fromEntries(['x', 'y', 'toX', 'toY', 'durationMs'].map(key => [key, { type: 'number', minimum: 0, maximum: key === 'durationMs' ? 2000 : 32768 }]))
40
+ }
41
+ };
33
42
 
34
43
  const thought = {
35
44
  type: 'string',
@@ -185,6 +194,8 @@ export function actionFor(toolUse) {
185
194
  return { kind: 'navigate', url: String(input.url || '') };
186
195
  case 'wait':
187
196
  return { kind: 'wait', ms: Number(input.ms) || 0 };
197
+ case 'swipe':
198
+ return { kind: 'swipe', from: { x: input.x, y: input.y }, to: { x: input.toX, y: input.toY }, durationMs: input.durationMs };
188
199
  default:
189
200
  return null;
190
201
  }
package/src/states.js CHANGED
@@ -26,7 +26,10 @@ export const DRIVERS = Object.freeze([
26
26
  'godot_driver',
27
27
  'cli_stdio',
28
28
  'unity_driver',
29
- 'unreal_driver'
29
+ 'unreal_driver',
30
+ 'desktop_driver',
31
+ 'android_driver',
32
+ 'ios_driver'
30
33
  ]);
31
34
 
32
35
  export const JOB_STATES = Object.freeze([