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,384 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+
3
+ /**
4
+ * Every model call this CLI makes goes through here, and every one of them
5
+ * goes to Ravensight's proxy rather than to the Anthropic API.
6
+ *
7
+ * What that means concretely, and why:
8
+ *
9
+ * - **No Anthropic key exists on the developer's machine.** The client is
10
+ * built with `authToken` (the `gt_cli_...` token from the device-code
11
+ * flow) and an explicitly null `apiKey`, so a stray `ANTHROPIC_API_KEY`
12
+ * in the developer's environment is never picked up and never sent
13
+ * anywhere.
14
+ * - **No `model` field, ever.** Routing is the server's decision
15
+ * (`src/playtest/packs/routing.json`, server-only). The proxy rebuilds
16
+ * the body from an allowlist and supplies the model itself; a body that
17
+ * names a real routed model id is refused 403 `model_not_allowed`. The
18
+ * CLI cannot know which model a step resolves to, which is the point.
19
+ * - **Client tools only.** The proxy refuses `mcp_servers`, `container`,
20
+ * `betas` and `service_tier` outright, refuses any tool whose type starts
21
+ * `web_search`, `computer`, `bash` or `text_editor`, and refuses any tool
22
+ * whose name is not in the step's own `allowed_client_tools`. So the tool
23
+ * names in ./tools.js are not a style choice, they are the contract.
24
+ * - **Streaming is forced on by the proxy.** Asking for a non-stream
25
+ * response would get a stream anyway, so we ask for one.
26
+ * - **`X-Playtest-Job`, `-Run` and `-Step` are required on every call.**
27
+ * The step has to belong to the run's own module (plus the shared
28
+ * `brief.validate`), or the answer is 400 `step_not_allowed`.
29
+ *
30
+ * The two answers that change what the caller does next:
31
+ *
32
+ * - `X-Playtest-Budget: warn` comes back at or past 80 percent of the run's
33
+ * model budget. It is surfaced to the developer and it tells the persona
34
+ * loop to wrap up and write its report while there is still money to do
35
+ * it with.
36
+ * - `402 run_budget_exceeded` is the end of the run's spending. Partial
37
+ * results are written and uploaded, and the process exits 2.
38
+ */
39
+
40
+ /** Where the proxy lives under the API host. */
41
+ export const MODEL_BASE_PATH = '/api/v1/playtest/model';
42
+
43
+ /** Step names, from routing.json by way of `clientRouting()`. */
44
+ export const STEPS = Object.freeze({
45
+ personaAct: 'persona_playtest.act',
46
+ personaReport: 'persona_playtest.report',
47
+ profileTriage: 'game_profile.triage',
48
+ profileSynthesis: 'game_profile.synthesis',
49
+ aggregateDedup: 'aggregate.dedup',
50
+ aggregateSynthesis: 'aggregate.synthesis',
51
+ briefValidate: 'brief.validate'
52
+ });
53
+
54
+ /** Default retry budget for the answers that are worth retrying. */
55
+ export const MAX_ATTEMPTS = 4;
56
+
57
+ /** Codes the proxy raises that a retry can actually clear. */
58
+ const RETRYABLE_CODES = new Set(['concurrency_limited', 'velocity_limited', 'rate_limited', 'upstream_error']);
59
+
60
+ export class ProxyError extends Error {
61
+ /**
62
+ * @param {{status: number, code: string, message: string, retryAfter?: number|null, figures?: Object}} init
63
+ */
64
+ constructor({ status, code, message, retryAfter = null, figures = {} }) {
65
+ super(message);
66
+ this.name = 'ProxyError';
67
+ this.status = status;
68
+ this.code = code;
69
+ this.retryAfter = retryAfter;
70
+ this.figures = figures;
71
+ }
72
+
73
+ /** The run has no model budget left. Write what we have and exit 2. */
74
+ get isBudget() {
75
+ return this.status === 402;
76
+ }
77
+
78
+ /** The job or run is closed, cancelled or past its wall clock. Exit 10. */
79
+ get isClosed() {
80
+ return this.status === 409;
81
+ }
82
+
83
+ /** Bad or missing credentials. Exit 4. */
84
+ get isAuth() {
85
+ return this.status === 401 || this.status === 403;
86
+ }
87
+
88
+ get isRetryable() {
89
+ if (this.status === 502) return true;
90
+ return this.status === 429 && RETRYABLE_CODES.has(this.code);
91
+ }
92
+ }
93
+
94
+ /** Every retry was spent and the proxy still will not answer. Exit 5. */
95
+ export class ModelUnavailableError extends Error {
96
+ constructor(message, cause) {
97
+ super(message);
98
+ this.name = 'ModelUnavailableError';
99
+ this.cause = cause;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Read a header off whatever the SDK handed back: a `Headers` object on a
105
+ * response, a plain object on some error paths, or nothing at all.
106
+ *
107
+ * @param {any} headers
108
+ * @param {string} name
109
+ * @returns {string|null}
110
+ */
111
+ export function headerValue(headers, name) {
112
+ if (!headers) return null;
113
+ if (typeof headers.get === 'function') return headers.get(name);
114
+ const lower = name.toLowerCase();
115
+ for (const [key, value] of Object.entries(headers)) {
116
+ if (String(key).toLowerCase() === lower) return String(value);
117
+ }
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Normalise the two error envelopes the playtest surface uses.
123
+ *
124
+ * The router's own refusals are the Anthropic envelope the SDK already
125
+ * parses, `{ error: { type, message, ...figures } }`. The two middleware
126
+ * refusals (no `gt_cli_` credential, an ingest key) keep the repo's flat
127
+ * `{ error: 'code', message }` shape, because they come from the shared CLI
128
+ * auth chain. Both have to be understood here or a 401 reads as an
129
+ * unknown failure.
130
+ *
131
+ * @param {any} error an error thrown by the SDK
132
+ * @returns {ProxyError}
133
+ */
134
+ export function toProxyError(error) {
135
+ const status = Number(error && error.status) || 0;
136
+ const body = (error && error.error) || {};
137
+ const inner = body && typeof body.error === 'object' && body.error !== null ? body.error : null;
138
+ const flat = typeof body.error === 'string' ? body.error : null;
139
+ const code = (inner && inner.type) || flat || (error && error.name) || 'model_error';
140
+ const message = (inner && inner.message) || body.message || (error && error.message) || 'The model proxy refused the call.';
141
+ const headerRetry = Number(headerValue(error && error.headers, 'retry-after'));
142
+ const bodyRetry = Number(inner && inner.retry_after);
143
+ const retryAfter = Number.isFinite(bodyRetry) && bodyRetry > 0
144
+ ? bodyRetry
145
+ : (Number.isFinite(headerRetry) && headerRetry > 0 ? headerRetry : null);
146
+ const figures = {};
147
+ if (inner) {
148
+ for (const [key, value] of Object.entries(inner)) {
149
+ if (key !== 'type' && key !== 'message') figures[key] = value;
150
+ }
151
+ }
152
+ return new ProxyError({ status, code, message, retryAfter, figures });
153
+ }
154
+
155
+ /**
156
+ * @param {Object} opts
157
+ * @param {string} opts.apiUrl e.g. https://api.ravensight.io
158
+ * @param {string} opts.token the gt_cli_ token
159
+ * @param {number} [opts.timeoutMs]
160
+ * @param {Function} [opts.fetch] test seam
161
+ * @returns {Anthropic}
162
+ */
163
+ export function createModelClient({ apiUrl, token, timeoutMs = 300000, fetch: fetchImpl } = {}) {
164
+ if (!apiUrl) throw new Error('createModelClient needs an apiUrl');
165
+ if (!token) throw new Error('createModelClient needs a gt_cli_ token');
166
+ return new Anthropic({
167
+ baseURL: `${String(apiUrl).replace(/\/+$/, '')}${MODEL_BASE_PATH}`,
168
+ authToken: token,
169
+ // Explicitly null so the SDK never falls back to a local
170
+ // ANTHROPIC_API_KEY. This CLI has no business holding one.
171
+ apiKey: null,
172
+ // Retries are ours: the proxy's 429s carry a code and a retry_after
173
+ // that decide whether waiting will help at all, and the SDK cannot see
174
+ // that difference.
175
+ maxRetries: 0,
176
+ timeout: timeoutMs,
177
+ ...(fetchImpl ? { fetch: fetchImpl } : {})
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Merge one usage payload into an accumulator. `message_delta.usage` is
183
+ * cumulative rather than incremental, so the last value of each field wins
184
+ * instead of being added (the proxy does the same thing on its side, and
185
+ * the two figures have to agree).
186
+ *
187
+ * @param {Object} accumulator
188
+ * @param {Object|null|undefined} usage
189
+ */
190
+ export function mergeUsage(accumulator, usage) {
191
+ if (!usage || typeof usage !== 'object') return accumulator;
192
+ for (const field of ['input_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens', 'output_tokens']) {
193
+ const value = Number(usage[field]);
194
+ if (Number.isFinite(value)) accumulator[field] = value;
195
+ }
196
+ return accumulator;
197
+ }
198
+
199
+ /**
200
+ * One model turn.
201
+ *
202
+ * @param {Object} args
203
+ * @param {Anthropic} args.client
204
+ * @param {string} args.jobId
205
+ * @param {string} args.runId
206
+ * @param {string} args.step a routing.json step name
207
+ * @param {Array} args.messages
208
+ * @param {string|Array} [args.system]
209
+ * @param {Array} [args.tools]
210
+ * @param {number} args.maxTokens asked for, clamped by the step's own ceiling
211
+ * @param {boolean} [args.escalate] honoured only on an escalatable step
212
+ * @param {AbortSignal} [args.signal]
213
+ * @returns {Promise<{text: string, toolUses: Array, stopReason: string|null, usage: Object, model: string|null, tier: string|null, budget: string|null}>}
214
+ */
215
+ export async function callStep({
216
+ client,
217
+ jobId,
218
+ runId,
219
+ step,
220
+ messages,
221
+ system,
222
+ tools,
223
+ maxTokens,
224
+ escalate = false,
225
+ signal
226
+ }) {
227
+ const headers = {
228
+ 'X-Playtest-Job': jobId,
229
+ 'X-Playtest-Run': runId,
230
+ 'X-Playtest-Step': step
231
+ };
232
+ if (escalate) headers['X-Playtest-Escalate'] = '1';
233
+
234
+ const body = { messages, max_tokens: maxTokens, stream: true };
235
+ if (system !== undefined) body.system = system;
236
+ if (Array.isArray(tools) && tools.length > 0) body.tools = tools;
237
+
238
+ let stream;
239
+ let response;
240
+ try {
241
+ const answered = await client.messages.create(body, { headers, signal }).withResponse();
242
+ stream = answered.data;
243
+ response = answered.response;
244
+ } catch (error) {
245
+ if (error && (error.name === 'AbortError' || error.name === 'APIUserAbortError')) throw error;
246
+ throw toProxyError(error);
247
+ }
248
+
249
+ const tier = headerValue(response && response.headers, 'x-playtest-model-tier');
250
+ const budget = headerValue(response && response.headers, 'x-playtest-budget');
251
+
252
+ const usage = { input_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 0 };
253
+ const blocks = new Map();
254
+ let text = '';
255
+ let stopReason = null;
256
+ let model = null;
257
+
258
+ try {
259
+ await consume(stream);
260
+ } catch (error) {
261
+ if (error && (error.name === 'AbortError' || error.name === 'APIUserAbortError')) throw error;
262
+ // The SDK's own iterator raises an `event: error` frame as an APIError
263
+ // with no status: the status line was sent long ago. Everything from the
264
+ // first byte onwards is therefore a 502 by construction, which is also
265
+ // how the proxy classifies its own mid-stream failures.
266
+ const proxied = toProxyError(error);
267
+ throw proxied.status ? proxied : new ProxyError({
268
+ status: 502,
269
+ code: proxied.code === 'model_error' ? 'upstream_error' : proxied.code,
270
+ message: proxied.message
271
+ });
272
+ }
273
+
274
+ async function consume(events) {
275
+ for await (const event of events) {
276
+ switch (event.type) {
277
+ case 'message_start':
278
+ // The proxy re-emits the upstream events verbatim, so this is where
279
+ // the resolved model id becomes visible even though the tier header
280
+ // deliberately does not carry it. It is recorded in usage.json for
281
+ // reconciliation against the server's own figures, never used to
282
+ // pick a model.
283
+ model = (event.message && event.message.model) || model;
284
+ mergeUsage(usage, event.message && event.message.usage);
285
+ break;
286
+ case 'content_block_start':
287
+ blocks.set(event.index, { block: event.content_block, json: '' });
288
+ break;
289
+ case 'content_block_delta': {
290
+ const entry = blocks.get(event.index);
291
+ if (!entry) break;
292
+ if (event.delta && event.delta.type === 'text_delta') {
293
+ text += event.delta.text;
294
+ } else if (event.delta && event.delta.type === 'input_json_delta') {
295
+ entry.json += event.delta.partial_json || '';
296
+ }
297
+ break;
298
+ }
299
+ case 'message_delta':
300
+ if (event.delta && event.delta.stop_reason) stopReason = event.delta.stop_reason;
301
+ mergeUsage(usage, event.usage);
302
+ break;
303
+ default:
304
+ // Anything else, `ping` included, is not a frame this loop has an
305
+ // opinion about. The SDK drops `ping` before it reaches here and
306
+ // raises an `error` frame as a thrown APIError, which the caller
307
+ // above converts.
308
+ break;
309
+ }
310
+ }
311
+ }
312
+
313
+ const toolUses = [];
314
+ for (const { block, json } of blocks.values()) {
315
+ if (!block || block.type !== 'tool_use') continue;
316
+ let input = block.input && Object.keys(block.input).length > 0 ? block.input : {};
317
+ if (json) {
318
+ try {
319
+ input = JSON.parse(json);
320
+ } catch {
321
+ // A truncated tool call (usually `max_tokens` cutting the JSON in
322
+ // half) is reported as a malformed call so the caller can tell the
323
+ // model rather than crash on it.
324
+ toolUses.push({ id: block.id, name: block.name, input: null, malformed: true, raw: json });
325
+ continue;
326
+ }
327
+ }
328
+ toolUses.push({ id: block.id, name: block.name, input, malformed: false });
329
+ }
330
+
331
+ return { text, toolUses, stopReason, usage, model, tier, budget };
332
+ }
333
+
334
+ /**
335
+ * @param {number} ms
336
+ * @param {Function} [sleep]
337
+ */
338
+ function wait(ms, sleep) {
339
+ if (sleep) return sleep(ms);
340
+ return new Promise(resolve => setTimeout(resolve, ms));
341
+ }
342
+
343
+ /**
344
+ * `callStep` with the retries that are worth taking, and none of the ones
345
+ * that are not.
346
+ *
347
+ * Retried: 502 `upstream_error`, and a 429 whose code says the wait is the
348
+ * fix (`concurrency_limited`, `velocity_limited`, `rate_limited`). The
349
+ * proxy's own `retry_after` is honoured when it sends one, since it knows
350
+ * how long its hourly window or its upstream asked for.
351
+ *
352
+ * Never retried: 402 (no money left, waiting changes nothing), 409 (the job
353
+ * is closed), 401 and 403 (credentials or scope), 400 and 404 (a contract
354
+ * problem in this CLI, and hammering it just spends the developer's
355
+ * patience). 429 `count_tokens_limited` is not retried either: the job's
356
+ * free-count ceiling does not refill inside one job.
357
+ *
358
+ * @param {Object} args see callStep, plus:
359
+ * @param {number} [args.maxAttempts]
360
+ * @param {(info: Object) => void} [args.onRetry]
361
+ * @param {Function} [args.sleep] test seam
362
+ */
363
+ export async function callStepWithRetry(args) {
364
+ const { maxAttempts = MAX_ATTEMPTS, onRetry, sleep, ...rest } = args;
365
+ let last = null;
366
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
367
+ try {
368
+ return await callStep(rest);
369
+ } catch (error) {
370
+ if (error instanceof ProxyError && !error.isRetryable) throw error;
371
+ if (error && (error.name === 'AbortError' || error.name === 'APIUserAbortError')) throw error;
372
+ last = error;
373
+ if (attempt === maxAttempts) break;
374
+ const suggested = error instanceof ProxyError && error.retryAfter ? error.retryAfter * 1000 : null;
375
+ const backoff = suggested ?? Math.min(1000 * 2 ** (attempt - 1), 30000);
376
+ if (onRetry) onRetry({ attempt, maxAttempts, waitMs: backoff, error });
377
+ await wait(backoff, sleep);
378
+ }
379
+ }
380
+ const reason = last instanceof ProxyError ? `${last.status} ${last.code}: ${last.message}` : String(last && last.message);
381
+ throw new ModelUnavailableError(`The model proxy did not answer after ${maxAttempts} attempts (${reason}).`, last);
382
+ }
383
+
384
+ export default { createModelClient, callStep, callStepWithRetry, STEPS, ProxyError, ModelUnavailableError };
@@ -0,0 +1,88 @@
1
+ import path from 'node:path';
2
+
3
+ /**
4
+ * The local layout, spec 17 section 1.5:
5
+ *
6
+ * <repo>/.ravensight/
7
+ * config.json (cli-core: game id, driver defaults)
8
+ * jobs/<job_id>/state.json the resumable journal (./state.js)
9
+ * jobs/<job_id>/capability-report.json
10
+ * jobs/<job_id>/aggregate-report.md
11
+ * jobs/<job_id>/aggregate-report.json
12
+ * jobs/<job_id>/runs/<run_id>/report.md
13
+ * jobs/<job_id>/runs/<run_id>/report.json
14
+ * jobs/<job_id>/runs/<run_id>/usage.json
15
+ * jobs/<job_id>/runs/<run_id>/transcript.jsonl
16
+ * jobs/<job_id>/runs/<run_id>/screenshots/NN-slug.png
17
+ * jobs/<job_id>/runs/<run_id>/session.webm
18
+ *
19
+ * The basenames are load-bearing: the upload presign keys are derived from
20
+ * the path relative to the run directory, and the server's own allowlist
21
+ * and lifecycle rules (the S3 lifecycle expires every `session.webm` and
22
+ * `transcript.jsonl` at 90 days) are written against exactly these names.
23
+ */
24
+
25
+ export const RAVENSIGHT_DIR = '.ravensight';
26
+
27
+ /** @param {string} repoRoot */
28
+ export function ravensightDir(repoRoot) {
29
+ return path.join(repoRoot, RAVENSIGHT_DIR);
30
+ }
31
+
32
+ /** @param {string} repoRoot */
33
+ export function jobsDir(repoRoot) {
34
+ return path.join(ravensightDir(repoRoot), 'jobs');
35
+ }
36
+
37
+ /**
38
+ * @param {string} repoRoot
39
+ * @param {string} jobId
40
+ */
41
+ export function jobDir(repoRoot, jobId) {
42
+ return path.join(jobsDir(repoRoot), jobId);
43
+ }
44
+
45
+ /**
46
+ * @param {string} repoRoot
47
+ * @param {string} jobId
48
+ * @param {string} runId
49
+ */
50
+ export function runDir(repoRoot, jobId, runId) {
51
+ return path.join(jobDir(repoRoot, jobId), 'runs', runId);
52
+ }
53
+
54
+ /** @param {string} dir a run directory */
55
+ export function screenshotsDir(dir) {
56
+ return path.join(dir, 'screenshots');
57
+ }
58
+
59
+ /**
60
+ * The file names inside a run directory, in upload order: reports first,
61
+ * then screenshots, then the opt-in transcript and video (spec 17 section
62
+ * 1.7).
63
+ */
64
+ export const RUN_FILES = Object.freeze({
65
+ reportMd: 'report.md',
66
+ reportJson: 'report.json',
67
+ usageJson: 'usage.json',
68
+ transcript: 'transcript.jsonl',
69
+ video: 'session.webm'
70
+ });
71
+
72
+ export const JOB_FILES = Object.freeze({
73
+ state: 'state.json',
74
+ capabilityReport: 'capability-report.json',
75
+ aggregateMd: 'aggregate-report.md',
76
+ aggregateJson: 'aggregate-report.json'
77
+ });
78
+
79
+ export default {
80
+ RAVENSIGHT_DIR,
81
+ RUN_FILES,
82
+ JOB_FILES,
83
+ ravensightDir,
84
+ jobsDir,
85
+ jobDir,
86
+ runDir,
87
+ screenshotsDir
88
+ };