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.
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/addons/ravensight_driver/driver.gd +836 -0
- package/addons/ravensight_driver/export_plugin.gd +51 -0
- package/addons/ravensight_driver/plugin.cfg +7 -0
- package/addons/ravensight_driver/plugin.gd +36 -0
- package/bin/ravensight-playtest.js +31 -0
- package/package.json +45 -0
- package/src/api/README.md +500 -0
- package/src/api/client.js +340 -0
- package/src/api/errors.js +115 -0
- package/src/api/http.js +194 -0
- package/src/api/index.js +107 -0
- package/src/auth/deviceCode.js +79 -0
- package/src/auth/keychain.js +159 -0
- package/src/auth/session.js +128 -0
- package/src/cli.js +335 -0
- package/src/commands/brief.js +303 -0
- package/src/commands/check.js +318 -0
- package/src/commands/fakeCore.js +379 -0
- package/src/commands/init.js +120 -0
- package/src/commands/login.js +90 -0
- package/src/commands/logout.js +70 -0
- package/src/commands/open.js +125 -0
- package/src/commands/profile.js +262 -0
- package/src/commands/resume.js +156 -0
- package/src/commands/run.js +1015 -0
- package/src/commands/upload.js +137 -0
- package/src/config.js +100 -0
- package/src/dashboard.js +97 -0
- package/src/detect.js +77 -0
- package/src/errors.js +44 -0
- package/src/fsutil.js +77 -0
- package/src/godot.js +85 -0
- package/src/packs/index.js +191 -0
- package/src/paths.js +129 -0
- package/src/run/aggregate.js +658 -0
- package/src/run/args.js +111 -0
- package/src/run/context.js +181 -0
- package/src/run/deps.js +184 -0
- package/src/run/drivers/driver.js +183 -0
- package/src/run/drivers/godot-observation.js +138 -0
- package/src/run/drivers/godot-project.js +475 -0
- package/src/run/drivers/godot-rpc.js +225 -0
- package/src/run/drivers/godot.js +587 -0
- package/src/run/drivers/index.js +52 -0
- package/src/run/drivers/web.js +385 -0
- package/src/run/exit.js +21 -0
- package/src/run/heartbeat.js +131 -0
- package/src/run/index.js +31 -0
- package/src/run/json.js +56 -0
- package/src/run/model.js +384 -0
- package/src/run/paths.js +88 -0
- package/src/run/personaLoop.js +871 -0
- package/src/run/profile.js +214 -0
- package/src/run/regenerate.js +149 -0
- package/src/run/repoTools.js +286 -0
- package/src/run/report.js +222 -0
- package/src/run/resume.js +272 -0
- package/src/run/secretScan.js +171 -0
- package/src/run/state.js +198 -0
- package/src/run/synthetic.js +206 -0
- package/src/run/tools.js +344 -0
- package/src/run/transcript.js +93 -0
- package/src/run/usage.js +115 -0
- package/src/state/index.js +105 -0
- package/src/states.js +104 -0
- package/src/ui/index.js +195 -0
- package/src/upload/allowlist.js +116 -0
- package/src/upload/index.js +467 -0
- package/src/upload/queue.js +114 -0
- package/src/version.js +63 -0
package/src/run/tools.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { OBSERVATION_MAX_CHARS, truncateObservation } from './drivers/driver.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The client tool set for `persona_playtest.act`.
|
|
5
|
+
*
|
|
6
|
+
* **The names are not ours to choose.** `routing.json`'s
|
|
7
|
+
* `persona_playtest.act` lists `allowed_client_tools: ["snapshot", "click",
|
|
8
|
+
* "type", "key", "navigate", "wait", "screenshot"]`, and the proxy answers
|
|
9
|
+
* 400 `tool_not_allowed` for anything else. Two consequences worth knowing
|
|
10
|
+
* before editing this file:
|
|
11
|
+
*
|
|
12
|
+
* - There is no `note` tool and no `observe`/`act` pair, so the log note
|
|
13
|
+
* rides along as an optional field on whichever tool the persona is
|
|
14
|
+
* already calling. Same information, same transcript line, no tool the
|
|
15
|
+
* server would refuse.
|
|
16
|
+
* - There is no `quit` tool either. A persona that wants to stop simply
|
|
17
|
+
* stops calling tools; the loop reads a turn with no tool call as
|
|
18
|
+
* `persona_quit` and keeps the model's own words as the quit detail.
|
|
19
|
+
*
|
|
20
|
+
* Spec 03's accounting is kept: one action is one input to the game, so
|
|
21
|
+
* `click`, `type`, `key` and `navigate` count against `max_actions` while
|
|
22
|
+
* `snapshot`, `screenshot` and `wait` do not.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Note kinds, from spec 03's `log_note`. */
|
|
26
|
+
export const NOTE_KINDS = Object.freeze(['bug', 'confusing', 'good', 'suggestion', 'reaction']);
|
|
27
|
+
|
|
28
|
+
/** Severities, from the report schema's finding severity enum. */
|
|
29
|
+
export const SEVERITIES = Object.freeze(['blocker', 'major', 'minor', 'cosmetic', 'praise']);
|
|
30
|
+
|
|
31
|
+
/** Tools that spend an action. */
|
|
32
|
+
export const ACTION_TOOLS = Object.freeze(['click', 'type', 'key', 'navigate']);
|
|
33
|
+
|
|
34
|
+
const thought = {
|
|
35
|
+
type: 'string',
|
|
36
|
+
description: 'One to three sentences in the persona voice, first person. Never break character here.'
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const note = {
|
|
40
|
+
type: 'object',
|
|
41
|
+
description: 'Optional: something worth recording at this step. Engineer voice, not persona voice. Use it for anything surprising, frustrating, delightful, broken or confusing.',
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
required: ['kind', 'text'],
|
|
44
|
+
properties: {
|
|
45
|
+
kind: { type: 'string', enum: [...NOTE_KINDS] },
|
|
46
|
+
text: { type: 'string' },
|
|
47
|
+
severity: { type: 'string', enum: [...SEVERITIES] }
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The tool definitions sent with every `persona_playtest.act` call. */
|
|
52
|
+
export const PLAY_TOOLS = Object.freeze([
|
|
53
|
+
{
|
|
54
|
+
name: 'snapshot',
|
|
55
|
+
description: 'Look at the current screen. Does not spend an action. Use it after anything that might have changed the game state.',
|
|
56
|
+
input_schema: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
additionalProperties: false,
|
|
59
|
+
properties: { thought_in_character: thought, note }
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'click',
|
|
64
|
+
description: 'Click one thing. Spends an action. Either name the target or give x and y pixel coordinates. What a named target means depends on the driver: on playwright_web it is an accessibility node such as button "Start", a visible text fragment, or a CSS selector; on godot_driver it is a node path from the scene tree you were shown.',
|
|
65
|
+
input_schema: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
additionalProperties: false,
|
|
68
|
+
properties: {
|
|
69
|
+
target: { type: 'string', description: 'web: button "Start", Start Game, or #start-button. Godot: a node path such as /root/Main/UI/StartButton.' },
|
|
70
|
+
x: { type: 'number' },
|
|
71
|
+
y: { type: 'number' },
|
|
72
|
+
thought_in_character: thought,
|
|
73
|
+
note
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'type',
|
|
79
|
+
description: 'Type text. Spends an action. Set submit to press Enter afterwards.',
|
|
80
|
+
input_schema: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
required: ['text'],
|
|
84
|
+
properties: {
|
|
85
|
+
text: { type: 'string' },
|
|
86
|
+
submit: { type: 'boolean' },
|
|
87
|
+
thought_in_character: thought,
|
|
88
|
+
note
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'key',
|
|
94
|
+
description: 'Press one key. Spends an action. Names are the usual ones: Enter, Space, ArrowUp, Escape, a, 1.',
|
|
95
|
+
input_schema: {
|
|
96
|
+
type: 'object',
|
|
97
|
+
additionalProperties: false,
|
|
98
|
+
required: ['key'],
|
|
99
|
+
properties: {
|
|
100
|
+
key: { type: 'string' },
|
|
101
|
+
thought_in_character: thought,
|
|
102
|
+
note
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'navigate',
|
|
108
|
+
description: 'Go to a URL inside this build. Spends an action. Anything outside the build origin is refused, so do not guess URLs.',
|
|
109
|
+
input_schema: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
additionalProperties: false,
|
|
112
|
+
required: ['url'],
|
|
113
|
+
properties: {
|
|
114
|
+
url: { type: 'string' },
|
|
115
|
+
thought_in_character: thought,
|
|
116
|
+
note
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: 'wait',
|
|
122
|
+
description: 'Wait for the game to catch up. Does not spend an action. Capped, so ask for what you need rather than a long sleep.',
|
|
123
|
+
input_schema: {
|
|
124
|
+
type: 'object',
|
|
125
|
+
additionalProperties: false,
|
|
126
|
+
required: ['ms'],
|
|
127
|
+
properties: {
|
|
128
|
+
ms: { type: 'integer', minimum: 0 },
|
|
129
|
+
thought_in_character: thought,
|
|
130
|
+
note
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'screenshot',
|
|
136
|
+
description: 'Save a screenshot, for evidence. Does not spend an action. Only at key moments: a screen or mode seen for the first time, a bug, a strong reaction, a crash, the final state. Never on a fixed interval and never the same state twice.',
|
|
137
|
+
input_schema: {
|
|
138
|
+
type: 'object',
|
|
139
|
+
additionalProperties: false,
|
|
140
|
+
required: ['name', 'reason'],
|
|
141
|
+
properties: {
|
|
142
|
+
name: { type: 'string', description: 'a short slug, such as title-screen or level-2-softlock' },
|
|
143
|
+
reason: { type: 'string' },
|
|
144
|
+
thought_in_character: thought,
|
|
145
|
+
note
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
/** The single tool for `persona_playtest.report`. */
|
|
152
|
+
export const REPORT_TOOLS = Object.freeze([
|
|
153
|
+
{
|
|
154
|
+
name: 'write_file',
|
|
155
|
+
description: 'Write one of the two report files. path must be exactly report.md or report.json.',
|
|
156
|
+
input_schema: {
|
|
157
|
+
type: 'object',
|
|
158
|
+
additionalProperties: false,
|
|
159
|
+
required: ['path', 'content'],
|
|
160
|
+
properties: {
|
|
161
|
+
path: { type: 'string', enum: ['report.md', 'report.json'] },
|
|
162
|
+
content: { type: 'string' }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {Object} toolUse a tool_use block from ./model.js
|
|
170
|
+
* @returns {{kind: string}|null} the driver action, or null for the tools
|
|
171
|
+
* that do not touch the game
|
|
172
|
+
*/
|
|
173
|
+
export function actionFor(toolUse) {
|
|
174
|
+
const input = toolUse.input || {};
|
|
175
|
+
switch (toolUse.name) {
|
|
176
|
+
case 'click':
|
|
177
|
+
return Number.isFinite(Number(input.x)) && Number.isFinite(Number(input.y))
|
|
178
|
+
? { kind: 'click', target: { x: Number(input.x), y: Number(input.y) } }
|
|
179
|
+
: { kind: 'click', target: String(input.target || '') };
|
|
180
|
+
case 'type':
|
|
181
|
+
return { kind: 'type', text: String(input.text || ''), submit: Boolean(input.submit) };
|
|
182
|
+
case 'key':
|
|
183
|
+
return { kind: 'key', key: String(input.key || '') };
|
|
184
|
+
case 'navigate':
|
|
185
|
+
return { kind: 'navigate', url: String(input.url || '') };
|
|
186
|
+
case 'wait':
|
|
187
|
+
return { kind: 'wait', ms: Number(input.ms) || 0 };
|
|
188
|
+
default:
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Execute one tool call against the driver.
|
|
195
|
+
*
|
|
196
|
+
* The result is a `tool_result` content block plus the bookkeeping the loop
|
|
197
|
+
* needs: whether an action was spent, whether the screen moved, and the
|
|
198
|
+
* observation to keep in context.
|
|
199
|
+
*
|
|
200
|
+
* @param {Object} args
|
|
201
|
+
* @param {Object} args.driver
|
|
202
|
+
* @param {Object} args.toolUse
|
|
203
|
+
* @param {(note: Object) => void} [args.onNote]
|
|
204
|
+
* @param {(shot: Object) => void} [args.onScreenshot]
|
|
205
|
+
* @returns {Promise<{result: Object, observation: Object|null, spentAction: boolean, changed: boolean, error: string|null}>}
|
|
206
|
+
*/
|
|
207
|
+
export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
|
|
208
|
+
const input = toolUse.input || {};
|
|
209
|
+
if (toolUse.malformed) {
|
|
210
|
+
return {
|
|
211
|
+
result: toolResult(toolUse.id, 'That tool call did not parse as JSON. Send it again, smaller.', true),
|
|
212
|
+
observation: null,
|
|
213
|
+
spentAction: false,
|
|
214
|
+
changed: false,
|
|
215
|
+
error: 'malformed_tool_input'
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (input.note && onNote) onNote({ ...input.note, tool: toolUse.name });
|
|
220
|
+
|
|
221
|
+
if (toolUse.name === 'screenshot') {
|
|
222
|
+
try {
|
|
223
|
+
const shot = await driver.screenshot(String(input.name || 'shot'));
|
|
224
|
+
if (onScreenshot) onScreenshot({ ...shot, reason: String(input.reason || '') });
|
|
225
|
+
return {
|
|
226
|
+
result: toolResult(toolUse.id, `Saved ${shot.relativePath}.`),
|
|
227
|
+
observation: null,
|
|
228
|
+
spentAction: false,
|
|
229
|
+
changed: false,
|
|
230
|
+
error: null
|
|
231
|
+
};
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return {
|
|
234
|
+
result: toolResult(toolUse.id, `The screenshot failed: ${message(error)}`, true),
|
|
235
|
+
observation: null,
|
|
236
|
+
spentAction: false,
|
|
237
|
+
changed: false,
|
|
238
|
+
error: message(error)
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (toolUse.name === 'snapshot') {
|
|
244
|
+
const observation = await driver.observe();
|
|
245
|
+
return {
|
|
246
|
+
result: observationResult(toolUse.id, observation),
|
|
247
|
+
observation,
|
|
248
|
+
spentAction: false,
|
|
249
|
+
changed: false,
|
|
250
|
+
error: null
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const action = actionFor(toolUse);
|
|
255
|
+
if (!action) {
|
|
256
|
+
return {
|
|
257
|
+
result: toolResult(toolUse.id, `There is no tool called ${toolUse.name}.`, true),
|
|
258
|
+
observation: null,
|
|
259
|
+
spentAction: false,
|
|
260
|
+
changed: false,
|
|
261
|
+
error: 'unknown_tool'
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const outcome = await driver.act(action);
|
|
266
|
+
const observation = await driver.observe();
|
|
267
|
+
const spentAction = ACTION_TOOLS.includes(toolUse.name) && outcome.ok;
|
|
268
|
+
const prefix = outcome.ok
|
|
269
|
+
? (outcome.changed ? '' : 'That did not change the screen.\n')
|
|
270
|
+
: `That did not work: ${outcome.error}\n`;
|
|
271
|
+
return {
|
|
272
|
+
result: observationResult(toolUse.id, observation, prefix, !outcome.ok),
|
|
273
|
+
observation,
|
|
274
|
+
spentAction,
|
|
275
|
+
changed: Boolean(outcome.changed),
|
|
276
|
+
error: outcome.ok ? null : String(outcome.error || 'act failed')
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* A tool_result whose content is the new observation, with the screenshot
|
|
282
|
+
* attached as an image when the driver decided the text was too thin to
|
|
283
|
+
* play from. Images are pruned from history by the caller: the act step
|
|
284
|
+
* accepts a very small number of image blocks per request.
|
|
285
|
+
*
|
|
286
|
+
* @param {string} id
|
|
287
|
+
* @param {Object} observation
|
|
288
|
+
* @param {string} [prefix]
|
|
289
|
+
* @param {boolean} [isError]
|
|
290
|
+
*/
|
|
291
|
+
export function observationResult(id, observation, prefix = '', isError = false) {
|
|
292
|
+
const content = [
|
|
293
|
+
{
|
|
294
|
+
type: 'text',
|
|
295
|
+
text: `${prefix}step ${observation.step}${observation.url ? ` (${observation.url})` : ''}${citationLine(observation)}\n${truncateObservation(observation.text, OBSERVATION_MAX_CHARS)}`
|
|
296
|
+
}
|
|
297
|
+
];
|
|
298
|
+
if (observation.screenshotBase64) {
|
|
299
|
+
content.push({
|
|
300
|
+
type: 'image',
|
|
301
|
+
source: {
|
|
302
|
+
type: 'base64',
|
|
303
|
+
media_type: observation.screenshotMediaType || 'image/png',
|
|
304
|
+
data: observation.screenshotBase64
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return { type: 'tool_result', tool_use_id: id, content, ...(isError ? { is_error: true } : {}) };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {string} id
|
|
313
|
+
* @param {string} text
|
|
314
|
+
* @param {boolean} [isError]
|
|
315
|
+
*/
|
|
316
|
+
export function toolResult(id, text, isError = false) {
|
|
317
|
+
return { type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text }], ...(isError ? { is_error: true } : {}) };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* What the persona can cite as `file_line` evidence.
|
|
322
|
+
*
|
|
323
|
+
* The Godot driver reports the current scene and the scenes and scripts
|
|
324
|
+
* behind it, which is the only way a finding from a running game can name a
|
|
325
|
+
* `res://` path. The dogfood gate asks for exactly that ("at least one
|
|
326
|
+
* finding cites a res:// scene or script path"), so the paths have to reach
|
|
327
|
+
* the model, not just the transcript.
|
|
328
|
+
*
|
|
329
|
+
* @param {Object} observation
|
|
330
|
+
* @returns {string}
|
|
331
|
+
*/
|
|
332
|
+
export function citationLine(observation) {
|
|
333
|
+
const parts = [];
|
|
334
|
+
if (observation.scene) parts.push(`scene ${observation.scene}`);
|
|
335
|
+
if (Array.isArray(observation.scenes) && observation.scenes.length > 0) parts.push(`scenes ${observation.scenes.slice(0, 8).join(', ')}`);
|
|
336
|
+
if (Array.isArray(observation.scripts) && observation.scripts.length > 0) parts.push(`scripts ${observation.scripts.slice(0, 8).join(', ')}`);
|
|
337
|
+
return parts.length > 0 ? `\n[${parts.join(' | ')}] (cite these as file_line evidence)` : '';
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function message(error) {
|
|
341
|
+
return String(error && error.message ? error.message : error);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export default { PLAY_TOOLS, REPORT_TOOLS, executeTool, actionFor, toolResult, observationResult };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createWriteStream } from 'node:fs';
|
|
2
|
+
import { mkdir } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { RUN_FILES } from './paths.js';
|
|
5
|
+
import { scanForSecrets } from './secretScan.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `transcript.jsonl`, one JSON object per line.
|
|
9
|
+
*
|
|
10
|
+
* Two things depend on the exact contents:
|
|
11
|
+
*
|
|
12
|
+
* 1. `regenerate` rebuilds `report.md` and `report.json` from the transcript
|
|
13
|
+
* plus the screenshots, with the game not running (spec 03). So every
|
|
14
|
+
* input the report step reads has to be in here: the header line, each
|
|
15
|
+
* observation, each action, each note, each screenshot name.
|
|
16
|
+
* 2. Evidence refs are written as `step N`, where N is the step field on
|
|
17
|
+
* these lines. A transcript that renumbers on resume would invalidate
|
|
18
|
+
* every evidence ref in the report, so a resumed run starts a new
|
|
19
|
+
* transcript in a new run directory rather than appending.
|
|
20
|
+
*
|
|
21
|
+
* The file is opt-in for upload (spec 17 section 1.7) and the server expires
|
|
22
|
+
* it at 90 days, so it is written in full locally either way.
|
|
23
|
+
*
|
|
24
|
+
* Every line is scanned before it is written, because the transcript is the
|
|
25
|
+
* one artifact whose content the CLI does not author: it carries whatever the
|
|
26
|
+
* game printed and whatever the model quoted back. A leaked key would
|
|
27
|
+
* otherwise sit in a file the developer may then opt into uploading, and the
|
|
28
|
+
* server's own scan only ever sees the report. A hit is refused and replaced
|
|
29
|
+
* by a line naming the shapes, never the value, and the run continues: losing
|
|
30
|
+
* one transcript line is better than losing the playthrough, and the
|
|
31
|
+
* `secret_withheld` line is what tells anyone reading it that something was
|
|
32
|
+
* dropped.
|
|
33
|
+
*/
|
|
34
|
+
export function createTranscript(runDirectory) {
|
|
35
|
+
const target = path.join(runDirectory, RUN_FILES.transcript);
|
|
36
|
+
/** @type {import('node:fs').WriteStream|null} */
|
|
37
|
+
let stream = null;
|
|
38
|
+
let lines = 0;
|
|
39
|
+
let withheld = 0;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
path: target,
|
|
43
|
+
|
|
44
|
+
async open() {
|
|
45
|
+
await mkdir(runDirectory, { recursive: true });
|
|
46
|
+
stream = createWriteStream(target, { flags: 'a' });
|
|
47
|
+
return this;
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {Object} line
|
|
52
|
+
*/
|
|
53
|
+
write(line) {
|
|
54
|
+
if (!stream) throw new Error('transcript is not open');
|
|
55
|
+
const body = JSON.stringify({ t: Date.now(), ...line });
|
|
56
|
+
const secrets = scanForSecrets(body);
|
|
57
|
+
lines += 1;
|
|
58
|
+
if (secrets.length > 0) {
|
|
59
|
+
withheld += 1;
|
|
60
|
+
// The shapes, never the value: this file is the last place a found
|
|
61
|
+
// secret should be written out.
|
|
62
|
+
stream.write(`${JSON.stringify({
|
|
63
|
+
t: Date.now(),
|
|
64
|
+
kind: 'secret_withheld',
|
|
65
|
+
of: line.kind || 'unknown',
|
|
66
|
+
shapes: secrets.map(secret => `${secret.type} (${secret.count})`)
|
|
67
|
+
})}\n`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
stream.write(`${body}\n`);
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
get lineCount() {
|
|
74
|
+
return lines;
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/** How many lines were refused for looking secret shaped. */
|
|
78
|
+
get withheldCount() {
|
|
79
|
+
return withheld;
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async close() {
|
|
83
|
+
if (!stream) return;
|
|
84
|
+
const closing = stream;
|
|
85
|
+
stream = null;
|
|
86
|
+
await new Promise((resolve, reject) => {
|
|
87
|
+
closing.end(error => (error ? reject(error) : resolve()));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default createTranscript;
|
package/src/run/usage.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { RUN_FILES } from './paths.js';
|
|
4
|
+
import { assertNoSecrets } from './secretScan.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Token accounting for one run, and `usage.json`.
|
|
8
|
+
*
|
|
9
|
+
* Tokens are counted locally, off the stream: `message_start` and
|
|
10
|
+
* `message_delta` carry them, and the proxy re-emits both verbatim.
|
|
11
|
+
*
|
|
12
|
+
* **Dollars are not counted locally, and cannot be.** Per-token prices live
|
|
13
|
+
* in `routing.json`, which is server-only by design (CLAUDE.md: no model id
|
|
14
|
+
* or price anywhere in the API's `src/**`, and the client subset
|
|
15
|
+
* `clientRouting()` deliberately carries neither). So `total_usd` is taken
|
|
16
|
+
* from the server, which prices every call as it makes it: the run's
|
|
17
|
+
* `usage.total_usd` comes back on every heartbeat answer
|
|
18
|
+
* (`PATCH /jobs/:jobId/runs/:runId` -> `{ run: { usage: { total_usd, calls,
|
|
19
|
+
* wall_seconds } } }`). A locally invented figure would be a second,
|
|
20
|
+
* disagreeing number for the one thing the customer is paying, which is
|
|
21
|
+
* worse than no number.
|
|
22
|
+
*
|
|
23
|
+
* `by_model[].usd` therefore stays 0: the server reports the run total, not
|
|
24
|
+
* a per-model split, and splitting it here would be arithmetic we made up.
|
|
25
|
+
* The model ids themselves are real, read off `message_start.message.model`.
|
|
26
|
+
*/
|
|
27
|
+
export function createUsageLedger() {
|
|
28
|
+
/** @type {Map<string, {model: string, input_tokens: number, cache_read_tokens: number, output_tokens: number, usd: number}>} */
|
|
29
|
+
const byModel = new Map();
|
|
30
|
+
const started = Date.now();
|
|
31
|
+
let calls = 0;
|
|
32
|
+
let serverTotalUsd = null;
|
|
33
|
+
let serverCalls = null;
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
/**
|
|
37
|
+
* @param {{model: string|null, usage: Object}} turn
|
|
38
|
+
*/
|
|
39
|
+
record({ model, usage }) {
|
|
40
|
+
calls += 1;
|
|
41
|
+
const key = model || 'unknown';
|
|
42
|
+
const entry = byModel.get(key) || { model: key, input_tokens: 0, cache_read_tokens: 0, output_tokens: 0, usd: 0 };
|
|
43
|
+
// The report schema has no cache-creation field, so creation tokens
|
|
44
|
+
// fold into input_tokens (they are billed as input) and only cache
|
|
45
|
+
// reads are broken out. The PoC did the same.
|
|
46
|
+
entry.input_tokens += Number(usage.input_tokens || 0) + Number(usage.cache_creation_input_tokens || 0);
|
|
47
|
+
entry.cache_read_tokens += Number(usage.cache_read_input_tokens || 0);
|
|
48
|
+
entry.output_tokens += Number(usage.output_tokens || 0);
|
|
49
|
+
byModel.set(key, entry);
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The authoritative spend, from a heartbeat or run read.
|
|
54
|
+
*
|
|
55
|
+
* @param {{total_usd?: number, calls?: number}|null|undefined} runUsage
|
|
56
|
+
*/
|
|
57
|
+
recordServerUsage(runUsage) {
|
|
58
|
+
if (!runUsage) return;
|
|
59
|
+
if (Number.isFinite(Number(runUsage.total_usd))) serverTotalUsd = Number(runUsage.total_usd);
|
|
60
|
+
if (Number.isFinite(Number(runUsage.calls))) serverCalls = Number(runUsage.calls);
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
get modelCalls() {
|
|
64
|
+
return calls;
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The `usage` object as the report schema wants it.
|
|
69
|
+
*
|
|
70
|
+
* @returns {{by_model: Array, total_usd: number, wall_seconds: number}}
|
|
71
|
+
*/
|
|
72
|
+
toReportUsage() {
|
|
73
|
+
return {
|
|
74
|
+
by_model: [...byModel.values()],
|
|
75
|
+
total_usd: serverTotalUsd === null ? 0 : Number(serverTotalUsd.toFixed(6)),
|
|
76
|
+
wall_seconds: Number(((Date.now() - started) / 1000).toFixed(3))
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `usage.json`, which is not schema-constrained and so can say where
|
|
82
|
+
* each number came from.
|
|
83
|
+
*
|
|
84
|
+
* @returns {Object}
|
|
85
|
+
*/
|
|
86
|
+
toUsageFile() {
|
|
87
|
+
const report = this.toReportUsage();
|
|
88
|
+
return {
|
|
89
|
+
...report,
|
|
90
|
+
local_model_calls: calls,
|
|
91
|
+
server_model_calls: serverCalls,
|
|
92
|
+
usd_source: serverTotalUsd === null ? 'unavailable' : 'server',
|
|
93
|
+
note: 'Token counts are read off the model stream. total_usd is the server figure, because per-token prices are server side only.'
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} runDirectory
|
|
101
|
+
* @param {ReturnType<typeof createUsageLedger>} ledger
|
|
102
|
+
* @returns {Promise<string>} the path written
|
|
103
|
+
*/
|
|
104
|
+
export async function writeUsageFile(runDirectory, ledger) {
|
|
105
|
+
const target = path.join(runDirectory, RUN_FILES.usageJson);
|
|
106
|
+
const body = `${JSON.stringify(ledger.toUsageFile(), null, 2)}\n`;
|
|
107
|
+
// Token counts and model ids should never look secret shaped, and that is
|
|
108
|
+
// the point of checking: this file is uploaded by default, so anything
|
|
109
|
+
// unexpected in it is refused rather than shipped.
|
|
110
|
+
assertNoSecrets('usage.json', body);
|
|
111
|
+
await writeFile(target, body, 'utf8');
|
|
112
|
+
return target;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default { createUsageLedger, writeUsageFile };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { paths } from '../paths.js';
|
|
3
|
+
import { readJson, writeJson } from '../fsutil.js';
|
|
4
|
+
import { CliError } from '../errors.js';
|
|
5
|
+
|
|
6
|
+
export const STATE_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The resumable job journal, one file per job, at
|
|
10
|
+
* `<repo>/.ravensight/jobs/<jobId>/state.json`.
|
|
11
|
+
*
|
|
12
|
+
* Written atomically (temp file plus rename, see fsutil), because the whole
|
|
13
|
+
* point of the file is to survive the process being killed, and a half written
|
|
14
|
+
* journal is worse than none: `resume` would read it, believe a run finished
|
|
15
|
+
* that did not, and skip it.
|
|
16
|
+
*
|
|
17
|
+
* The contents beyond `job_id` and `game_id` belong to the runner. cli-core
|
|
18
|
+
* reads those two, plus `runs` when it is present, and treats everything else
|
|
19
|
+
* as opaque so a runner change never needs a change here.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The skeleton a runner starts from. */
|
|
23
|
+
export function newState({ jobId, gameId, ...rest }) {
|
|
24
|
+
return {
|
|
25
|
+
state_version: STATE_VERSION,
|
|
26
|
+
job_id: jobId,
|
|
27
|
+
game_id: gameId,
|
|
28
|
+
created_at: new Date().toISOString(),
|
|
29
|
+
updated_at: new Date().toISOString(),
|
|
30
|
+
runs: {},
|
|
31
|
+
...rest
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const stateFileFor = (jobId, options = {}) => paths.stateFile(jobId, options);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read a job's journal, or null when there is none.
|
|
39
|
+
* @param {string} jobId
|
|
40
|
+
* @param {{repoRoot?: string}} [options]
|
|
41
|
+
* @returns {Promise<Object|null>}
|
|
42
|
+
*/
|
|
43
|
+
export async function readState(jobId, options = {}) {
|
|
44
|
+
return readJson(paths.stateFile(jobId, options), null);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Write a job's journal. `updated_at` is stamped here rather than by every
|
|
49
|
+
* caller, so a journal always says when it was last touched even if the writer
|
|
50
|
+
* forgot.
|
|
51
|
+
* @param {string} jobId
|
|
52
|
+
* @param {Object} state
|
|
53
|
+
* @param {{repoRoot?: string}} [options]
|
|
54
|
+
*/
|
|
55
|
+
export async function writeState(jobId, state, options = {}) {
|
|
56
|
+
const next = { ...state, state_version: STATE_VERSION, updated_at: new Date().toISOString() };
|
|
57
|
+
await writeJson(paths.stateFile(jobId, options), next);
|
|
58
|
+
return next;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read, transform, write. Not a lock: one job is driven by one process, and two
|
|
63
|
+
* processes on one job is a mistake no amount of locking here would make safe.
|
|
64
|
+
* @param {string} jobId
|
|
65
|
+
* @param {(state: Object|null) => Object} mutate
|
|
66
|
+
* @param {{repoRoot?: string}} [options]
|
|
67
|
+
*/
|
|
68
|
+
export async function updateState(jobId, mutate, options = {}) {
|
|
69
|
+
const current = await readState(jobId, options);
|
|
70
|
+
const next = mutate(current);
|
|
71
|
+
if (!next || typeof next !== 'object') {
|
|
72
|
+
throw new CliError('updateState needs an object back from its mutator.');
|
|
73
|
+
}
|
|
74
|
+
return writeState(jobId, next, options);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every job this repo has a journal for, newest first.
|
|
79
|
+
* @param {{repoRoot?: string}} [options]
|
|
80
|
+
* @returns {Promise<Array<{job_id: string, game_id: string, updated_at: string, state: Object}>>}
|
|
81
|
+
*/
|
|
82
|
+
export async function listStates(options = {}) {
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = await readdir(paths.jobsDir(options), { withFileTypes: true });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error.code === 'ENOENT') return [];
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const found = [];
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
if (!entry.isDirectory()) continue;
|
|
94
|
+
const state = await readState(entry.name, options);
|
|
95
|
+
if (!state) continue;
|
|
96
|
+
found.push({
|
|
97
|
+
job_id: state.job_id || entry.name,
|
|
98
|
+
game_id: state.game_id || null,
|
|
99
|
+
updated_at: state.updated_at || null,
|
|
100
|
+
state
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
found.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
|
|
104
|
+
return found;
|
|
105
|
+
}
|