ravensight-playtest 0.2.0 → 0.2.3

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/README.md CHANGED
@@ -367,7 +367,10 @@ In your repo:
367
367
  ```
368
368
 
369
369
  A `fix` job's run directory holds `changeset.json`, `report.md` (the pull
370
- request body) and `usage.json`, plus the screenshots it was shown as evidence.
370
+ request body) and `usage.json`, plus the screenshots it was shown as evidence,
371
+ and a local `transcript.jsonl` of the apply loop (which tool, on what, how
372
+ much came back) that is never uploaded and is how an attempt that wrote
373
+ nothing gets explained afterwards.
371
374
 
372
375
  Add `.ravensight/jobs/` to your `.gitignore`; `init` does it for you when you
373
376
  already have one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ravensight-playtest",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
4
  "description": "AI personas play your game and file reports to Ravensight.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ravensight.io/docs/#playtest",
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { appendFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { optionsFrom } from '../run/args.js';
5
5
  import { getDeps } from '../run/deps.js';
@@ -443,7 +443,13 @@ async function fix(target, flags, injected, deps, log) {
443
443
  ledger,
444
444
  log,
445
445
  onProgress: progress => {
446
- if (options.verbose) log(` ${progress.tool} ${progress.target || ''}`);
446
+ if (options.verbose) {
447
+ log(` ${progress.tool} ${progress.target || ''} (${progress.resultChars} chars${progress.isError ? ', error' : ''}${progress.repeat ? ', repeat' : ''})`);
448
+ }
449
+ // A local transcript of the apply loop: which tools, on what, how much
450
+ // came back. Never uploaded; it is how a fix that wrote nothing gets
451
+ // explained afterwards, which the first production attempt could not be.
452
+ appendFile(path.join(directory, 'transcript.jsonl'), `${JSON.stringify({ at: new Date().toISOString(), ...progress })}\n`).catch(() => {});
447
453
  },
448
454
  shouldStop: () => interrupt.signalled || heartbeat.canceled
449
455
  });
package/src/dashboard.js CHANGED
@@ -36,24 +36,34 @@ export function dashboardUrl(apiUrl = resolveApiUrl()) {
36
36
  return url.toString().replace(/\/+$/, '');
37
37
  }
38
38
 
39
+ /**
40
+ * The dashboard's playtest routes carry no game in the path: the switcher
41
+ * holds the game, and a link names it in the query so the router can select
42
+ * it on arrival. `/games/<id>/playtest/...` was the first cut and answered
43
+ * "That page does not exist" for every link this CLI printed.
44
+ */
45
+ function playtestPage(gameId, page, apiUrl) {
46
+ return `${dashboardUrl(apiUrl)}/playtest${page}?game=${encodeURIComponent(gameId)}`;
47
+ }
48
+
39
49
  /** The dashboard page for one game's playtest surface. */
40
50
  export function gameLink(gameId, apiUrl) {
41
- return `${dashboardUrl(apiUrl)}/games/${encodeURIComponent(gameId)}/playtest`;
51
+ return playtestPage(gameId, '', apiUrl);
42
52
  }
43
53
 
44
54
  /** The dashboard page for one job. Matches `links.dashboard` from POST /jobs. */
45
55
  export function jobLink(gameId, jobId, apiUrl) {
46
- return `${gameLink(gameId, apiUrl)}/jobs/${encodeURIComponent(jobId)}`;
56
+ return playtestPage(gameId, `/jobs/${encodeURIComponent(jobId)}`, apiUrl);
47
57
  }
48
58
 
49
59
  /** The post job review form for one job. */
50
60
  export function reviewLink(gameId, jobId, apiUrl) {
51
- return `${jobLink(gameId, jobId, apiUrl)}/review`;
61
+ return playtestPage(gameId, `/jobs/${encodeURIComponent(jobId)}/review`, apiUrl);
52
62
  }
53
63
 
54
64
  /** The brief form. */
55
65
  export function briefLink(gameId, apiUrl) {
56
- return `${gameLink(gameId, apiUrl)}/brief`;
66
+ return playtestPage(gameId, '/brief', apiUrl);
57
67
  }
58
68
 
59
69
  /**
@@ -63,7 +73,7 @@ export function briefLink(gameId, apiUrl) {
63
73
  * server can keep true.
64
74
  */
65
75
  export function trustLink(gameId, apiUrl) {
66
- return `${gameLink(gameId, apiUrl)}/what-leaves-your-machine`;
76
+ return playtestPage(gameId, '/what-leaves-your-machine', apiUrl);
67
77
  }
68
78
 
69
79
  /**
@@ -37,6 +37,32 @@ export const SUMMARY_MAX_TOKENS = 4096;
37
37
  */
38
38
  export const MAX_FIX_ACTIONS = 60;
39
39
 
40
+ /**
41
+ * How many tool-answering turns the plan step may take before it has to
42
+ * answer. A real codebase takes several reads to locate a behavior; one
43
+ * repair turn (the first cut) produced empty plans on every real repository.
44
+ */
45
+ export const PLAN_MAX_TURNS = 6;
46
+
47
+ /**
48
+ * Every this-many apply turns without a write, the loop tells the model how
49
+ * many actions are left and asks it to write or say what blocks it. A model
50
+ * that is lost otherwise reads until the budget warning ends the run.
51
+ */
52
+ export const NUDGE_EVERY = 8;
53
+
54
+ /** The text of a tool result, whichever of the two content shapes it uses. */
55
+ function resultText(result) {
56
+ if (!result) return '';
57
+ if (typeof result.content === 'string') return result.content;
58
+ if (Array.isArray(result.content)) return result.content.map(block => (block && block.text) || '').join('');
59
+ return '';
60
+ }
61
+
62
+ function resultLength(result) {
63
+ return resultText(result).length;
64
+ }
65
+
40
66
  /** A plan that says nothing, which is what a malformed answer becomes. */
41
67
  export const EMPTY_PLAN = Object.freeze({ hypothesis: '', files_to_change: [], out_of_scope: [] });
42
68
 
@@ -176,7 +202,11 @@ export async function planFix({
176
202
  let repaired = false;
177
203
  let plan = null;
178
204
 
179
- for (let attempt = 1; attempt <= 2 && !plan; attempt += 1) {
205
+ // Reading turns are answered and counted; a turn with no tool calls is
206
+ // the answer, and one repair is allowed when that answer is not JSON. At
207
+ // the ceiling the model is told to answer now, and if it still reaches for
208
+ // a tool the plan stays empty: planning is not where the budget belongs.
209
+ while (turns < PLAN_MAX_TURNS + 1 && !plan) {
180
210
  const answer = await callStepWithRetry({
181
211
  client,
182
212
  jobId,
@@ -196,9 +226,13 @@ export async function planFix({
196
226
  plan = normalisePlan(parsed);
197
227
  break;
198
228
  }
199
- if (attempt === 2) break;
229
+ const reading = answer.toolUses.length > 0 && Boolean(repo);
230
+ if (!reading) {
231
+ if (repaired) break;
232
+ repaired = true;
233
+ }
234
+ if (turns > PLAN_MAX_TURNS) break;
200
235
 
201
- repaired = true;
202
236
  messages.push({
203
237
  role: 'assistant',
204
238
  content: [
@@ -212,16 +246,15 @@ export async function planFix({
212
246
  const executed = await repo.execute(toolUse);
213
247
  results.push(executed.result);
214
248
  }
215
- messages.push({
216
- role: 'user',
217
- content: [
218
- ...results,
219
- { type: 'text', text: 'That was not the plan object. Answer with the JSON object only, nothing before or after it.' }
220
- ]
221
- });
249
+ const nudge = turns >= PLAN_MAX_TURNS
250
+ ? 'No more reading. Answer now with the JSON object only, nothing before or after it.'
251
+ : reading
252
+ ? `${PLAN_MAX_TURNS - turns} more reading turn(s) before you have to answer with the JSON object.`
253
+ : 'That was not the plan object. Answer with the JSON object only, nothing before or after it.';
254
+ messages.push({ role: 'user', content: [...results, { type: 'text', text: nudge }] });
222
255
  }
223
256
 
224
- if (!plan) log('The plan did not come back as JSON after one repair, so the fix proceeds without one.');
257
+ if (!plan) log(`The plan did not come back as JSON within ${PLAN_MAX_TURNS} reading turns and one repair, so the fix proceeds without one.`);
225
258
  return { plan: plan || { ...EMPTY_PLAN }, turns, repaired, messages };
226
259
  }
227
260
 
@@ -275,7 +308,7 @@ export async function applyFix({
275
308
  content: [{
276
309
  type: 'text',
277
310
  text: [
278
- 'Make the change now. Read before you write: `write_file` replaces the whole file, so send the file back with your edit in it.',
311
+ 'Make the change now. Read before you edit. Change an existing file with `edit_file` (old_text copied verbatim, new_text in its place); use `write_file` only for a new file, because it replaces the whole file and a large file will not fit in one answer.',
279
312
  'Change as little as possible, keep the style of the code around it, and stop as soon as the fix is in. When you are done, say in one line what you changed and call no more tools.',
280
313
  '',
281
314
  `<plan>\n${JSON.stringify(plan || EMPTY_PLAN, null, 2)}\n</plan>`,
@@ -286,6 +319,10 @@ export async function applyFix({
286
319
 
287
320
  let turns = 0;
288
321
  let stop = 'max_actions';
322
+ // Every call the loop has already answered, so a lost model that asks the
323
+ // same question twice is told so instead of paying for the same answer.
324
+ const answered = new Map();
325
+ let turnsSinceWrite = 0;
289
326
  while (turns < maxActions) {
290
327
  if (shouldStop()) {
291
328
  stop = 'interrupted';
@@ -328,10 +365,32 @@ export async function applyFix({
328
365
  ]
329
366
  });
330
367
  const results = [];
368
+ let wroteThisTurn = false;
331
369
  for (const toolUse of answer.toolUses) {
370
+ const key = `${toolUse.name}:${JSON.stringify(toolUse.input || {})}`;
371
+ const repeat = answered.get(key);
332
372
  const executed = await repo.execute(toolUse);
333
- results.push(executed.result);
334
- onProgress({ turn: turns, tool: toolUse.name, target: toolUse.input && (toolUse.input.path || toolUse.input.pattern) });
373
+ const result = repeat === undefined
374
+ ? executed.result
375
+ : { ...executed.result, content: [{ type: 'text', text: `(identical to a call you already made on turn ${repeat}; the answer has not changed)\n${resultText(executed.result)}` }] };
376
+ answered.set(key, repeat === undefined ? turns : repeat);
377
+ results.push(result);
378
+ if ((toolUse.name === 'write_file' || toolUse.name === 'edit_file') && !executed.result.is_error) wroteThisTurn = true;
379
+ onProgress({
380
+ turn: turns,
381
+ tool: toolUse.name,
382
+ target: toolUse.input && (toolUse.input.path || toolUse.input.pattern),
383
+ resultChars: resultLength(executed.result),
384
+ isError: Boolean(executed.result.is_error),
385
+ repeat: repeat !== undefined
386
+ });
387
+ }
388
+ turnsSinceWrite = wroteThisTurn ? 0 : turnsSinceWrite + 1;
389
+ if (turnsSinceWrite > 0 && turnsSinceWrite % NUDGE_EVERY === 0 && turns < maxActions) {
390
+ results.push({
391
+ type: 'text',
392
+ text: `You have used ${turns} of ${maxActions} actions and written nothing. Either make the change now with edit_file (or write_file for a new file), or answer in one line, with no tool call, why it cannot be made.`
393
+ });
335
394
  }
336
395
  messages.push({ role: 'user', content: results });
337
396
 
@@ -68,7 +68,13 @@ export const MAX_READ_BYTES = 120000;
68
68
  export const MAX_GLOB_RESULTS = 400;
69
69
 
70
70
  /** How many matching lines a grep answers with. */
71
- export const MAX_GREP_RESULTS = 200;
71
+ /**
72
+ * Sixty, not two hundred: at three hundred characters a line the old cap
73
+ * let one grep put 60 KB into the context, and forty greps in one apply loop
74
+ * ate the whole model budget without a write. A capped answer says so, so
75
+ * the model narrows rather than repeats.
76
+ */
77
+ export const MAX_GREP_RESULTS = 60;
72
78
 
73
79
  /** The tool definitions for `game_profile.triage`. */
74
80
  export const PROFILE_TOOLS = Object.freeze([
@@ -126,10 +132,34 @@ export const MAX_WRITE_BYTES = 200000;
126
132
  /** How many distinct paths one run may write. A fix is a patch, not a rewrite. */
127
133
  export const MAX_WRITES_PER_RUN = 20;
128
134
 
129
- /** The one write tool, defined here so nothing else can invent a second one. */
135
+ /**
136
+ * The two writing tools, defined here so nothing else can invent a third.
137
+ *
138
+ * `edit_file` exists because whole-file writes do not survive contact with a
139
+ * real codebase: the first production fix attempt read an 84 KB C++ file and
140
+ * then could not send it back inside the step's output ceiling, so its one
141
+ * write arrived as malformed input and the attempt ended with nothing on the
142
+ * branch. An exact-text replacement is a few hundred bytes for the same edit.
143
+ */
144
+ export const EDIT_FILE_TOOL = Object.freeze({
145
+ name: 'edit_file',
146
+ description: 'Replace one exact passage of a text file in the repository with new text. Paths are relative to the repository root. old_text must appear exactly once in the file (or set replace_all for every occurrence); copy it verbatim from read_file, whitespace included. This is the tool for changing a file that already exists.',
147
+ input_schema: {
148
+ type: 'object',
149
+ additionalProperties: false,
150
+ required: ['path', 'old_text', 'new_text'],
151
+ properties: {
152
+ path: { type: 'string' },
153
+ old_text: { type: 'string' },
154
+ new_text: { type: 'string' },
155
+ replace_all: { type: 'boolean' }
156
+ }
157
+ }
158
+ });
159
+
130
160
  export const WRITE_FILE_TOOL = Object.freeze({
131
161
  name: 'write_file',
132
- description: 'Write a text file in the repository, replacing it whole. Paths are relative to the repository root. Read the file first: there is no patch tool, so the content you send is the file.',
162
+ description: 'Write a text file in the repository, replacing it whole. Paths are relative to the repository root. Use it for a new file or a small one; for a change to an existing file use edit_file, because the content you send here is the whole file.',
133
163
  input_schema: {
134
164
  type: 'object',
135
165
  additionalProperties: false,
@@ -141,8 +171,8 @@ export const WRITE_FILE_TOOL = Object.freeze({
141
171
  }
142
172
  });
143
173
 
144
- /** The tool definitions for `fix.apply`: the read-only three, plus the write. */
145
- export const WRITE_TOOLS = Object.freeze([...PROFILE_TOOLS, WRITE_FILE_TOOL]);
174
+ /** The tool definitions for `fix.apply`: the read-only three, plus the two writes. */
175
+ export const WRITE_TOOLS = Object.freeze([...PROFILE_TOOLS, EDIT_FILE_TOOL, WRITE_FILE_TOOL]);
146
176
 
147
177
  /**
148
178
  * Compile a glob into a regular expression. Supports `**`, `*` and `?`,
@@ -388,6 +418,14 @@ export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
388
418
  const left = MAX_WRITES_PER_RUN - written.size;
389
419
  return { result: ok(toolUse.id, `Wrote ${planned.relative} (${planned.bytes} bytes). ${left} more file(s) may be written in this run.`) };
390
420
  }
421
+ if (toolUse.name === 'edit_file' && canWrite) {
422
+ const planned = await planEdit(root, input, written);
423
+ if (planned.refusal) return { result: ok(toolUse.id, planned.refusal, true) };
424
+ await doWrite(planned.absolute, planned.content);
425
+ written.add(planned.relative);
426
+ const left = MAX_WRITES_PER_RUN - written.size;
427
+ return { result: ok(toolUse.id, `Edited ${planned.relative}: ${planned.replaced} occurrence(s) replaced, now ${planned.bytes} bytes. ${left} more file(s) may be written in this run.`) };
428
+ }
391
429
  if (toolUse.name === 'glob') {
392
430
  const matcher = globToRegExp(input.pattern);
393
431
  const matched = (await files()).filter(file => matcher.test(file)).slice(0, MAX_GLOB_RESULTS);
@@ -495,6 +533,54 @@ export async function planWrite(root, input, written = new Set()) {
495
533
  * @param {string} absolute
496
534
  * @param {string} content
497
535
  */
536
+ /**
537
+ * Everything `planWrite` checks about the path, then the replacement itself:
538
+ * the file has to exist and be text, `old_text` has to be found exactly once
539
+ * unless `replace_all`, and the result has to fit the same byte ceiling a
540
+ * whole write does. Refusals name the count found, because "not found" and
541
+ * "found three times" call for different next moves.
542
+ *
543
+ * @param {string} root
544
+ * @param {Object} input
545
+ * @param {Set<string>} written
546
+ * @returns {Promise<{refusal: string}|{relative: string, absolute: string, content: string, bytes: number, replaced: number}>}
547
+ */
548
+ export async function planEdit(root, input, written = new Set()) {
549
+ const oldText = input && input.old_text;
550
+ const newText = input && input.new_text;
551
+ if (typeof oldText !== 'string' || oldText.length === 0) {
552
+ return { refusal: 'Refused: edit_file needs old_text, the exact passage to replace, copied verbatim from read_file.' };
553
+ }
554
+ if (typeof newText !== 'string') return { refusal: 'Refused: new_text has to be a string (it may be empty to delete the passage).' };
555
+ // The path rules are the write rules; the placeholder content only has to be
556
+ // a string so the shared checks run.
557
+ const planned = await planWrite(root, { path: input && input.path, content: '' }, written);
558
+ if (planned.refusal) return { refusal: planned.refusal.replace('write_file', 'edit_file') };
559
+
560
+ let current;
561
+ try {
562
+ current = await readFile(planned.absolute, 'utf8');
563
+ } catch {
564
+ return { refusal: `Refused: ${planned.relative} does not exist yet. Use write_file to create a new file.` };
565
+ }
566
+ if (current.includes(NUL)) return { refusal: `Refused: ${planned.relative} is not a text file.` };
567
+
568
+ const occurrences = current.split(oldText).length - 1;
569
+ if (occurrences === 0) {
570
+ return { refusal: `Refused: old_text was not found in ${planned.relative}. Read the file again and copy the passage exactly, whitespace included.` };
571
+ }
572
+ if (occurrences > 1 && !(input && input.replace_all)) {
573
+ return { refusal: `Refused: old_text appears ${occurrences} times in ${planned.relative}. Include more surrounding lines so it is unique, or set replace_all.` };
574
+ }
575
+ const content = input && input.replace_all ? current.split(oldText).join(newText) : current.replace(oldText, () => newText);
576
+ if (content.includes(NUL)) return { refusal: 'Refused: new_text holds a NUL byte, so that is not text.' };
577
+ const bytes = Buffer.byteLength(content, 'utf8');
578
+ if (bytes > MAX_WRITE_BYTES) {
579
+ return { refusal: `Refused: ${planned.relative} would be ${bytes} bytes and one write may be at most ${MAX_WRITE_BYTES}.` };
580
+ }
581
+ return { relative: planned.relative, absolute: planned.absolute, content, bytes, replaced: input && input.replace_all ? occurrences : 1 };
582
+ }
583
+
498
584
  async function doWrite(absolute, content) {
499
585
  await mkdir(path.dirname(absolute), { recursive: true });
500
586
  const tmp = `${absolute}.${process.pid}.${randomUUID()}.tmp`;
@@ -532,7 +618,11 @@ async function doGrep(root, all, input) {
532
618
  if (regex.test(lines[i])) hits.push(`${relative}:${i + 1}: ${lines[i].trim().slice(0, 300)}`);
533
619
  }
534
620
  }
535
- return hits.length > 0 ? hits.join('\n') : '(no matches)';
621
+ if (hits.length === 0) return '(no matches)';
622
+ const capped = hits.length >= MAX_GREP_RESULTS;
623
+ return capped
624
+ ? `${hits.join('\n')}\n(stopped at ${MAX_GREP_RESULTS} matches; narrow the pattern or add a glob)`
625
+ : hits.join('\n');
536
626
  }
537
627
 
538
628
  function ok(id, text, isError = false) {
@@ -544,6 +634,8 @@ export default {
544
634
  PROFILE_TOOLS,
545
635
  READ_ONLY_TOOLS,
546
636
  WRITE_TOOLS,
637
+ EDIT_FILE_TOOL,
638
+ planEdit,
547
639
  globToRegExp,
548
640
  listFiles,
549
641
  planWrite,