ravensight-playtest 0.2.3 → 0.2.5

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.
@@ -2,7 +2,6 @@ import { mkdir, rename, stat, readdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import {
4
4
  DriverError,
5
- OBSERVATION_MAX_CHARS,
6
5
  THIN_OBSERVATION_CHARS,
7
6
  observationHash,
8
7
  screenshotName,
@@ -36,6 +35,87 @@ export const MAX_WAIT_MS = 10000;
36
35
  /** How long a single Playwright call may take before it is an error, not a hang. */
37
36
  export const ACTION_TIMEOUT_MS = 15000;
38
37
 
38
+ /**
39
+ * The web observation ceiling. An aria snapshot is re-sent on every turn the
40
+ * observation stays in context, and the run's model budget is fixed, so a busy
41
+ * page at the shared 12,000 character ceiling spends the budget on markup the
42
+ * persona never needed. Compaction (below) usually lands well under this.
43
+ */
44
+ export const WEB_OBSERVATION_MAX_CHARS = 4000;
45
+
46
+ /**
47
+ * Full observations kept in context for a web run. The newest screen is what
48
+ * the persona acts on; one more is enough to compare against, and every older
49
+ * one is a stub.
50
+ */
51
+ export const WEB_KEEP_OBSERVATIONS = 2;
52
+
53
+ /** Roles a player can act on. Kept even with no name, since they are still clickable. */
54
+ const INTERACTIVE_ROLES = new Set([
55
+ 'button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio', 'combobox', 'listbox', 'option',
56
+ 'slider', 'spinbutton', 'switch', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'treeitem',
57
+ 'canvas'
58
+ ]);
59
+
60
+ /**
61
+ * Keep the nodes a player could act on or read, and drop the structure in
62
+ * between. An aria snapshot prints every wrapper (`- generic:`, `- list:`, a
63
+ * nameless `- img`) and the attribute lines under links (`- /url: ...`), none
64
+ * of which tell a persona anything, and all of which cost tokens every turn.
65
+ *
66
+ * Indentation is dropped too: the persona targets nodes by role and name, not
67
+ * by depth. Text that is not an aria snapshot (the innerText fallback for a
68
+ * thin page) is returned unchanged.
69
+ *
70
+ * @param {string} text
71
+ * @returns {string}
72
+ */
73
+ export function compactAriaSnapshot(text) {
74
+ const value = String(text == null ? '' : text);
75
+ const lines = value.split('\n');
76
+ if (!lines.some(line => /^\s*- /.test(line))) return value;
77
+ const kept = [];
78
+ for (const line of lines) {
79
+ const trimmed = line.trim();
80
+ if (!trimmed.startsWith('- ')) continue;
81
+ const body = unquoteKey(trimmed.slice(2));
82
+ if (body.startsWith('/')) continue;
83
+ const role = (body.match(/^([a-z]+)/) || [])[1] || '';
84
+ const named = /^[a-z]+ "[^"]*"/.test(body);
85
+ const inlineText = /^[a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*: \S/.test(body);
86
+ if (INTERACTIVE_ROLES.has(role) || named || inlineText) kept.push(`- ${body}`);
87
+ }
88
+ return kept.join('\n');
89
+ }
90
+
91
+ /**
92
+ * Playwright writes a node key as a single quoted YAML scalar when the name
93
+ * contains `: `, ` #`, a brace or a backtick: `- 'button "Player #1"'`. Strip
94
+ * that outer pair (a doubled `''` inside is one quote) so the node is
95
+ * classified and shown like any other, keeping whatever followed the closing
96
+ * quote (a `:` for a node with children, or `: text`).
97
+ *
98
+ * @param {string} body the line after its `- `
99
+ * @returns {string}
100
+ */
101
+ export function unquoteKey(body) {
102
+ if (!body.startsWith("'")) return body;
103
+ let out = '';
104
+ for (let i = 1; i < body.length; i += 1) {
105
+ if (body[i] === "'") {
106
+ if (body[i + 1] === "'") {
107
+ out += "'";
108
+ i += 1;
109
+ continue;
110
+ }
111
+ return out + body.slice(i + 1);
112
+ }
113
+ out += body[i];
114
+ }
115
+ // No closing quote: not a quoted key after all.
116
+ return body;
117
+ }
118
+
39
119
  const KNOWN_SELECTOR_ENGINES = /^(role|text|css|xpath|id|data-testid|internal:)=/;
40
120
 
41
121
  /**
@@ -127,12 +207,33 @@ export function resolveLocator(page, target) {
127
207
  return page.getByText(value, { exact: false }).first();
128
208
  }
129
209
 
210
+ /**
211
+ * Hand the run's playtest identity to the page. Runs in the browser, before
212
+ * any of the game's own scripts, so the Ravensight JS SDK finds it at
213
+ * construction. Serialised by Playwright, so it must not close over anything.
214
+ *
215
+ * @param {{token: string, runId: string, jobId: string, persona: string}} identity
216
+ */
217
+ function injectPlaytestIdentity(identity) {
218
+ window.__RAVENSIGHT_PLAYTEST__ = identity;
219
+ try {
220
+ localStorage.setItem('ravensight.playtest', JSON.stringify(identity));
221
+ } catch {
222
+ // Storage can be refused (a sandboxed frame, a privacy mode). The window
223
+ // global is the first thing the SDK reads, so this is only the fallback.
224
+ }
225
+ }
226
+
130
227
  /**
131
228
  * @param {Object} opts
132
229
  * @param {(specifier: string) => Promise<any>} [opts.importer] test seam
230
+ * @param {boolean} [opts.headless]
231
+ * @param {{token?: string, runId?: string, jobId?: string, persona?: string}} [opts.playtest]
232
+ * the run's playtest identity, handed to the page so the game's own SDK
233
+ * session is marked synthetic
133
234
  * @returns {Object} a driver
134
235
  */
135
- export function createWebDriver({ importer, headless = true } = {}) {
236
+ export function createWebDriver({ importer, headless = true, playtest = null } = {}) {
136
237
  let browser = null;
137
238
  let context = null;
138
239
  let page = null;
@@ -142,6 +243,7 @@ export function createWebDriver({ importer, headless = true } = {}) {
142
243
  let step = 0;
143
244
  let shots = 0;
144
245
  let lastHash = null;
246
+ let lastObservedHash = null;
145
247
  const consoleBuffer = [];
146
248
 
147
249
  /** @returns {any} */
@@ -150,9 +252,15 @@ export function createWebDriver({ importer, headless = true } = {}) {
150
252
  return page;
151
253
  }
152
254
 
255
+ /**
256
+ * @returns {Promise<{text: string, aria: boolean}>} the screen text, and
257
+ * whether it is the aria snapshot (compacted) or the innerText fallback
258
+ * (never compacted: a bullet list in the page is not snapshot structure)
259
+ */
153
260
  async function snapshotText() {
154
261
  const current = requirePage();
155
262
  let text = '';
263
+ let aria = true;
156
264
  const body = current.locator('body');
157
265
  if (typeof body.ariaSnapshot === 'function') {
158
266
  try {
@@ -167,17 +275,31 @@ export function createWebDriver({ importer, headless = true } = {}) {
167
275
  const el = globalThis.document && globalThis.document.body;
168
276
  return el ? el.innerText : '';
169
277
  });
170
- if (String(inner).trim().length > text.trim().length) text = String(inner);
278
+ if (String(inner).trim().length > text.trim().length) {
279
+ text = String(inner);
280
+ aria = false;
281
+ }
171
282
  } catch {
172
283
  // A page mid-navigation has no body to read. The thin observation
173
284
  // is the honest answer, and the screenshot fallback below covers it.
174
285
  }
175
286
  }
176
- return text;
287
+ return { text, aria };
288
+ }
289
+
290
+ /**
291
+ * What the model is shown for a raw snapshot. `observe` and `act` both use
292
+ * this, so the two hashes compare like with like.
293
+ * @param {{text: string, aria: boolean}} snap
294
+ * @returns {string}
295
+ */
296
+ function shownText(snap) {
297
+ return truncateObservation(snap.aria ? compactAriaSnapshot(snap.text) : snap.text, WEB_OBSERVATION_MAX_CHARS);
177
298
  }
178
299
 
179
300
  return {
180
301
  name: 'playwright_web',
302
+ keepObservations: WEB_KEEP_OBSERVATIONS,
181
303
 
182
304
  /**
183
305
  * @param {Object} opts
@@ -200,6 +322,18 @@ export function createWebDriver({ importer, headless = true } = {}) {
200
322
  viewport,
201
323
  ...(videoRequested ? { recordVideo: { dir: runDir, size: viewport } } : {})
202
324
  });
325
+ // The playtest identity goes in as an init script, not as
326
+ // `extraHTTPHeaders`: a context header rides on every request the page
327
+ // makes, including to the game's own API, and the token is only for
328
+ // Ravensight. The SDK reads it from the page and sends it itself.
329
+ if (playtest && playtest.token) {
330
+ await context.addInitScript(injectPlaytestIdentity, {
331
+ token: playtest.token,
332
+ runId: playtest.runId || null,
333
+ jobId: playtest.jobId || null,
334
+ persona: playtest.persona || null
335
+ });
336
+ }
203
337
  page = await context.newPage();
204
338
  page.on('console', message => {
205
339
  if (message.type() === 'error') consoleBuffer.push({ type: 'console', text: message.text() });
@@ -219,10 +353,11 @@ export function createWebDriver({ importer, headless = true } = {}) {
219
353
  const current = requirePage();
220
354
  const started = Date.now();
221
355
  step += 1;
222
- const text = await snapshotText();
356
+ const snap = await snapshotText();
357
+ const text = snap.text;
223
358
  const observation = {
224
359
  step,
225
- text: truncateObservation(text, OBSERVATION_MAX_CHARS),
360
+ text: shownText(snap),
226
361
  url: current.url(),
227
362
  elapsedMs: Date.now() - started
228
363
  };
@@ -244,6 +379,11 @@ export function createWebDriver({ importer, headless = true } = {}) {
244
379
  // point would otherwise read as changed to `act` and identical to
245
380
  // anything reading the observation.
246
381
  lastHash = observationHash(observation);
382
+ // The same screen twice is sent as a marker rather than in full
383
+ // (./tools.js), which is most of what a stuck persona would otherwise
384
+ // pay for.
385
+ if (lastHash === lastObservedHash) observation.unchanged = true;
386
+ lastObservedHash = lastHash;
247
387
  return observation;
248
388
  },
249
389
 
@@ -293,7 +433,13 @@ export function createWebDriver({ importer, headless = true } = {}) {
293
433
  try {
294
434
  // The same truncation observe() applies, so the two hashes are
295
435
  // comparable. Without it a long screen is always "changed".
296
- changed = observationHash(truncateObservation(await snapshotText(), OBSERVATION_MAX_CHARS)) !== before;
436
+ const snap = await snapshotText();
437
+ const after = { text: shownText(snap) };
438
+ if (String(snap.text).trim().length < THIN_OBSERVATION_CHARS) {
439
+ const frame = await current.screenshot({ type: 'png', timeout: ACTION_TIMEOUT_MS });
440
+ after.screenshotBase64 = frame.toString('base64');
441
+ }
442
+ changed = observationHash(after) !== before;
297
443
  } catch {
298
444
  changed = true;
299
445
  }
@@ -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 };