ravensight-playtest 0.2.6 → 0.2.8

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.
@@ -11,6 +11,7 @@ import { assertNoSecrets } from './secretScan.js';
11
11
  import { ACTION_TOOLS, PLAY_TOOLS, SWIPE_TOOL, REPORT_TOOLS, executeTool } from './tools.js';
12
12
  import { createTranscript } from './transcript.js';
13
13
  import { createUsageLedger, writeUsageFile } from './usage.js';
14
+ import { fillPlaceholders, fillReportPlaceholders } from './placeholders.js';
14
15
 
15
16
  /**
16
17
  * One persona's playthrough: PLAYING then REPORTING (spec 03).
@@ -51,8 +52,22 @@ const nextOrder = () => {
51
52
  return ORDER;
52
53
  };
53
54
 
54
- /** `Failed to load resource: ... status of 4xx`: a missing asset or a refused call, not a crash. */
55
- const RESOURCE_4XX = /Failed to load resource: the server responded with a status of 4\d\d/;
55
+ /**
56
+ * A 4xx on a request, in the shapes a browser console and a game's own logging
57
+ * print it: `Failed to load resource: ... status of 4xx`, `HTTP 404`,
58
+ * `status: 401`, `statusCode=403`, `401 Unauthorized`. A missing asset or a
59
+ * refused call, not a crash.
60
+ */
61
+ export const RESOURCE_4XX = /Failed to load resource: the server responded with a status of 4\d\d|\bHTTP 4\d\d\b|status(?:Code)?\s*[:=]?\s*4\d\d\b|\b4\d\d (?:Unauthorized|Forbidden|Not Found|Bad Request)\b/i;
62
+
63
+ /** The screenshots the runner takes on its own, which no persona described. */
64
+ const RUNNER_SCREENSHOTS = Object.freeze(['landing', 'console-error', 'final']);
65
+
66
+ /** Severities above `minor`, which network notes alone cannot earn. */
67
+ const ABOVE_MINOR = Object.freeze(['blocker', 'major']);
68
+
69
+ /** What a capped finding's description gains, so the reader knows why. */
70
+ export const NETWORK_CAP_NOTE = 'Severity capped at minor by the runner: the only evidence is automatic network notes, and a failed request is a finding above minor only when the persona saw the game break because of it.';
56
71
 
57
72
  /** A persona note kind as a report finding category. */
58
73
  export const NOTE_CATEGORY = Object.freeze({ bug: 'bug', confusing: 'ux', suggestion: 'ux', good: 'ux', reaction: 'ux' });
