ravensight-playtest 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/addons/ravensight_driver/driver.gd +836 -0
  4. package/addons/ravensight_driver/export_plugin.gd +51 -0
  5. package/addons/ravensight_driver/plugin.cfg +7 -0
  6. package/addons/ravensight_driver/plugin.gd +36 -0
  7. package/bin/ravensight-playtest.js +31 -0
  8. package/package.json +45 -0
  9. package/src/api/README.md +500 -0
  10. package/src/api/client.js +340 -0
  11. package/src/api/errors.js +115 -0
  12. package/src/api/http.js +194 -0
  13. package/src/api/index.js +107 -0
  14. package/src/auth/deviceCode.js +79 -0
  15. package/src/auth/keychain.js +159 -0
  16. package/src/auth/session.js +128 -0
  17. package/src/cli.js +335 -0
  18. package/src/commands/brief.js +303 -0
  19. package/src/commands/check.js +318 -0
  20. package/src/commands/fakeCore.js +379 -0
  21. package/src/commands/init.js +120 -0
  22. package/src/commands/login.js +90 -0
  23. package/src/commands/logout.js +70 -0
  24. package/src/commands/open.js +125 -0
  25. package/src/commands/profile.js +262 -0
  26. package/src/commands/resume.js +156 -0
  27. package/src/commands/run.js +1015 -0
  28. package/src/commands/upload.js +137 -0
  29. package/src/config.js +100 -0
  30. package/src/dashboard.js +97 -0
  31. package/src/detect.js +77 -0
  32. package/src/errors.js +44 -0
  33. package/src/fsutil.js +77 -0
  34. package/src/godot.js +85 -0
  35. package/src/packs/index.js +191 -0
  36. package/src/paths.js +129 -0
  37. package/src/run/aggregate.js +658 -0
  38. package/src/run/args.js +111 -0
  39. package/src/run/context.js +181 -0
  40. package/src/run/deps.js +184 -0
  41. package/src/run/drivers/driver.js +183 -0
  42. package/src/run/drivers/godot-observation.js +138 -0
  43. package/src/run/drivers/godot-project.js +475 -0
  44. package/src/run/drivers/godot-rpc.js +225 -0
  45. package/src/run/drivers/godot.js +587 -0
  46. package/src/run/drivers/index.js +52 -0
  47. package/src/run/drivers/web.js +385 -0
  48. package/src/run/exit.js +21 -0
  49. package/src/run/heartbeat.js +131 -0
  50. package/src/run/index.js +31 -0
  51. package/src/run/json.js +56 -0
  52. package/src/run/model.js +384 -0
  53. package/src/run/paths.js +88 -0
  54. package/src/run/personaLoop.js +871 -0
  55. package/src/run/profile.js +214 -0
  56. package/src/run/regenerate.js +149 -0
  57. package/src/run/repoTools.js +286 -0
  58. package/src/run/report.js +222 -0
  59. package/src/run/resume.js +272 -0
  60. package/src/run/secretScan.js +171 -0
  61. package/src/run/state.js +198 -0
  62. package/src/run/synthetic.js +206 -0
  63. package/src/run/tools.js +344 -0
  64. package/src/run/transcript.js +93 -0
  65. package/src/run/usage.js +115 -0
  66. package/src/state/index.js +105 -0
  67. package/src/states.js +104 -0
  68. package/src/ui/index.js +195 -0
  69. package/src/upload/allowlist.js +116 -0
  70. package/src/upload/index.js +467 -0
  71. package/src/upload/queue.js +114 -0
  72. package/src/version.js +63 -0
