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.
@@ -1,12 +1,14 @@
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';
4
5
  import { observationHash } from './drivers/driver.js';
6
+ import { withRollingCacheBreakpoint } from './fixLoop.js';
5
7
  import { callStepWithRetry, ProxyError, STEPS } from './model.js';
6
8
  import { RUN_FILES } from './paths.js';
7
9
  import { checkEvidence, createReportValidator, lintMarkdownSections, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
8
10
  import { assertNoSecrets } from './secretScan.js';
9
- import { ACTION_TOOLS, PLAY_TOOLS, REPORT_TOOLS, executeTool } from './tools.js';
11
+ import { ACTION_TOOLS, PLAY_TOOLS, SWIPE_TOOL, REPORT_TOOLS, executeTool } from './tools.js';
10
12
  import { createTranscript } from './transcript.js';
11
13
  import { createUsageLedger, writeUsageFile } from './usage.js';
12
14
 
@@ -31,11 +33,30 @@ import { createUsageLedger, writeUsageFile } from './usage.js';
31
33
  * up, so the report (which is the product) gets written while there is
32
34
  * still budget to write it with.
33
35
  * - A 402 means the money is gone. Nothing further is asked of the model,
34
- * including the report: a partial `report.md` is written locally from the
35
- * notes, `complete` is sent with `state: budget_exceeded` and no
36
- * `report`, and the process exits 2.
36
+ * including the report: a partial `report.md` and `report.json` are
37
+ * written locally from the notes (`partial: true`, each note an
38
+ * unreviewed finding), `complete` is sent with `state: budget_exceeded`
39
+ * and that report, and the process exits 2. The notes are the one thing
40
+ * the run bought before the money ran out, so they are not thrown away.
37
41
  */
38
42
 
43
+ /**
44
+ * Order across notes and screenshots, so a partial report can attach the
45
+ * screenshot nearest to each note. Module wide and only ever compared within
46
+ * one run, where it is monotonic.
47
+ */
48
+ let ORDER = 0;
49
+ const nextOrder = () => {
50
+ ORDER += 1;
51
+ return ORDER;
52
+ };
53
+
54
+ /** `Failed to load resource: ... status of 4xx`: a missing asset or a refused call, not a crash. */
55
+ const RESOURCE_4XX = /Failed to load resource: the server responded with a status of 4\d\d/;
56
+
57
+ /** A persona note kind as a report finding category. */
58
+ export const NOTE_CATEGORY = Object.freeze({ bug: 'bug', confusing: 'ux', suggestion: 'ux', good: 'ux', reaction: 'ux' });
59
+
39
60
  /** Stop injecting the "nothing changed" hint after this many identical screens. */
40
61
  export const STUCK_HINT_AFTER = 5;
41
62
 
@@ -88,7 +109,9 @@ export async function runPersona(args) {
88
109
  schemas,
89
110
  maxActions,
90
111
  maxImages = MAX_IMAGES_IN_CONTEXT,
91
- keepObservations = KEEP_OBSERVATIONS,
112
+ // A driver may say how many observations it needs in context: a web
113
+ // snapshot is big and re-sent every turn, a Godot scene summary is not.
114
+ keepObservations = (driver && Number.isInteger(driver.keepObservations) ? driver.keepObservations : KEEP_OBSERVATIONS),
92
115
  heartbeat = null,
93
116
  onProgress = () => {},
94
117
  log = () => {},
@@ -114,6 +137,7 @@ export async function runPersona(args) {
114
137
  job_id: jobId,
115
138
  persona: persona.slug,
116
139
  driver: driverName,
140
+ controller: args.controller || 'standard',
117
141
  target,
118
142
  prompt_version: promptVersionOf(skill),
119
143
  brief_id: brief ? brief.brief_id || null : null,
@@ -186,7 +210,15 @@ export async function runPersona(args) {
186
210
  outcome.quitReason = 'cost_cap';
187
211
  // Nothing more is asked of the model: there is no budget left to ask
188
212
  // with, and a refused call would just be another 402.
189
- await writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason: 'The run reached its model budget before the report step. What the persona saw is below; there is no report.json for this run.' });
213
+ // A report that cannot be written (a secret shaped note, a full disk)
214
+ // must not turn an out of budget run into a failed one.
215
+ try {
216
+ await writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt });
217
+ } catch (writeError) {
218
+ outcome.report = null;
219
+ outcome.reportMarkdown = null;
220
+ log(`the partial report could not be written: ${String(writeError && writeError.message)}`);
221
+ }
190
222
  return finish(outcome, ledger, runDirectory, transcript, heartbeat);