@@ -127,6 +142,10 @@ export async function runPersona(args) {
127
142
  const startedAt = new Date().toISOString();
128
143
  const notes = [];
129
144
  const screenshots = [];
145
+ // Every observation the persona was shown, by index, so a note can carry
146
+ // what the screen looked like next and a finding can cite where a failure
147
+ // is visible.
148
+ const observations = [];
130
149
  let imageBudget = maxImages;
131
150
 
132
151
  await mkdir(path.join(runDirectory, 'screenshots'), { recursive: true });
@@ -186,6 +205,7 @@ export async function runPersona(args) {
186
205
  ledger,
187
206
  notes,
188
207
  screenshots,
208
+ observations,
189
209
  imageBudget,
190
210
  keepObservations,
191
211
  onImageBudget: next => {
@@ -242,6 +262,7 @@ export async function runPersona(args) {
242
262
  ledger,
243
263
  notes,
244
264
  screenshots,
265
+ observations,
245
266
  play,
246
267
  startedAt,
247
268
  endedAt,
@@ -305,6 +326,7 @@ async function playLoop({
305
326
  ledger,
306
327
  notes,
307
328
  screenshots,
329
+ observations = [],
308
330
  imageBudget,
309
331
  keepObservations,
310
332
  heartbeat,
@@ -343,7 +365,26 @@ async function playLoop({
343
365
  let wrapUpLeft = Infinity;
344
366
  const maxTurns = maxActions * 3 + 20;
345
367
 
368
+ // Notes waiting for the next observation, which is the screen they are
369
+ // checked against: a failure the next screen does not show is not a
370
+ // blocker.
371
+ let awaitingAfter = [];
372
+ const observed = observation => {
373
+ const entry = { index: observations.length + 1, step: observation.step, url: observation.url || null, title: observationTitle(observation) };
374
+ observations.push(entry);
375
+ for (const note of awaitingAfter) note.after = { url: entry.url, title: entry.title };
376
+ awaitingAfter = [];
377
+ return entry;
378
+ };
379
+ const noted = note => {
380
+ const entry = { ...note, at_action: actions, observation: observations.length, order: nextOrder() };
381
+ notes.push(entry);
382
+ awaitingAfter.push(entry);
383
+ return entry;
384
+ };
385
+
346
386
  const first = await driver.observe();
387
+ observed(first);
347
388
  latestObservation = first.text;
348
389
  visualObservation = Boolean(first.screenshotBase64);
349
390
  transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
@@ -458,7 +499,7 @@ async function playLoop({
458
499
  // (bug, confusing, good, suggestion, reaction) would otherwise
459
500
  // overwrite the transcript line's `kind`, and every note would
460
501
  // read as a different sort of line.
461
- notes.push({ ...note, at_action: actions, order: nextOrder() });
502
+ noted(note);
462
503
  transcript.write({ kind: 'note', tool: toolUse.name, note });
463
504
  },
464
505
  onScreenshot: shot => {
@@ -479,6 +520,7 @@ async function playLoop({
479
520
  actions
480
521
  });
481
522
  if (executed.observation) {
523
+ observed(executed.observation);
482
524
  latestObservation = executed.observation.text;
483
525
  visualObservation = Boolean(executed.observation.screenshotBase64);
484
526
  recentActions.push(`${toolUse.name}: ${executed.changed ? 'screen changed' : 'no observed change'}`);
@@ -513,8 +555,8 @@ async function playLoop({
513
555
  const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript);
514
556
  for (const entry of consoleErrors) {
515
557
  const kind = RESOURCE_4XX.test(String(entry.text)) ? 'network' : 'bug';
516
- const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true };
517
- notes.push({ ...note, at_action: actions, order: nextOrder() });
558
+ const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true, ...(shot ? { screenshot: shot.relativePath } : {}) };
559
+ noted(note);
518
560
  transcript.write({ kind: 'note', note });
519
561
  }
520
562
  asides.push(`The game logged ${consoleErrors.length} error(s): ${consoleErrors.map(entry => entry.text).join(' | ').slice(0, 800)}${shot ? ` A screenshot is saved at ${shot.relativePath}.` : ''}`);
@@ -584,6 +626,7 @@ async function reportPhase({
584
626
  ledger,
585
627
  notes,
586
628
  screenshots,
629
+ observations = [],
587
630
  play,
588
631
  startedAt,
589
632
  endedAt,
@@ -600,9 +643,17 @@ async function reportPhase({
600
643
  `actions taken: ${play.actionsTaken} stop reason: ${play.quitReason}${play.quitDetail ? ` (${play.quitDetail})` : ''}`,
601
644
  '',
602
645
  'Notes you logged, in order:',
603
- notes.length > 0 ? notes.map((note, i) => `${i + 1}. [${note.kind}${note.severity ? `/${note.severity}` : ''}] ${note.text}`).join('\n') : '(none)',
646
+ notes.length > 0 ? notes.map((note, i) => noteLine(note, i)).join('\n') : '(none)',
604
647
  '',
605
- 'Notes marked automatic were recorded by the runner from the console, with no severity: decide one from what the persona saw. Network notes are context, not bugs: a failed request is a finding only when the persona saw a broken screen because of it.',
648
+ 'Notes marked automatic were recorded by the runner from the console, with no severity: decide one from what the persona saw. Network notes are context, not bugs: a failed request is a finding only when the persona saw a broken screen because of it. A finding whose only evidence is automatic network notes is capped at minor by the runner.',
649
+ '',
650
+ 'Observations the persona was shown, by index:',
651
+ observationLines(observations),
652
+ '',
653
+ 'Each note carries "after": the URL and title of the observation that followed it. A blocker or major finding must cite the observation index where the failure is visible, as evidence {"type": "transcript_step", "ref": "observation N"}, and must not be contradicted by its note\'s "after" state or by a later persona note: if the next screen or a later note shows the game working, it is not a blocker or a major. Cite a note as {"type": "transcript_step", "ref": "note N"}.',
654
+ '',
655
+ 'Never write calendar dates or times in the prose of report.md or report.json. The runner stamps when the report was generated, so leave the template\'s Date out rather than writing a placeholder for it.',
656
+ 'Replace every <...> slot in the template with real content. Never copy a <...> placeholder into the report.',
606
657
  '',
607
658
  'Screenshots available as evidence (use these exact paths in evidence refs):',
608
659
  screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}${shot.reason ? ` (${shot.reason})` : ''}`).join('\n') : '(none)',
@@ -669,7 +720,7 @@ async function reportPhase({
669
720
  quitReason: play.quitReason,
670
721
  quitDetail: play.quitDetail
671
722
  };
672
- const candidate = parsed ? stampReport(parsed, facts) : null;
723
+ const candidate = parsed ? capNetworkOnlyFindings(stampReport(parsed, facts), { notes, screenshots }) : null;
673
724
  const validation = candidate ? validator.validate(candidate) : { ok: false, details: [{ instancePath: '/', message: parseError || 'report.json was not written' }] };
674
725
  const evidenceProblems = candidate ? await checkEvidence(candidate, runDirectory) : [];
675
726
  // The server lints the same four sections at `complete` and records the
@@ -715,6 +766,11 @@ async function reportPhase({
715
766
  if (!markdown) {
716
767
  markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason: 'The report step wrote report.json but no report.md.' });
717
768
  }
769
+ const generatedAt = new Date().toISOString();
770
+ const facts = reportFacts({ persona, driverName, target, play });
771
+ markdown = stampGeneratedAt(markdown, generatedAt, facts);
772
+ // The same slots in the prose the dashboard shows from report.json.
773
+ report = fillReportPlaceholders(report, { generatedAt, ...facts });
718
774
 
719
775
  await writeReportFiles({ runDirectory, markdown, report });
720
776
  return { report, markdown, lint };
@@ -1033,17 +1089,167 @@ async function writePartialReport({ outcome, runDirectory, persona, driverName,
1033
1089
  play,
1034
1090
  reason: 'The run reached its model budget before the report step. What the persona saw is below, and report.json carries the notes as unreviewed findings.'
1035
1091
  });
1036
- await writeReportFiles({ runDirectory, markdown, report });
1092
+ const stamped = stampGeneratedAt(markdown, new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
1093
+ await writeReportFiles({ runDirectory, markdown: stamped, report });
1037
1094
  outcome.report = report;
1038
- outcome.reportMarkdown = markdown;
1095
+ outcome.reportMarkdown = stamped;
1039
1096
  }
1040
1097
 
1041
1098
  async function writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason }) {
1042
- const markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason });
1099
+ const markdown = stampGeneratedAt(renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }), new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
1043
1100
  assertNoSecrets('report.md', markdown);
1044
1101
  await writeFile(path.join(runDirectory, RUN_FILES.reportMd), markdown, 'utf8');
1045
1102
  }
1046
1103
 
1104
+ /**
1105
+ * A note as one numbered line of the report prompt: its kind, whether the
1106
+ * runner recorded it, the observation it was made on and the screen that
1107
+ * followed it.
1108
+ *
1109
+ * @param {Object} note
1110
+ * @param {number} i
1111
+ * @returns {string}
1112
+ */
1113
+ export function noteLine(note, i) {
1114
+ const tags = [
1115
+ note.automatic ? 'automatic' : null,
1116
+ Number.isInteger(note.observation) && note.observation > 0 ? `seen at observation ${note.observation}` : null,
1117
+ note.screenshot ? `screenshot ${note.screenshot}` : null
1118
+ ].filter(Boolean);
1119
+ const after = note.after
1120
+ ? ` after: ${JSON.stringify({ url: note.after.url ?? null, title: note.after.title ?? null })}`
1121
+ : ' after: (no later observation)';
1122
+ return `${i + 1}. [${note.kind}${note.severity ? `/${note.severity}` : ''}] ${note.text}${tags.length > 0 ? ` (${tags.join(', ')})` : ''}${after}`;
1123
+ }
1124
+
1125
+ /** The most observations listed in the report prompt; the newest are kept. */
1126
+ export const MAX_OBSERVATIONS_IN_REPORT = 200;
1127
+
1128
+ function observationLines(observations) {
1129
+ if (!observations || observations.length === 0) return '(none)';
1130
+ const shown = observations.slice(-MAX_OBSERVATIONS_IN_REPORT);
1131
+ const lines = shown.map(entry => `observation ${entry.index}: step ${entry.step}${entry.url ? ` ${entry.url}` : ''}${entry.title ? ` "${entry.title}"` : ''}`);
1132
+ if (shown.length < observations.length) lines.unshift(`(the first ${observations.length - shown.length} are left out)`);
1133
+ return lines.join('\n');
1134
+ }
1135
+
1136
+ /**
1137
+ * A short title for an observation: the page title when the driver has one,
1138
+ * otherwise the first line of what was observed.
1139
+ *
1140
+ * @param {Object} observation
1141
+ * @returns {string|null}
1142
+ */
1143
+ export function observationTitle(observation) {
1144
+ if (observation && typeof observation.title === 'string' && observation.title.trim()) return observation.title.trim().slice(0, 120);
1145
+ const line = String((observation && observation.text) || '').split('\n').map(part => part.trim()).find(Boolean);
1146
+ return line ? line.slice(0, 80) : null;
1147
+ }
1148
+
1149
+ /**
1150
+ * Cap at `minor` every finding whose evidence is only automatic network
1151
+ * notes, in code rather than by asking.
1152
+ *
1153
+ * A finding rests only on network notes when it cites at least one (a
1154
+ * `note N` ref to an automatic network note, or the runner's console-error
1155
+ * screenshot whose notes were all network notes) and nothing else: no persona
1156
+ * note, no automatic non-network note, no observation ref, no screenshot the
1157
+ * persona took, no other evidence type. Only the runner's own landing and
1158
+ * final screenshots neither count for nor against it.
1159
+ *
1160
+ * @param {Object} report a stamped report
1161
+ * @param {{notes: Array<Object>, screenshots: Array<Object>}} context
1162
+ * @returns {Object} the report, with capped findings rewritten
1163
+ */
1164
+ export function capNetworkOnlyFindings(report, { notes = [], screenshots = [] } = {}) {
1165
+ if (!report || !Array.isArray(report.findings)) return report;
1166
+ const byShot = new Map();
1167
+ for (const note of notes) {
1168
+ if (!note || !note.automatic || !note.screenshot) continue;
1169
+ if (!byShot.has(note.screenshot)) byShot.set(note.screenshot, []);
1170
+ byShot.get(note.screenshot).push(note);
1171
+ }
1172
+ const reasons = new Map(screenshots.filter(shot => shot && shot.relativePath).map(shot => [shot.relativePath, String(shot.reason || '')]));
1173
+
1174
+ const verdict = finding => {
1175
+ let network = 0;
1176
+ for (const item of Array.isArray(finding.evidence) ? finding.evidence : []) {
1177
+ if (!item) continue;
1178
+ const ref = String(item.ref || '');
1179
+ if (item.type === 'transcript_step') {
1180
+ // An observation ref, or anything that is not a network note, is
1181
+ // other evidence.
1182
+ const match = ref.match(/^\s*note\s*#?\s*(\d+)\s*$/i);
1183
+ const note = match ? notes[Number(match[1]) - 1] : null;
1184
+ if (note && note.automatic && note.kind === 'network') network += 1;
1185
+ else return false;
1186
+ } else if (item.type === 'screenshot') {
1187
+ const attached = byShot.get(ref);
1188
+ if (attached && attached.length > 0) {
1189
+ if (attached.every(note => note.kind === 'network')) network += 1;
1190
+ else return false;
1191
+ continue;
1192
+ }
1193
+ // The runner's own landing and final screenshots say nothing either
1194
+ // way; any screenshot the persona took is other evidence.
1195
+ const reason = reasons.has(ref) ? reasons.get(ref) : null;
1196
+ if (reason !== null && RUNNER_SCREENSHOTS.includes(reason)) continue;
1197
+ return false;
1198
+ } else {
1199
+ return false;
1200
+ }
1201
+ }
1202
+ return network > 0;
1203
+ };
1204
+
1205
+ return {
1206
+ ...report,
1207
+ findings: report.findings.map(finding => {
1208
+ if (!finding || !ABOVE_MINOR.includes(finding.severity) || !verdict(finding)) return finding;
1209
+ const description = String(finding.description || '');
1210
+ return { ...finding, severity: 'minor', description: description ? `${description}\n\n${NETWORK_CAP_NOTE}` : NETWORK_CAP_NOTE };
1211
+ })
1212
+ };
1213
+ }
1214
+
1215
+ /**
1216
+ * Put the runner's own timestamp at the head of a report.md, right under
1217
+ * its first heading, replacing one stamped before. Whatever the model wrote
1218
+ * in its prose is left alone; the header is what says when the report was
1219
+ * generated.
1220
+ *
1221
+ * @param {string} markdown
1222
+ * @param {string} generatedAt an ISO timestamp
1223
+ * @returns {string}
1224
+ */
1225
+ export function stampGeneratedAt(markdown, generatedAt, facts = {}) {
1226
+ const line = `**Generated at:** ${generatedAt}`;
1227
+ const lines = String(markdown || '').split('\n').filter(entry => !/^\*\*Generated at:\*\* /.test(entry));
1228
+ const heading = lines.findIndex(entry => /^# /.test(entry));
1229
+ if (heading === -1) lines.unshift(line, '');
1230
+ else lines.splice(heading + 1, 0, line);
1231
+ // The template's own slots (`**Date:** <date>`) are filled from what the
1232
+ // runner knows, because the prompt forbids the model a calendar date and a
1233
+ // model that obeys both writes the placeholder itself.
1234
+ return fillPlaceholders(lines.join('\n'), { generatedAt, ...facts });
1235
+ }
1236
+
1237
+ /**
1238
+ * What the runner knows about a run, for the template slots a report left
1239
+ * unfilled.
1240
+ *
1241
+ * @returns {{driver: string, target: string, persona: string, actionsTaken: number, quitReason: string}}
1242
+ */
1243
+ function reportFacts({ persona, driverName, target, play }) {
1244
+ return {
1245
+ driver: driverName,
1246
+ target,
1247
+ persona: persona ? persona.name || persona.slug : undefined,
1248
+ actionsTaken: play ? play.actionsTaken : undefined,
1249
+ quitReason: play ? play.quitReason : undefined
1250
+ };
1251
+ }
1252
+
1047
1253
  /**
1048
1254
  * The `prompt_version` the report has to carry, read out of the skill's own
1049
1255
  * frontmatter so a content pack update moves it without a CLI release.
@@ -1056,4 +1262,4 @@ export function promptVersionOf(skill) {
1056
1262
  return match ? match[1] : null;
1057
1263
  }
1058
1264
 
1059
- export default { runPersona, buildSystem, promptVersionOf, shouldEscalate, renderFallbackMarkdown, notesToFindings, nearestScreenshot };
1265
+ export default { runPersona, buildSystem, promptVersionOf, shouldEscalate, renderFallbackMarkdown, notesToFindings, nearestScreenshot, capNetworkOnlyFindings, stampGeneratedAt, noteLine, observationTitle };
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Template placeholders a model copied into a report instead of filling.
3
+ *
4
+ * The persona skill's report template reads `**Date:** <date> ...`, and the
5
+ * report prompt says never to write a calendar date, so a model that obeys
6
+ * both writes the placeholder itself (`<run date>`) and the developer reads
7
+ * it as the report's date.
8
+ *
9
+ * Deliberately narrow, because angle brackets in a report are usually real:
10
+ * a key (`<Enter>`, `<Ctrl+A>`), an element (`<form>`, `<title>`), a type
11
+ * (`Array<string>`), a link, or the game's own text. So only two things are
12
+ * treated as a slot:
13
+ *
14
+ * 1. A slot the runner knows (date, driver, target, persona, actions, quit
15
+ * reason, game, version), filled from its own facts, or "not recorded"
16
+ * when it has no value for it.
17
+ * 2. Lowercase words in brackets on a `**Field:**` line, the shape of the
18
+ * template's header, which become "not recorded".
19
+ *
20
+ * Never touched: a `<` straight after a word character, a key name, anything
21
+ * quoted, HTML, autolinks, code spans and fenced blocks.
22
+ */
23
+
24
+ /** What a slot with no value becomes. */
25
+ export const NOT_RECORDED = 'not recorded';
26
+
27
+ const HTML_TAGS = new Set([
28
+ 'a', 'abbr', 'article', 'aside', 'b', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'code', 'dd', 'del',
29
+ 'details', 'dialog', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5',
30
+ 'h6', 'head', 'header', 'hr', 'html', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'label', 'li', 'link', 'main', 'mark',
31
+ 'meta', 'nav', 'ol', 'option', 'p', 'picture', 'pre', 'q', 's', 'samp', 'script', 'section', 'select', 'small',
32
+ 'source', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea',
33
+ 'tfoot', 'th', 'thead', 'title', 'tr', 'u', 'ul', 'video'
34
+ ]);
35
+
36
+ const KEY_NAMES = new Set([
37
+ 'enter', 'return', 'tab', 'space', 'spacebar', 'esc', 'escape', 'backspace', 'delete', 'del', 'insert', 'home', 'end',
38
+ 'pageup', 'pagedown', 'up', 'down', 'left', 'right', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'shift',
39
+ 'ctrl', 'control', 'alt', 'option', 'cmd', 'command', 'meta', 'super', 'win', 'fn', 'capslock'
40
+ ]);
41
+
42
+ /** `<...>` with no nested bracket and no line break, short enough to be a template slot. */
43
+ const ANGLE = /<([^<>\n]{1,80})>/g;
44
+
45
+ /** A line in the template's header shape: `**Date:** ...`. */
46
+ const FIELD_LINE = /\*\*[^*\n]+:\*\*/;
47
+
48
+ const QUOTES = { '"': '"', "'": "'", '“': '”', '‘': '’', '`': '`' };
49
+
50
+ function normalise(inner) {
51
+ return String(inner).trim().toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ');
52
+ }
53
+
54
+ /**
55
+ * Whether the bracket is a slot the runner knows, and its value if it has one.
56
+ *
57
+ * @param {string} inner
58
+ * @param {Object} facts
59
+ * @returns {{known: boolean, value: string|null}}
60
+ */
61
+ function knownSlot(inner, facts) {
62
+ const key = normalise(inner);
63
+ const date = facts.generatedAt ? String(facts.generatedAt).slice(0, 10) : null;
64
+ const actions = facts.actionsTaken === null || facts.actionsTaken === undefined || !Number.isFinite(Number(facts.actionsTaken))
65
+ ? null
66
+ : Number(facts.actionsTaken);
67
+ const slot = value => ({ known: true, value: value === null || value === undefined || value === '' ? null : String(value) });
68
+ if (/^(run |report |session )?(date|timestamp|date and time)$/.test(key) || key === 'generated at') return slot(date);
69
+ if (/^driver( name)?$/.test(key)) return slot(facts.driver);
70
+ if (/^(target|target url|build url|game url)$/.test(key)) return slot(facts.target);
71
+ if (/^persona( name| slug)?$/.test(key)) return slot(facts.persona);
72
+ if (/^(n|number of) actions$/.test(key)) return slot(actions === null ? null : `${actions} actions`);
73
+ if (key === 'actions taken') return slot(actions);
74
+ if (/^(quit|stop) reason$/.test(key)) return slot(facts.quitReason);
75
+ if (key === 'game' || key === 'game name') return slot(facts.game);
76
+ if (key === 'version' || key === 'game version') return slot(facts.version);
77
+ return { known: false, value: null };
78
+ }
79
+
80
+ /**
81
+ * The replacement for one bracket match, or null to leave it as written.
82
+ *
83
+ * @param {string} text the prose segment the match is in
84
+ * @param {string} whole the match, brackets included
85
+ * @param {string} inner
86
+ * @param {number} offset
87
+ * @param {string} line the whole line, for the field check
88
+ * @param {Object} facts
89
+ * @returns {string|null}
90
+ */
91
+ function replacementFor(text, whole, inner, offset, line, facts) {
92
+ const before = text[offset - 1];
93
+ const after = text[offset + whole.length];
94
+ // `Array<string>`, `Map<K, V>`: a type, not a slot.
95
+ if (before !== undefined && /\w/.test(before)) return null;
96
+ // The game's own words, quoted as evidence.
97
+ if (before !== undefined && QUOTES[before] !== undefined && QUOTES[before] === after) return null;
98
+ const value = String(inner).trim();
99
+ if (!/^[A-Za-z]/.test(value)) return null;
100
+ // A key or a chord: <Enter>, <Ctrl+A>, <Shift + Tab>.
101
+ if (value.includes('+') || KEY_NAMES.has(value.toLowerCase())) return null;
102
+ const known = knownSlot(value, facts);
103
+ if (known.known) return known.value || NOT_RECORDED;
104
+ if (FIELD_LINE.test(line) && /^[a-z]+(?: [a-z]+)*$/.test(value) && !HTML_TAGS.has(value)) return NOT_RECORDED;
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Walk the prose of a markdown document (not fenced blocks, not code spans),
110
+ * handing each segment and its whole line to `fn`.
111
+ *
112
+ * @param {string} markdown
113
+ * @param {(text: string, line: string) => string} fn
114
+ * @returns {string}
115
+ */
116
+ function mapProse(markdown, fn) {
117
+ let fenced = false;
118
+ return String(markdown == null ? '' : markdown).split('\n').map(line => {
119
+ if (/^\s*(```|~~~)/.test(line)) {
120
+ fenced = !fenced;
121
+ return line;
122
+ }
123
+ if (fenced) return line;
124
+ return line.split(/(`[^`]*`)/).map(part => (part.length > 1 && part.startsWith('`') && part.endsWith('`') ? part : fn(part, line))).join('');
125
+ }).join('\n');
126
+ }
127
+
128
+ /**
129
+ * Fill the slots the runner knows, and the header fields it does not, from
130
+ * its own facts. Everything else in angle brackets is left as written.
131
+ *
132
+ * @param {string} markdown
133
+ * @param {{generatedAt?: string, driver?: string, target?: string, persona?: string,
134
+ * actionsTaken?: number, quitReason?: string, game?: string, version?: string}} [facts]
135
+ * @returns {string}
136
+ */
137
+ export function fillPlaceholders(markdown, facts = {}) {
138
+ return mapProse(markdown, (text, line) => text.replace(ANGLE, (whole, inner, offset) => {
139
+ const replacement = replacementFor(text, whole, inner, offset, line, facts);
140
+ return replacement === null ? whole : replacement;
141
+ }));
142
+ }
143
+
144
+ /**
145
+ * Every slot `fillPlaceholders` would still replace, for tests and a last check.
146
+ *
147
+ * @param {string} markdown
148
+ * @returns {Array<string>}
149
+ */
150
+ export function findPlaceholders(markdown) {
151
+ const found = [];
152
+ mapProse(markdown, (text, line) => {
153
+ for (const match of text.matchAll(ANGLE)) {
154
+ if (replacementFor(text, match[0], match[1], match.index, line, {}) !== null) found.push(match[0]);
155
+ }
156
+ return text;
157
+ });
158
+ return found;
159
+ }
160
+
161
+ /**
162
+ * The same filling over the prose fields of report.json, which the dashboard
163
+ * shows: titles, descriptions, repro steps and quotes.
164
+ *
165
+ * @param {Object} report
166
+ * @param {Object} [facts]
167
+ * @returns {Object}
168
+ */
169
+ export function fillReportPlaceholders(report, facts = {}) {
170
+ if (!report || !Array.isArray(report.findings)) return report;
171
+ const fill = value => (typeof value === 'string' ? fillPlaceholders(value, facts) : value);
172
+ return {
173
+ ...report,
174
+ findings: report.findings.map(finding => {
175
+ if (!finding || typeof finding !== 'object') return finding;
176
+ const out = { ...finding };
177
+ for (const field of ['title', 'description', 'persona_quote']) {
178
+ if (field in out) out[field] = fill(out[field]);
179
+ }
180
+ if (Array.isArray(out.repro_steps)) out.repro_steps = out.repro_steps.map(fill);
181
+ return out;
182
+ })
183
+ };
184
+ }
185
+
186
+ export default { fillPlaceholders, findPlaceholders, fillReportPlaceholders, NOT_RECORDED };
package/src/run/resume.js CHANGED
@@ -3,6 +3,7 @@ import { getDeps } from './deps.js';
3
3
  import EXIT from './exit.js';
4
4
  import { runDir as runDirFor } from './paths.js';
5
5
  import { buildPartialReport, readTranscript, shouldFinalize } from './regenerate.js';
6
+ import { reconcileDroppedFindings } from './dropped.js';
6
7
  import { createReportValidator, writeReportFiles } from './report.js';
7
8
  import * as stateFile from './state.js';
8
9
  import { playJob, resolvePersonas } from '../commands/run.js';
@@ -221,7 +222,7 @@ async function finalizeFromTranscript({ deps, api, gameId, jobId, repoRoot, row,
221
222
  // `interrupted` can go to `reporting`, which is where this run actually is:
222
223
  // it has a report now. Then `uploading`, then the delivery.
223
224
  await api.runs.transition(gameId, jobId, row.run_id, 'reporting', { reason: 'rebuilt from transcript' });
224
- await deps.finalizeRun(directory, {
225
+ const answer = await deps.finalizeRun(directory, {
225
226
  api,
226
227
  gameId,
227
228
  jobId,
@@ -237,6 +238,20 @@ async function finalizeFromTranscript({ deps, api, gameId, jobId, repoRoot, row,
237
238
  report_md: markdown
238
239
  }
239
240
  });
241
+ if (answer && answer.dropped_findings > 0) {
242
+ log(`run ${row.run_id}: the server dropped ${answer.dropped_findings} finding(s) with no usable evidence.`);
243
+ try {
244
+ await reconcileDroppedFindings({
245
+ directory,
246
+ droppedCount: answer.dropped_findings,
247
+ droppedIds: answer.dropped_finding_ids,
248
+ options,
249
+ journal: { jobId, runId: row.run_id, repoRoot }
250
+ });
251
+ } catch (markError) {
252
+ log(`run ${row.run_id}: could not mark the dropped finding(s) in the local report (${String(markError && markError.message)}).`);
253
+ }
254
+ }
240
255
  } catch (error) {
241
256
  log(`run ${row.run_id}: could not be finalised (${String(error && error.message)}); it will be replayed.`);
242
257
  return false;
@@ -154,6 +154,9 @@ export class SyntheticSession {
154
154
  }
155
155
  }
156
156
 
157
+ /** The run summary's line when the page's own SDK sends the run's telemetry. */
158
+ export const PAGE_SDK_REASON = "synthetic telemetry comes from the game's own SDK on the page, sent with the run's playtest token";
159
+
157
160
  /**
158
161
  * Emit the three events a run produces, best effort.
159
162
  *
@@ -161,6 +164,8 @@ export class SyntheticSession {
161
164
  * @param {string} args.apiUrl
162
165
  * @param {string|null} args.ingestKey
163
166
  * @param {string|null} args.playtestToken
167
+ * @param {boolean} [args.pageSdk] the driver injected the playtest token into
168
+ * the page (the web driver), so the game's own SDK sends with it
164
169
  * @param {Object} args.context
165
170
  * @param {Object} args.report the run's report.json
166
171
  * @param {string} args.driver
@@ -168,8 +173,14 @@ export class SyntheticSession {
168
173
  * @param {(message: string) => void} [args.log]
169
174
  * @returns {Promise<{sent: boolean, accepted: number, reason?: string}>}
170
175
  */
171
- export async function emitRunEvents({ apiUrl, ingestKey, playtestToken, context, report, driver, fetchImpl, log }) {
176
+ export async function emitRunEvents({ apiUrl, ingestKey, playtestToken, pageSdk = false, context, report, driver, fetchImpl, log }) {
172
177
  if (!ingestKey) {
178
+ // A web build carries its own SDK, and the web driver injected the run's
179
+ // playtest token into the page, so the game's own events already arrive
180
+ // marked synthetic. Saying "nothing was sent" there would be wrong.
181
+ if (pageSdk && playtestToken) {
182
+ return { sent: false, accepted: 0, pageSdk: true, reason: PAGE_SDK_REASON };
183
+ }
173
184
  return { sent: false, accepted: 0, reason: 'no ingest key configured, so no synthetic telemetry was sent' };
174
185
  }
175
186
  if (!playtestToken) {
@@ -182,6 +193,8 @@ export async function emitRunEvents({ apiUrl, ingestKey, playtestToken, context,
182
193
 
183
194
  session.track('playtest_run_started', { driver });
184
195
  for (const finding of Array.isArray(report && report.findings) ? report.findings : []) {
196
+ // The server kept no record of a dropped finding, so neither does the game's telemetry.
197
+ if (finding && finding.dropped === true) continue;
185
198
  session.track('playtest_finding', {
186
199
  finding_id: finding.id,
187
200
  severity: finding.severity,
package/src/run/tools.js CHANGED
@@ -86,7 +86,7 @@ export const PLAY_TOOLS = Object.freeze([
86
86
  },
87
87
  {
88
88
  name: 'type',
89
- description: 'Type text. Spends an action. Set submit to press Enter afterwards.',
89
+ description: 'Type text. Spends an action. Set submit to press Enter afterwards. To correct or clear a text field, set replace: the field is emptied first and holds only this text afterwards (an empty text clears it). Do that rather than a select-all key chord, which differs between platforms.',
90
90
  input_schema: {
91
91
  type: 'object',
92
92
  additionalProperties: false,
@@ -94,6 +94,8 @@ export const PLAY_TOOLS = Object.freeze([
94
94
  properties: {
95
95
  text: { type: 'string' },
96
96
  submit: { type: 'boolean' },
97
+ replace: { type: 'boolean', description: 'Empty the field first, so it holds only this text. playwright_web only; other drivers refuse it.' },
98
+ target: { type: 'string', description: 'playwright_web only, other drivers refuse it: the field to type into, named like a click target, such as textbox "Email". Without it, the focused field.' },
97
99
  thought_in_character: thought,
98
100
  note
99
101
  }
@@ -101,7 +103,7 @@ export const PLAY_TOOLS = Object.freeze([
101
103
  },
102
104
  {
103
105
  name: 'key',
104
- description: 'Press one key. Spends an action. Names are the usual ones: Enter, Space, ArrowUp, Escape, a, 1.',
106
+ description: 'Press one key. Spends an action. Names are the usual ones: Enter, Space, ArrowUp, Escape, a, 1. Hold a modifier with +, such as Shift+Tab; editing shortcuts such as Control+A are sent with this platform\'s own modifier. To clear a text field, use type with replace instead.',
105
107
  input_schema: {
106
108
  type: 'object',
107
109
  additionalProperties: false,
@@ -187,8 +189,12 @@ export function actionFor(toolUse) {
187
189
  return Number.isFinite(Number(input.x)) && Number.isFinite(Number(input.y))
188
190
  ? { kind: 'click', target: { x: Number(input.x), y: Number(input.y) } }
189
191
  : { kind: 'click', target: String(input.target || '') };
190
- case 'type':
191
- return { kind: 'type', text: String(input.text || ''), submit: Boolean(input.submit) };
192
+ case 'type': {
193
+ const action = { kind: 'type', text: String(input.text || ''), submit: Boolean(input.submit) };
194
+ if (input.replace === true) action.replace = true;
195
+ if (typeof input.target === 'string' && input.target.trim()) action.target = input.target;
196
+ return action;
197
+ }
192
198
  case 'key':
193
199
  return { kind: 'key', key: String(input.key || '') };
194
200
  case 'navigate':
package/src/states.js CHANGED
@@ -90,8 +90,10 @@ export const RUN_TRANSITIONS = Object.freeze({
90
90
  queued: ['launching', 'canceled', 'skipped', 'failed', 'interrupted'],
91
91
  launching: ['playing', 'failed', 'canceled', 'interrupted'],
92
92
  playing: ['reporting', 'failed', 'canceled', 'budget_exceeded', 'interrupted'],
93
- reporting: ['uploading', 'failed', 'interrupted'],
94
- uploading: ['succeeded', 'failed', 'interrupted'],
93
+ // A run out of money in its report step uploads its partial report and
94
+ // ends here; the API allows it from 0.2.7's companion server change.
95
+ reporting: ['uploading', 'failed', 'budget_exceeded', 'interrupted'],
96
+ uploading: ['succeeded', 'failed', 'budget_exceeded', 'interrupted'],
95
97
  interrupted: ['launching', 'playing', 'reporting', 'uploading', 'failed', 'canceled'],
96
98
  succeeded: [],
97
99
  failed: [],