@@ -0,0 +1,385 @@
1
+ import { mkdir, rename, stat, readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {
4
+ DriverError,
5
+ OBSERVATION_MAX_CHARS,
6
+ THIN_OBSERVATION_CHARS,
7
+ observationHash,
8
+ screenshotName,
9
+ truncateObservation
10
+ } from './driver.js';
11
+
12
+ /**
13
+ * The `playwright_web` driver: Playwright as a library, not through
14
+ * `@playwright/mcp`.
15
+ *
16
+ * The PoC drove the browser through the Playwright MCP server, which the
17
+ * plan rules out for production (v1-plan W9, "Not reused: ... Playwright
18
+ * MCP is not viable; use the library"), and the model proxy makes that
19
+ * concrete: it refuses a body carrying `mcp_servers` outright. So the tool
20
+ * surface is client tools over this object, and this object is the only
21
+ * thing with a browser handle.
22
+ *
23
+ * Playwright is an optional peer: a `npm i -g ravensight-playtest` should
24
+ * not drag a 300 MB browser download onto a machine that only ever runs
25
+ * `godot_driver`. It is imported lazily and a missing install is an
26
+ * environment error with the two commands that fix it, which is the same
27
+ * thing `check` reports.
28
+ */
29
+
30
+ /** Default recording and viewport size. Kept at 720p to match the upload guidance. */
31
+ export const DEFAULT_VIEWPORT = Object.freeze({ width: 1280, height: 720 });
32
+
33
+ /** A `wait` action is clamped to this, so one turn cannot burn the wall clock. */
34
+ export const MAX_WAIT_MS = 10000;
35
+
36
+ /** How long a single Playwright call may take before it is an error, not a hang. */
37
+ export const ACTION_TIMEOUT_MS = 15000;
38
+
39
+ const KNOWN_SELECTOR_ENGINES = /^(role|text|css|xpath|id|data-testid|internal:)=/;
40
+
41
+ /**
42
+ * @param {string} target
43
+ * @returns {Promise<any>} the playwright module
44
+ */
45
+ async function loadPlaywright(importer) {
46
+ const load = importer || (specifier => import(specifier));
47
+ try {
48
+ return await load('playwright');
49
+ } catch (error) {
50
+ throw new DriverError(
51
+ 'The playwright_web driver needs Playwright and its Chromium build. Install them with "npm i -D playwright" and "npx playwright install chromium", then run check again.',
52
+ { fatal: true }
53
+ );
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Whether the model is allowed to navigate here. The persona may only move
59
+ * around inside the build it was pointed at, plus the rest of loopback when
60
+ * the build ITSELF is on loopback (a local web export is commonly served on
61
+ * one port with an asset or API server on another).
62
+ *
63
+ * Game text is untrusted input: a level name or a leaderboard entry can
64
+ * carry "go to https://.../ and paste your token". Confining `navigate` to
65
+ * the target's own origin means the worst such an instruction can achieve
66
+ * is a page of the developer's own game.
67
+ *
68
+ * The loopback allowance is conditional for a reason. A hosted build has no
69
+ * business steering the browser at the developer's own machine: `localhost`
70
+ * there is whatever else they happen to be running, dev servers and admin
71
+ * panels included, and reaching it is a genuine escalation rather than a
72
+ * convenience.
73
+ *
74
+ * @param {string} candidate
75
+ * @param {string} target
76
+ * @returns {boolean}
77
+ */
78
+ export function isNavigationAllowed(candidate, target) {
79
+ let url;
80
+ let base;
81
+ try {
82
+ url = new URL(candidate);
83
+ base = new URL(target);
84
+ } catch {
85
+ return false;
86
+ }
87
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
88
+ if (url.origin === base.origin) return true;
89
+ return isLoopback(base.hostname) && isLoopback(url.hostname);
90
+ }
91
+
92
+ const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]', '::1'];
93
+
94
+ /**
95
+ * @param {string} hostname
96
+ * @returns {boolean}
97
+ */
98
+ export function isLoopback(hostname) {
99
+ return LOOPBACK_HOSTS.includes(hostname) || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
100
+ }
101
+
102
+ /**
103
+ * Turn what the model wrote into a Playwright locator.
104
+ *
105
+ * An aria snapshot prints nodes as `- button "Start"`, so that form is
106
+ * accepted directly and rewritten into the `role=` engine. Anything with a
107
+ * known engine prefix is passed through, a CSS-looking string is treated
108
+ * as CSS, and everything else falls back to visible text, which is what a
109
+ * player would actually be aiming at.
110
+ *
111
+ * @param {any} page
112
+ * @param {string} target
113
+ * @returns {any} a locator
114
+ */
115
+ export function resolveLocator(page, target) {
116
+ const value = String(target).trim();
117
+ if (KNOWN_SELECTOR_ENGINES.test(value)) return page.locator(value);
118
+
119
+ // The name group cannot contain a quote (`[^"]*`), so there is nothing to
120
+ // escape when it is spliced back into the `role=` engine. A target whose
121
+ // name really does contain a quote falls through to the text search below.
122
+ const roleAndName = value.match(/^([a-z]+)\s+"([^"]*)"$/);
123
+ if (roleAndName) return page.locator(`role=${roleAndName[1]}[name="${roleAndName[2]}"]`);
124
+
125
+ if (/^[#.[]/.test(value) || /^[a-z][a-z0-9-]*(\[|\.|#|:| >)/i.test(value)) return page.locator(value);
126
+
127
+ return page.getByText(value, { exact: false }).first();
128
+ }
129
+
130
+ /**
131
+ * @param {Object} opts
132
+ * @param {(specifier: string) => Promise<any>} [opts.importer] test seam
133
+ * @returns {Object} a driver
134
+ */
135
+ export function createWebDriver({ importer, headless = true } = {}) {
136
+ let browser = null;
137
+ let context = null;
138
+ let page = null;
139
+ let target = null;
140
+ let runDirectory = null;
141
+ let videoRequested = false;
142
+ let step = 0;
143
+ let shots = 0;
144
+ let lastHash = null;
145
+ const consoleBuffer = [];
146
+
147
+ /** @returns {any} */
148
+ function requirePage() {
149
+ if (!page) throw new DriverError('the browser is not open; launch() first', { fatal: true });
150
+ return page;
151
+ }
152
+
153
+ async function snapshotText() {
154
+ const current = requirePage();
155
+ let text = '';
156
+ const body = current.locator('body');
157
+ if (typeof body.ariaSnapshot === 'function') {
158
+ try {
159
+ text = await body.ariaSnapshot({ timeout: ACTION_TIMEOUT_MS });
160
+ } catch {
161
+ text = '';
162
+ }
163
+ }
164
+ if (text.trim().length < THIN_OBSERVATION_CHARS) {
165
+ try {
166
+ const inner = await current.evaluate(() => {
167
+ const el = globalThis.document && globalThis.document.body;
168
+ return el ? el.innerText : '';
169
+ });
170
+ if (String(inner).trim().length > text.trim().length) text = String(inner);
171
+ } catch {
172
+ // A page mid-navigation has no body to read. The thin observation
173
+ // is the honest answer, and the screenshot fallback below covers it.
174
+ }
175
+ }
176
+ return text;
177
+ }
178
+
179
+ return {
180
+ name: 'playwright_web',
181
+
182
+ /**
183
+ * @param {Object} opts
184
+ * @param {string} opts.target the build URL
185
+ * @param {string} opts.runDir where video and screenshots land
186
+ * @param {boolean} [opts.recordVideo]
187
+ * @param {{width: number, height: number}} [opts.viewport]
188
+ */
189
+ async launch({ target: url, runDir, recordVideo = false, viewport = DEFAULT_VIEWPORT }) {
190
+ if (!url) throw new DriverError('playwright_web needs a target URL', { fatal: true });
191
+ target = url;
192
+ runDirectory = runDir;
193
+ videoRequested = Boolean(recordVideo);
194
+
195
+ const playwright = await loadPlaywright(importer);
196
+ await mkdir(runDir, { recursive: true });
197
+
198
+ browser = await playwright.chromium.launch({ headless });
199
+ context = await browser.newContext({
200
+ viewport,
201
+ ...(videoRequested ? { recordVideo: { dir: runDir, size: viewport } } : {})
202
+ });
203
+ page = await context.newPage();
204
+ page.on('console', message => {
205
+ if (message.type() === 'error') consoleBuffer.push({ type: 'console', text: message.text() });
206
+ });
207
+ page.on('pageerror', error => consoleBuffer.push({ type: 'pageerror', text: String(error && error.message) }));
208
+
209
+ const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
210
+ // A 404 is not something to guess around: the build is not there, and
211
+ // guessing URLs is exactly what spec 03 forbids.
212
+ if (response && response.status() >= 400) {
213
+ throw new DriverError(`${url} answered ${response.status()}`, { fatal: true });
214
+ }
215
+ },
216
+
217
+ /** @returns {Promise<import('./driver.js').Observation>} */
218
+ async observe() {
219
+ const current = requirePage();
220
+ const started = Date.now();
221
+ step += 1;
222
+ const text = await snapshotText();
223
+ const observation = {
224
+ step,
225
+ text: truncateObservation(text, OBSERVATION_MAX_CHARS),
226
+ url: current.url(),
227
+ elapsedMs: Date.now() - started
228
+ };
229
+ // Canvas builds (a Godot or Unity web export is one <canvas> and
230
+ // nothing else) have no accessibility tree worth reading, so vision
231
+ // is the only way to play them. Spend an image only then.
232
+ if (String(text).trim().length < THIN_OBSERVATION_CHARS) {
233
+ try {
234
+ const buffer = await current.screenshot({ type: 'png', timeout: ACTION_TIMEOUT_MS });
235
+ observation.screenshotBase64 = buffer.toString('base64');
236
+ observation.screenshotMediaType = 'image/png';
237
+ } catch {
238
+ // No image and thin text is still an observation; the persona will
239
+ // read it as a blank screen, which is what it is.
240
+ }
241
+ }
242
+ // Hash what was REPORTED, not the untruncated snapshot: `act` compares
243
+ // against this, so two screens that differ only past the truncation
244
+ // point would otherwise read as changed to `act` and identical to
245
+ // anything reading the observation.
246
+ lastHash = observationHash(observation);
247
+ return observation;
248
+ },
249
+
250
+ /**
251
+ * @param {import('./driver.js').Action} action
252
+ * @returns {Promise<import('./driver.js').ActResult>}
253
+ */
254
+ async act(action) {
255
+ const current = requirePage();
256
+ const before = lastHash;
257
+ try {
258
+ switch (action.kind) {
259
+ case 'key':
260
+ await current.keyboard.press(String(action.key), { timeout: ACTION_TIMEOUT_MS });
261
+ break;
262
+ case 'type':
263
+ await current.keyboard.type(String(action.text), { delay: 20 });
264
+ if (action.submit) await current.keyboard.press('Enter');
265
+ break;
266
+ case 'click': {
267
+ if (action.target && typeof action.target === 'object') {
268
+ await current.mouse.click(Number(action.target.x), Number(action.target.y));
269
+ break;
270
+ }
271
+ await resolveLocator(current, action.target).click({ timeout: ACTION_TIMEOUT_MS });
272
+ break;
273
+ }
274
+ case 'navigate': {
275
+ const url = String(action.url);
276
+ if (!isNavigationAllowed(url, target)) {
277
+ return { ok: false, changed: false, error: `navigation to ${url} is outside this build's origin (${new URL(target).origin}) and was refused` };
278
+ }
279
+ await current.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
280
+ break;
281
+ }
282
+ case 'wait':
283
+ await current.waitForTimeout(Math.min(Math.max(Number(action.ms) || 0, 0), MAX_WAIT_MS));
284
+ break;
285
+ default:
286
+ return { ok: false, changed: false, error: `the playwright_web driver cannot do "${action.kind}"` };
287
+ }
288
+ } catch (error) {
289
+ return { ok: false, changed: false, error: String(error && error.message ? error.message : error) };
290
+ }
291
+
292
+ let changed = false;
293
+ try {
294
+ // The same truncation observe() applies, so the two hashes are
295
+ // comparable. Without it a long screen is always "changed".
296
+ changed = observationHash(truncateObservation(await snapshotText(), OBSERVATION_MAX_CHARS)) !== before;
297
+ } catch {
298
+ changed = true;
299
+ }
300
+ return { ok: true, changed };
301
+ },
302
+
303
+ /**
304
+ * @param {string} name
305
+ * @returns {Promise<{path: string, relativePath: string}>}
306
+ */
307
+ async screenshot(name) {
308
+ const current = requirePage();
309
+ shots += 1;
310
+ const relativePath = path.join('screenshots', screenshotName(shots, name));
311
+ const absolute = path.join(runDirectory, relativePath);
312
+ await mkdir(path.dirname(absolute), { recursive: true });
313
+ await current.screenshot({ path: absolute, type: 'png', timeout: ACTION_TIMEOUT_MS });
314
+ return { path: absolute, relativePath };
315
+ },
316
+
317
+ /** @returns {Promise<Array<{type: string, text: string}>>} */
318
+ async consoleErrors() {
319
+ return consoleBuffer.splice(0, consoleBuffer.length);
320
+ },
321
+
322
+ /** @returns {Promise<{videoPath: string|null}>} */
323
+ async stop() {
324
+ let videoPath = null;
325
+ const video = videoRequested && page && typeof page.video === 'function' ? page.video() : null;
326
+ try {
327
+ if (context) await context.close();
328
+ } catch {
329
+ // A context that is already gone is not an error worth failing the
330
+ // run over; the report is already written by the time we get here.
331
+ }
332
+ if (video) {
333
+ try {
334
+ videoPath = await renameVideo(await video.path(), runDirectory);
335
+ } catch {
336
+ videoPath = await findVideo(runDirectory);
337
+ }
338
+ }
339
+ try {
340
+ if (browser) await browser.close();
341
+ } catch {
342
+ // Same: a closed browser is the desired end state either way.
343
+ }
344
+ page = null;
345
+ context = null;
346
+ browser = null;
347
+ return { videoPath };
348
+ }
349
+ };
350
+ }
351
+
352
+ /**
353
+ * Playwright names its recording with a random id. The upload allowlist and
354
+ * the server's 90 day lifecycle rule are written against `session.webm`, so
355
+ * the file is renamed rather than uploaded under whatever name it got.
356
+ *
357
+ * @param {string} source
358
+ * @param {string} runDir
359
+ * @returns {Promise<string>}
360
+ */
361
+ async function renameVideo(source, runDir) {
362
+ const target = path.join(runDir, 'session.webm');
363
+ if (!source || path.resolve(source) === path.resolve(target)) return target;
364
+ await rename(source, target);
365
+ return target;
366
+ }
367
+
368
+ /**
369
+ * @param {string} runDir
370
+ * @returns {Promise<string|null>}
371
+ */
372
+ async function findVideo(runDir) {
373
+ try {
374
+ const entries = await readdir(runDir);
375
+ const webm = entries.find(entry => entry.endsWith('.webm'));
376
+ if (!webm) return null;
377
+ const source = path.join(runDir, webm);
378
+ await stat(source);
379
+ return await renameVideo(source, runDir);
380
+ } catch {
381
+ return null;
382
+ }
383
+ }
384
+
385
+ export default createWebDriver;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Process exit codes, from spec 17 section 1.2:
3
+ *
4
+ * 0 success, 2 budget exceeded (partial results uploaded), 3 environment
5
+ * check failed, 4 auth, 5 model API error after retries, 10 canceled.
6
+ *
7
+ * CI treats 2 as success-with-warning behind a flag, so 2 must never be
8
+ * used for anything but "we ran out of money and still shipped what we
9
+ * had".
10
+ */
11
+ export const EXIT = Object.freeze({
12
+ OK: 0,
13
+ FAILED: 1,
14
+ BUDGET: 2,
15
+ ENVIRONMENT: 3,
16
+ AUTH: 4,
17
+ MODEL: 5,
18
+ CANCELED: 10
19
+ });
20
+
21
+ export default EXIT;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The heartbeat, and the only channel through which a run learns it has
3
+ * been cancelled.
4
+ *
5
+ * `PATCH /jobs/:jobId/runs/:runId` with no `state` is a heartbeat, and its
6
+ * answer carries two things the runner needs and cannot get anywhere else:
7
+ *
8
+ * - `cancel_requested`, set by `POST /jobs/:jobId/cancel`. A runner that
9
+ * polls nothing else still stops, because every heartbeat answers with it.
10
+ * (The model proxy reads the same flag and starts refusing calls, so the
11
+ * run would stop either way; the difference is stopping cleanly with a
12
+ * partial report instead of on a 409.)
13
+ * - `run.usage.total_usd`, the authoritative spend. Prices are server side
14
+ * only, so this is where `usage.json`'s dollar figure comes from.
15
+ *
16
+ * The server's housekeeping sweep moves a run to `interrupted` when its
17
+ * `lastHeartbeatAt` is older than 30 minutes, so the interval only has to be
18
+ * comfortably under that; spec 17 asks for 5 seconds or less while online,
19
+ * which is what the default is.
20
+ *
21
+ * A failed beat is not fatal. The network dropping for twenty seconds should
22
+ * not kill a paid run, and the sweep's 30 minute window is the real
23
+ * deadline. Failures are counted and surfaced; the caller decides.
24
+ */
25
+
26
+ export const DEFAULT_INTERVAL_MS = 5000;
27
+
28
+ /**
29
+ * @param {Object} args
30
+ * @param {Object} args.api the deps api surface
31
+ * @param {string} args.gameId
32
+ * @param {string} args.jobId
33
+ * @param {string} args.runId
34
+ * @param {number} [args.intervalMs]
35
+ * @param {() => void} [args.onCancel] called once, when the server first
36
+ * reports cancel_requested
37
+ * @param {(usage: Object) => void} [args.onUsage]
38
+ * @param {(patch: Object) => void|Promise<void>} [args.onBeat] the progress this
39
+ * beat reported, after the server accepted it
40
+ * @param {(error: Error) => void} [args.onError]
41
+ * @param {Function} [args.setIntervalImpl] test seam
42
+ * @param {Function} [args.clearIntervalImpl] test seam
43
+ */
44
+ export function createHeartbeat({
45
+ api,
46
+ gameId,
47
+ jobId,
48
+ runId,
49
+ intervalMs = DEFAULT_INTERVAL_MS,
50
+ onCancel,
51
+ onUsage,
52
+ onBeat,
53
+ onError,
54
+ setIntervalImpl = setInterval,
55
+ clearIntervalImpl = clearInterval
56
+ }) {
57
+ let timer = null;
58
+ let canceled = false;
59
+ let failures = 0;
60
+ let inFlight = false;
61
+ // The progress the loop last reported. The interval beat carries it too,
62
+ // rather than resetting the server's and the journal's idea of the
63
+ // checkpoint to zero every five seconds.
64
+ let progress = {};
65
+
66
+ /**
67
+ * @param {{actionsTaken?: number, checkpointStep?: number}} [patch] both
68
+ * ride along on the heartbeat rather than costing a second write.
69
+ * camelCase, because that is the shape cli-core's `api.runs.heartbeat`
70
+ * takes (it translates to the server's snake_case body itself).
71
+ */
72
+ async function beat(patch = {}) {
73
+ progress = { ...progress, ...patch };
74
+ // A beat that overlaps the previous one is dropped rather than queued:
75
+ // the next tick is five seconds away and a stale beat proves nothing.
76
+ if (inFlight) return null;
77
+ inFlight = true;
78
+ try {
79
+ const answer = await api.runs.heartbeat(gameId, jobId, runId, { heartbeat: true, ...progress });
80
+ failures = 0;
81
+ if (onBeat) await onBeat({ ...progress });
82
+ if (answer && answer.run && answer.run.usage && onUsage) onUsage(answer.run.usage);
83
+ if (answer && answer.cancel_requested && !canceled) {
84
+ canceled = true;
85
+ if (onCancel) onCancel();
86
+ }
87
+ return answer;
88
+ } catch (error) {
89
+ failures += 1;
90
+ if (onError) onError(error);
91
+ return null;
92
+ } finally {
93
+ inFlight = false;
94
+ }
95
+ }
96
+
97
+ return {
98
+ beat,
99
+
100
+ get canceled() {
101
+ return canceled;
102
+ },
103
+
104
+ get failures() {
105
+ return failures;
106
+ },
107
+
108
+ /** The last progress reported, for a caller that needs to journal it. */
109
+ get progress() {
110
+ return { ...progress };
111
+ },
112
+
113
+ start() {
114
+ if (timer) return this;
115
+ timer = setIntervalImpl(() => {
116
+ beat().catch(() => {});
117
+ }, intervalMs);
118
+ // Never hold the process open for a heartbeat.
119
+ if (timer && typeof timer.unref === 'function') timer.unref();
120
+ return this;
121
+ },
122
+
123
+ stop() {
124
+ if (timer) clearIntervalImpl(timer);
125
+ timer = null;
126
+ return this;
127
+ }
128
+ };
129
+ }
130
+
131
+ export default createHeartbeat;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The runner's entry point, and the only module cli-core's CLI imports from
3
+ * this directory.
4
+ *
5
+ * It lazy-imports exactly three names, so those three are the contract:
6
+ *
7
+ * | Name | Called with | Answers |
8
+ * |---|---|---|
9
+ * | `runCommand(flags)` | commander's flags plus `flags.argv` (the run-specific options, which `src/run/args.js` parses) | an exit code |
10
+ * | `profileCommand(flags)` | the same | an exit code |
11
+ * | `resumeJob({ api, gameId, jobId, job, runs, state, repoRoot, flags })` | cli-core's `resume` hand-off | an exit code |
12
+ *
13
+ * Everything else exported here is for tests and for cli-godot's driver: the
14
+ * driver interface, the model client, and the pieces `resume` shares with
15
+ * `run`. Nothing in cli-core should reach past the three names above.
16
+ */
17
+
18
+ export { runCommand, playJob, playPersonaRun } from '../commands/run.js';
19
+ export { profileCommand } from '../commands/profile.js';
20
+ export { resumeJob } from './resume.js';
21
+
22
+ export { EXIT } from './exit.js';
23
+ export { assertDriver, DriverError, observationHash, screenshotName } from './drivers/driver.js';
24
+ export { createDriver, DRIVERS } from './drivers/index.js';
25
+ export { createModelClient, callStep, callStepWithRetry, ModelUnavailableError, ProxyError, STEPS } from './model.js';
26
+ export { getDeps, setDeps, resetDeps } from './deps.js';
27
+ export { runPersona } from './personaLoop.js';
28
+ export { runGameProfile } from './profile.js';
29
+ export { buildAggregate } from './aggregate.js';
30
+ export { scanForSecrets, assertNoSecrets } from './secretScan.js';
31
+ export { parseRunArgv, optionsFrom } from './args.js';
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Pulling JSON out of a model answer.
3
+ *
4
+ * Two of the four steps this CLI uses have no `write_file` tool in their
5
+ * routing allowlist (`game_profile.synthesis` and `aggregate.synthesis`), so
6
+ * their output arrives as text and may or may not be fenced. Asking for "JSON
7
+ * only" and then failing on a stray sentence would spend a repair turn on
8
+ * punctuation, so the fence and any surrounding prose are tolerated; anything
9
+ * that is still not JSON is a real failure and is reported as one.
10
+ */
11
+
12
+ /**
13
+ * @param {string} text
14
+ * @returns {string} the most likely JSON body
15
+ */
16
+ function unfence(text) {
17
+ const value = String(text || '');
18
+ const fenced = value.match(/```(?:json)?\s*([\s\S]*?)```/);
19
+ return fenced ? fenced[1] : value;
20
+ }
21
+
22
+ /**
23
+ * @param {string} text
24
+ * @returns {Object|null} null when the answer holds no JSON object
25
+ */
26
+ export function parseJsonObject(text) {
27
+ const body = unfence(text);
28
+ const start = body.indexOf('{');
29
+ const end = body.lastIndexOf('}');
30
+ if (start === -1 || end <= start) return null;
31
+ try {
32
+ const parsed = JSON.parse(body.slice(start, end + 1));
33
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * @param {string} text
41
+ * @returns {Array<Object>} empty when the answer holds no JSON array
42
+ */
43
+ export function parseJsonArray(text) {
44
+ const body = unfence(text);
45
+ const start = body.indexOf('[');
46
+ const end = body.lastIndexOf(']');
47
+ if (start === -1 || end <= start) return [];
48
+ try {
49
+ const parsed = JSON.parse(body.slice(start, end + 1));
50
+ return Array.isArray(parsed) ? parsed : [];
51
+ } catch {
52
+ return [];
53
+ }
54
+ }
55
+
56
+ export default { parseJsonObject, parseJsonArray };