191
223
  }
192
224
 
@@ -228,6 +260,14 @@ export async function runPersona(args) {
228
260
  if (error instanceof ProxyError && error.isBudget) {
229
261
  outcome.state = 'budget_exceeded';
230
262
  outcome.quitReason = 'cost_cap';
263
+ // The money ran out in the report step itself: the notes are still
264
+ // worth keeping, by the same rule as a 402 during play.
265
+ try {
266
+ const play = { actionsTaken: outcome.actionsTaken, quitReason: 'cost_cap', quitDetail: String(error.message || '') };
267
+ await writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt: new Date().toISOString() });
268
+ } catch (writeError) {
269
+ log(`the partial report could not be written: ${String(writeError && writeError.message)}`);
270
+ }
231
271
  } else if (error instanceof ProxyError && error.isClosed) {
232
272
  outcome.state = 'canceled';
233
273
  outcome.quitReason = 'error';
@@ -248,6 +288,8 @@ export async function runPersona(args) {
248
288
  * @returns {Promise<Object>}
249
289
  */
250
290
  async function playLoop({
291
+ api,
292
+ controller = 'standard',
251
293
  client,
252
294
  jobId,
253
295
  runId,
@@ -270,6 +312,25 @@ async function playLoop({
270
312
  log = () => {}
271
313
  }) {
272
314
  const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions });
315
+ if (controller === 'jev') system.push({ type: 'text', text: JEV_CONTROLLER_PROMPT });
316
+ const availableTools = driver.supportedActions
317
+ ? [...PLAY_TOOLS, ...(driver.supportedActions.includes('swipe') ? [SWIPE_TOOL] : [])].filter(tool => !ACTION_TOOLS.includes(tool.name) || driver.supportedActions.includes(tool.name))
318
+ : PLAY_TOOLS;
319
+ const playTools = controller === 'jev' ? availableTools.map(tool => ({
320
+ ...tool,
321
+ input_schema: {
322
+ ...tool.input_schema,
323
+ required: [...(tool.input_schema.required || []), 'visible_state', ...(tool.name === 'click' ? ['target'] : [])],
324
+ properties: {
325
+ ...tool.input_schema.properties,
326
+ 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.' },
327
+ ...(tool.name === 'click' ? { target: { type: 'string', description: 'Label of the visible control. For canvas also supply x and y; the driver uses those coordinates.' } } : {})
328
+ }
329
+ }
330
+ })) : availableTools;
331
+ const recentActions = [];
332
+ let latestObservation = '';
333
+ let visualObservation = false;
273
334
  const messages = [];
274
335
  let actions = 0;
275
336
  let turns = 0;
@@ -283,6 +344,8 @@ async function playLoop({
283
344
  const maxTurns = maxActions * 3 + 20;
284
345
 
285
346
  const first = await driver.observe();
347
+ latestObservation = first.text;
348
+ visualObservation = Boolean(first.screenshotBase64);
286
349
  transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
287
350
  const landing = await safeScreenshot(driver, 'landing', screenshots, transcript);
288
351
  messages.push({
@@ -317,7 +380,7 @@ async function playLoop({
317
380
  step: STEPS.personaAct,
318
381
  system,
319
382
  messages,
320
- tools: PLAY_TOOLS,
383
+ tools: playTools,
321
384
  maxTokens: ACT_MAX_TOKENS,
322
385
  escalate: shouldEscalate(persona),
323
386
  imageBudget,
@@ -370,9 +433,23 @@ async function playLoop({
370
433
  break;
371
434
  }
372
435
 
436
+ let selection = null;
437
+ if (controller === 'jev') {
438
+ try {
439
+ 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 });
440
+ } catch (error) {
441
+ if (error instanceof ProxyError && error.isBudget) { budgetExceeded = true; quitReason = 'cost_cap'; quitDetail = error.message; break; }
442
+ if (error instanceof ProxyError && error.isClosed) { canceled = true; quitDetail = error.message; break; }
443
+ throw error;
444
+ }
445
+ transcript.write({ kind: 'action_selection', source: selection.source || 'jev', status: selection.status, confidence: selection.confidence, selected_tool_id: selection.toolId, reason: selection.reason });
446
+ if (selection.status !== 'selected') { quitReason = 'persona_quit'; quitDetail = 'Ravensight Playtester stopped for review: ' + selection.reason; break; }
447
+ if (selection.budget === 'warn') { budgetWarned = true; wrapUpLeft = Math.min(wrapUpLeft, WRAP_UP_TURNS); }
448
+ }
373
449
  messages.push({ role: 'assistant', content: assistantContent(answer) });
374
450
  const results = [];
375
451
  for (const toolUse of answer.toolUses) {
452
+ 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
453
  const executed = await executeTool({
377
454
  driver,
378
455
  toolUse,
@@ -381,11 +458,11 @@ async function playLoop({
381
458
  // (bug, confusing, good, suggestion, reaction) would otherwise
382
459
  // overwrite the transcript line's `kind`, and every note would
383
460
  // read as a different sort of line.
384
- notes.push({ ...note, at_action: actions });
461
+ notes.push({ ...note, at_action: actions, order: nextOrder() });
385
462
  transcript.write({ kind: 'note', tool: toolUse.name, note });
386
463
  },
387
464
  onScreenshot: shot => {
388
- screenshots.push(shot);
465
+ screenshots.push({ ...shot, order: nextOrder() });
389
466
  transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: shot.reason });
390
467
  }
391
468
  });
@@ -402,6 +479,10 @@ async function playLoop({
402
479
  actions
403
480
  });
404
481
  if (executed.observation) {
482
+ latestObservation = executed.observation.text;
483
+ visualObservation = Boolean(executed.observation.screenshotBase64);
484
+ recentActions.push(`${toolUse.name}: ${executed.changed ? 'screen changed' : 'no observed change'}`);
485
+ if (recentActions.length > 10) recentActions.shift();
405
486
  transcript.write({ kind: 'observation', step: executed.observation.step, text: executed.observation.text, url: executed.observation.url });
406
487
  // Only an input to the game can tell us anything about being stuck. A
407
488
  // `snapshot` or a `screenshot` never changes the screen by design, so
@@ -423,13 +504,17 @@ async function playLoop({
423
504
 
424
505
  const consoleErrors = typeof driver.consoleErrors === 'function' ? await driver.consoleErrors() : [];
425
506
  if (consoleErrors.length > 0) {
426
- // An uncaught error in the game is a finding whether or not the
507
+ // An uncaught error in the game is worth recording whether or not the
427
508
  // persona noticed it, so it is logged and evidenced automatically
428
- // (spec 03). It is the model's job to decide the severity.
509
+ // (spec 03). It carries no severity: the report step decides that,
510
+ // with the persona's view of the screen in front of it. A 4xx on a
511
+ // resource is a `network` note, which the report step reads as context
512
+ // rather than as a bug (a missing favicon is not a broken game).
429
513
  const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript);
430
514
  for (const entry of consoleErrors) {
431
- const note = { kind: 'bug', severity: 'major', text: `${entry.type}: ${entry.text}`, automatic: true };
432
- notes.push(note);
515
+ const kind = RESOURCE_4XX.test(String(entry.text)) ? 'network' : 'bug';
516
+ const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true };
517
+ notes.push({ ...note, at_action: actions, order: nextOrder() });
433
518
  transcript.write({ kind: 'note', note });
434
519
  }
435
520
  asides.push(`The game logged ${consoleErrors.length} error(s): ${consoleErrors.map(entry => entry.text).join(' | ').slice(0, 800)}${shot ? ` A screenshot is saved at ${shot.relativePath}.` : ''}`);
@@ -517,6 +602,8 @@ async function reportPhase({
517
602
  'Notes you logged, in order:',
518
603
  notes.length > 0 ? notes.map((note, i) => `${i + 1}. [${note.kind}${note.severity ? `/${note.severity}` : ''}] ${note.text}`).join('\n') : '(none)',
519
604
  '',
605
+ 'Notes marked automatic were recorded by the runner from the console, with no severity: decide one from what the persona saw. Network notes are context, not bugs: a failed request is a finding only when the persona saw a broken screen because of it.',
606
+ '',
520
607
  'Screenshots available as evidence (use these exact paths in evidence refs):',
521
608
  screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}${shot.reason ? ` (${shot.reason})` : ''}`).join('\n') : '(none)',
522
609
  '',
@@ -642,7 +729,10 @@ async function reportPhase({
642
729
  async function callWithImageRetry({ imageBudget, keepObservations, messages, onImageBudget, ...rest }) {
643
730
  let budget = imageBudget;
644
731
  for (let attempt = 0; attempt < 3; attempt += 1) {
645
- const prepared = prepareMessages(messages, { maxImages: budget, keepObservations });
732
+ // One moving cache breakpoint on the newest block, as the fix loop does:
733
+ // the prefix up to it is what the next turn re-sends, so the next turn
734
+ // reads it from the cache instead of paying for it again.
735
+ const prepared = withRollingCacheBreakpoint(prepareMessages(messages, { maxImages: budget, keepObservations }));
646
736
  try {
647
737
  return await callStepWithRetry({ ...rest, messages: prepared });
648
738
  } catch (error) {
@@ -758,7 +848,7 @@ function redactToolInput(toolUse) {
758
848
  async function safeScreenshot(driver, name, screenshots, transcript) {
759
849
  try {
760
850
  const shot = await driver.screenshot(name);
761
- screenshots.push({ ...shot, reason: name });
851
+ screenshots.push({ ...shot, reason: name, order: nextOrder() });
762
852
  transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: name });
763
853
  return shot;
764
854
  } catch (error) {
@@ -850,6 +940,104 @@ export function renderFallbackMarkdown({ persona, driverName, target, notes, scr
850
940
  ].join('\n');
851
941
  }
852
942
 
943
+ /**
944
+ * The screenshot nearest a note, by the order they happened in; a tie goes
945
+ * to the later one, since a persona usually notes something and then
946
+ * captures it.
947
+ *
948
+ * @param {Object} note
949
+ * @param {Array<Object>} screenshots
950
+ * @returns {Object|null}
951
+ */
952
+ export function nearestScreenshot(note, screenshots) {
953
+ let best = null;
954
+ let bestDistance = Infinity;
955
+ for (const shot of screenshots) {
956
+ if (!shot || !shot.relativePath || !Number.isFinite(shot.order) || !Number.isFinite(note.order)) continue;
957
+ const distance = Math.abs(shot.order - note.order);
958
+ if (distance < bestDistance || (distance === bestDistance && best && shot.order > best.order)) {
959
+ best = shot;
960
+ bestDistance = distance;
961
+ }
962
+ }
963
+ return best || screenshots.find(shot => shot && shot.relativePath) || null;
964
+ }
965
+
966
+ /**
967
+ * The persona's notes as report findings, for a run with no report step.
968
+ *
969
+ * Nothing is judged here, because there is no model turn to judge with:
970
+ * the persona's own kind becomes the category, its own severity is kept
971
+ * when it gave one and is `minor` otherwise (`praise` for a `good` note), and
972
+ * a network note is left out, since it is context rather than a finding.
973
+ *
974
+ * @param {Array<Object>} notes
975
+ * @param {Array<Object>} screenshots
976
+ * @returns {Array<Object>}
977
+ */
978
+ export function notesToFindings(notes, screenshots) {
979
+ const findings = [];
980
+ for (const note of notes) {
981
+ const category = NOTE_CATEGORY[note.kind];
982
+ if (!category || !note.text) continue;
983
+ const text = String(note.text);
984
+ const shot = nearestScreenshot(note, screenshots);
985
+ findings.push({
986
+ id: `F${String(findings.length + 1).padStart(3, '0')}`,
987
+ severity: note.kind === 'good' ? 'praise' : (note.severity && note.severity !== 'praise' ? note.severity : 'minor'),
988
+ category,
989
+ title: text.length > 120 ? `${text.slice(0, 117)}...` : text,
990
+ description: text,
991
+ repro_steps: [`Seen after ${Number(note.at_action) || 0} action(s) in this run.`],
992
+ evidence: shot
993
+ ? [{ type: 'screenshot', ref: shot.relativePath }]
994
+ : [{ type: 'transcript_step', ref: `action ${Number(note.at_action) || 0}` }]
995
+ });
996
+ }
997
+ return findings;
998
+ }
999
+
1000
+ /**
1001
+ * The report for a run the money ran out on: the notes as findings,
1002
+ * `partial: true`, and both files written. The sentiment is the scale's
1003
+ * midpoint and `would_continue` is false, because the schema requires both
1004
+ * and no persona verdict was recorded; `partial` is what tells a reader not
1005
+ * to read them as one.
1006
+ */
1007
+ async function writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt }) {
1008
+ const report = stampReport({
1009
+ findings: notesToFindings(notes, screenshots),
1010
+ sentiment: { fun: 3, clarity: 3, frustration: 3 },
1011
+ would_continue: false,
1012
+ partial: true
1013
+ }, {
1014
+ runId,
1015
+ jobId,
1016
+ persona: persona.slug,
1017
+ driver: driverName,
1018
+ target,
1019
+ startedAt,
1020
+ endedAt,
1021
+ actionsTaken: play.actionsTaken,
1022
+ usage: ledger.toReportUsage(),
1023
+ promptVersion: promptVersionOf(skill) || 'unknown',
1024
+ quitReason: 'cost_cap',
1025
+ quitDetail: play.quitDetail
1026
+ });
1027
+ const markdown = renderFallbackMarkdown({
1028
+ persona,
1029
+ driverName,
1030
+ target,
1031
+ notes,
1032
+ screenshots,
1033
+ play,
1034
+ reason: 'The run reached its model budget before the report step. What the persona saw is below, and report.json carries the notes as unreviewed findings.'
1035
+ });
1036
+ await writeReportFiles({ runDirectory, markdown, report });
1037
+ outcome.report = report;
1038
+ outcome.reportMarkdown = markdown;
1039
+ }
1040
+
853
1041
  async function writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason }) {
854
1042
  const markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason });
855
1043
  assertNoSecrets('report.md', markdown);
@@ -868,4 +1056,4 @@ export function promptVersionOf(skill) {
868
1056
  return match ? match[1] : null;
869
1057
  }
870
1058
 
871
- export default { runPersona, buildSystem, promptVersionOf, shouldEscalate, renderFallbackMarkdown };
1059
+ export default { runPersona, buildSystem, promptVersionOf, shouldEscalate, renderFallbackMarkdown, notesToFindings, nearestScreenshot };
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
@@ -1,3 +1,4 @@
1
+ import { UNCHANGED_OBSERVATION_TEXT } from './context.js';
1
2
  import { OBSERVATION_MAX_CHARS, truncateObservation } from './drivers/driver.js';
2
3
 
3
4
  /**
@@ -29,7 +30,16 @@ export const NOTE_KINDS = Object.freeze(['bug', 'confusing', 'good', 'suggestion
29
30
  export const SEVERITIES = Object.freeze(['blocker', 'major', 'minor', 'cosmetic', 'praise']);
30
31
 
31
32
  /** Tools that spend an action. */
32
- export const ACTION_TOOLS = Object.freeze(['click', 'type', 'key', 'navigate']);
33
+ export const ACTION_TOOLS = Object.freeze(['click', 'type', 'key', 'navigate', 'swipe']);
34
+
35
+ export const SWIPE_TOOL = {
36
+ name: 'swipe',
37
+ description: 'Drag from one screenshot coordinate to another. Spends one action.',
38
+ input_schema: {
39
+ type: 'object', additionalProperties: false, required: ['x', 'y', 'toX', 'toY'],
40
+ properties: Object.fromEntries(['x', 'y', 'toX', 'toY', 'durationMs'].map(key => [key, { type: 'number', minimum: 0, maximum: key === 'durationMs' ? 2000 : 32768 }]))
41
+ }
42
+ };
33
43
 
34
44
  const thought = {
35
45
  type: 'string',
@@ -185,6 +195,8 @@ export function actionFor(toolUse) {
185
195
  return { kind: 'navigate', url: String(input.url || '') };
186
196
  case 'wait':
187
197
  return { kind: 'wait', ms: Number(input.ms) || 0 };
198
+ case 'swipe':
199
+ return { kind: 'swipe', from: { x: input.x, y: input.y }, to: { x: input.toX, y: input.toY }, durationMs: input.durationMs };
188
200
  default:
189
201
  return null;
190
202
  }
@@ -289,13 +301,17 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
289
301
  * @param {boolean} [isError]
290
302
  */
291
303
  export function observationResult(id, observation, prefix = '', isError = false) {
304
+ // The same screen as last time is a marker, not a resend: the persona
305
+ // already has it in context, and a stuck run would otherwise pay for the
306
+ // whole snapshot (and its image) on every turn it stays stuck.
307
+ const body = observation.unchanged ? UNCHANGED_OBSERVATION_TEXT : truncateObservation(observation.text, OBSERVATION_MAX_CHARS);
292
308
  const content = [
293
309
  {
294
310
  type: 'text',
295
- text: `${prefix}step ${observation.step}${observation.url ? ` (${observation.url})` : ''}${citationLine(observation)}\n${truncateObservation(observation.text, OBSERVATION_MAX_CHARS)}`
311
+ text: `${prefix}step ${observation.step}${observation.url ? ` (${observation.url})` : ''}${citationLine(observation)}\n${body}`
296
312
  }
297
313
  ];
298
- if (observation.screenshotBase64) {
314
+ if (observation.screenshotBase64 && !observation.unchanged) {
299
315
  content.push({
300
316
  type: 'image',
301
317
  source: {
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([
package/src/ui/index.js CHANGED
@@ -157,6 +157,33 @@ export async function confirm(question, options = {}) {
157
157
  }
158
158
  }
159
159
 
160
+ /**
161
+ * Ask the person to pick one of a numbered list. Like `confirm`, it never
162
+ * asks without a terminal: with no TTY (CI, a pipe) it answers null at once,
163
+ * and so does an answer that is not one of the numbers shown.
164
+ *
165
+ * @param {string} question e.g. "Which game? [1-3]"
166
+ * @param {Array<string>} labels one line per choice, printed numbered from 1
167
+ * @param {{input?: object, output?: object}} [options]
168
+ * @returns {Promise<number|null>} the zero based index, or null
169
+ */
170
+ export async function choose(question, labels, options = {}) {
171
+ const input = options.input || process.stdin;
172
+ if (!input.isTTY) return null;
173
+ const output = options.output || stream;
174
+
175
+ labels.forEach((label, index) => output.write(` ${index + 1}. ${label}\n`));
176
+ const rl = createInterface({ input, output });
177
+ try {
178
+ const answer = (await rl.question(`${question} `)).trim();
179
+ if (!/^\d+$/.test(answer)) return null;
180
+ const index = Number(answer) - 1;
181
+ return index >= 0 && index < labels.length ? index : null;
182
+ } finally {
183
+ rl.close();
184
+ }
185
+ }
186
+
160
187
  /** Cents as dollars, the one place that formatting exists. */
161
188
  export function money(cents) {
162
189
  if (cents === null || cents === undefined) return 'unknown';
@@ -190,6 +217,7 @@ export const ui = {
190
217
  table,
191
218
  spinner,
192
219
  confirm,
220
+ choose,
193
221
  money,
194
222
  bytes
195
223
  };