cyborg-hunter 0.5.0 → 0.7.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 (48) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/CITATION.cff +29 -0
  3. package/LICENSE +21 -0
  4. package/README.md +78 -21
  5. package/package.json +10 -3
  6. package/src/cli/analyzers/edge-exit.js +4 -1
  7. package/src/cli/analyzers/phase-scope.js +83 -0
  8. package/src/cli/analyzers/summary.js +161 -28
  9. package/src/cli/analyzers/triage.js +59 -27
  10. package/src/cli/config.js +26 -1
  11. package/src/cli/ingest.js +623 -41
  12. package/src/cli/init.js +1 -1
  13. package/src/cli/renderers/event-log.js +18 -19
  14. package/src/cli/renderers/extensions.js +12 -3
  15. package/src/cli/renderers/html-index.js +163 -24
  16. package/src/cli/renderers/replay-assets.js +177 -0
  17. package/src/cli/renderers/replay-viewer.client.js +1022 -0
  18. package/src/cli/renderers/session-timeline.js +917 -0
  19. package/src/cli/renderers/summary-csv.js +5 -0
  20. package/src/cli/renderers/trajectories.js +68 -7
  21. package/src/cli/renderers/triage-md.js +10 -4
  22. package/src/cli/renderers/typing-profile.js +7 -1
  23. package/src/cli/report.js +42 -8
  24. package/src/core/monitor.js +60 -7
  25. package/src/core/scoring.js +11 -2
  26. package/src/core/signals/browser.js +51 -18
  27. package/src/core/signals/clipboard.js +10 -2
  28. package/src/core/signals/dom-protection.js +9 -0
  29. package/src/core/signals/focus.js +16 -2
  30. package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
  31. package/src/jspsych/extension-cyborg-hunter.js +9 -2
  32. package/src/jspsych/extension-guard-friction.js +32 -12
  33. package/src/jspsych/extension-guard-honeypot.js +25 -1
  34. package/src/replay/capture-dom.js +575 -0
  35. package/src/replay/capture-trace.js +468 -0
  36. package/src/replay/index.js +104 -0
  37. package/src/replay/persistence.js +141 -0
  38. package/src/replay/recorder.js +315 -0
  39. package/src/replay/serializer.js +119 -0
  40. package/src/shared/constants.js +12 -6
  41. package/src/shared/schema.js +5 -0
  42. package/src/shared/validation.js +55 -0
  43. package/dist/cyborg-hunter.esm.js +0 -1527
  44. package/dist/cyborg-hunter.min.js +0 -6
  45. package/dist/extension-cyborg-hunter.js +0 -1
  46. package/dist/extension-guard-friction.js +0 -36
  47. package/dist/extension-guard-honeypot.js +0 -1
  48. package/src/cli/renderers/tab-timeline.js +0 -149
package/src/cli/init.js CHANGED
@@ -16,7 +16,7 @@ export async function runInit() {
16
16
 
17
17
  // Minimal config — just the fields every project needs to set.
18
18
  // filePattern defaults to JSON-or-CSV because jsPsych's `.csv()` save is
19
- // the most common format we see in the wild (rule-gallery uses JSON; most
19
+ // the most common format we see in the wild (Shape-2 producers use JSON; most
20
20
  // jsPsych setups use CSV).
21
21
  const config = {
22
22
  dataDir: './data',
@@ -12,41 +12,40 @@ export async function renderEventLog(participants, config) {
12
12
  const rows = [];
13
13
 
14
14
  for (const p of participants) {
15
+ // Collect this participant's events with their session-absolute timestamp,
16
+ // then sort chronologically before serializing. All event timestamps are on
17
+ // the same performance.now() clock (copy/paste/drop/synthetic use `e.t`,
18
+ // tab-aways use `e.start`), so a single numeric sort interleaves them
19
+ // correctly. Without this the rows came out in loop/category order
20
+ // (all pastes, then all copies, …), contradicting the "chronological" docs.
21
+ const pEvents = [];
15
22
  for (const trial of p.trials) {
16
23
  const trialId = trial.trialId || trial.ruleId || '?';
17
24
 
18
- // Paste events
19
25
  for (const e of (trial.pasteEvents || [])) {
20
- rows.push(formatRow(p.participantId, trialId, 'paste', e.t, '', e.text));
26
+ pEvents.push({ ts: e.t, row: formatRow(p.participantId, trialId, 'paste', e.t, '', e.text) });
21
27
  }
22
-
23
- // Copy events
24
28
  for (const e of (trial.copyEvents || [])) {
25
- rows.push(formatRow(p.participantId, trialId, 'copy', e.t, '', ''));
29
+ pEvents.push({ ts: e.t, row: formatRow(p.participantId, trialId, 'copy', e.t, '', '') });
26
30
  }
27
-
28
- // Drop events
29
31
  for (const e of (trial.dropEvents || [])) {
30
- rows.push(formatRow(p.participantId, trialId, 'drop', e.t, '', e.text));
32
+ pEvents.push({ ts: e.t, row: formatRow(p.participantId, trialId, 'drop', e.t, '', e.text) });
31
33
  }
32
-
33
- // Synthetic insertions (text appeared without keystrokes)
34
34
  for (const e of (trial.syntheticInsertions || [])) {
35
- rows.push(formatRow(p.participantId, trialId, 'synthetic', e.t, '', e.text));
35
+ pEvents.push({ ts: e.t, row: formatRow(p.participantId, trialId, 'synthetic', e.t, '', e.text) });
36
36
  }
37
-
38
- // Tab-away events. timestamp uses the session-absolute `start` (same
39
- // performance.now() clock the copy/paste `t` field uses), so all rows
40
- // in this CSV share one chronological scale. The `text` column carries
41
- // the tab-away type (windowBlur / visibilityChange / etc.) so analysts
42
- // can filter by trigger.
37
+ // Tab-away timestamp uses the session-absolute `start` (the `text` column
38
+ // carries the trigger type — windowBlur / visibilityChange / etc. — so
39
+ // analysts can filter by trigger).
43
40
  for (const e of (trial.tabAwayEvents || [])) {
44
- rows.push(formatRow(p.participantId, trialId, 'tabAway', e.start, e.duration_ms, e.type || ''));
41
+ pEvents.push({ ts: e.start, row: formatRow(p.participantId, trialId, 'tabAway', e.start, e.duration_ms, e.type || '') });
45
42
  }
46
43
  }
44
+ // Stable chronological sort; events with no usable timestamp sort last.
45
+ pEvents.sort((a, b) => (a.ts ?? Infinity) - (b.ts ?? Infinity));
46
+ for (const e of pEvents) rows.push(e.row);
47
47
  }
48
48
 
49
- // Sort chronologically within each participant
50
49
  const csv = [header, ...rows].join('\n') + '\n';
51
50
  const outPath = join(config.outputDir, 'event-log.csv');
52
51
  writeFileSync(outPath, csv);
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { writeFileSync } from 'fs';
7
7
  import { join } from 'path';
8
+ import { countSidebarOpenings } from '../analyzers/summary.js';
8
9
 
9
10
  export async function renderExtensions(participants, config) {
10
11
  const header = 'participantId,detectionType,name,details';
@@ -20,9 +21,17 @@ export async function renderExtensions(participants, config) {
20
21
  rows.push(`${pid},extension,${escapeCSV(name)},`);
21
22
  }
22
23
 
23
- // Sidebar detection
24
- const hasSidebar = p.trials.some(t => (t.sidebarGapPx || 0) > 0);
25
- if (hasSidebar) {
24
+ // Sidebar detection. Prefer the current library's session-level
25
+ // sidebarEvents (the monitor records sidebars session-scoped, not per-trial);
26
+ // fall back to the legacy per-trial sidebarGapPx only when no session events
27
+ // exist (pre-session / Shape-2 legacy data). countSidebarOpenings() counts
28
+ // distinct openings (collapsing the paired open/close records and the
29
+ // innerWidth_delta + layout_compression double-detection), matching the
30
+ // summary/triage count.
31
+ const sidebarOpens = countSidebarOpenings(p.session?.sidebarEvents);
32
+ if (sidebarOpens > 0) {
33
+ rows.push(`${pid},sidebar,browser_sidebar,${sidebarOpens} open event${sidebarOpens === 1 ? '' : 's'}`);
34
+ } else if (p.trials.some(t => (t.sidebarGapPx || 0) > 0)) {
26
35
  const maxGap = Math.max(...p.trials.map(t => t.sidebarGapPx || 0));
27
36
  rows.push(`${pid},sidebar,browser_sidebar,${maxGap}px gap`);
28
37
  }
@@ -11,14 +11,22 @@
11
11
  //
12
12
  // References linked images in images/ (not base64-embedded). Works offline.
13
13
 
14
- import { writeFileSync } from 'fs';
14
+ import { writeFileSync, readFileSync } from 'fs';
15
15
  import { join } from 'path';
16
- import { VERSION } from '../../shared/constants.js';
16
+ import { VERSION, sanitizeId } from '../../shared/constants.js';
17
17
  import { decomposeScore } from '../analyzers/triage.js';
18
+ import { getByPath } from '../ingest.js';
19
+
20
+ // The replay viewer client is developed as a real JS file (linted, syntax-
21
+ // highlighted) and embedded verbatim at render time — same file://-safe
22
+ // output as the inline IIFE, without string-blob development pain.
23
+ const REPLAY_CLIENT_SRC = readFileSync(
24
+ new URL('./replay-viewer.client.js', import.meta.url), 'utf8');
18
25
 
19
26
  export async function renderHtmlIndex(summaries, triage, participants, config, visualsRendered) {
20
27
  // Cohort counts for filter chips and totals footer. The triage array is
21
- // already sorted descending by score by triage.js — we don't re-sort here.
28
+ // already sorted tier-first (hard → soft → clean, score-desc within tier) by
29
+ // triage.js — we don't re-sort here; the default "Tier" sort matches it.
22
30
  const hard = triage.filter(t => t.hardTriggered).length;
23
31
  const soft = triage.filter(t => !t.hardTriggered && t.softFlagged).length;
24
32
  const clean = triage.length - hard - soft;
@@ -33,7 +41,7 @@ export async function renderHtmlIndex(summaries, triage, participants, config, v
33
41
  // exercised by the second smoke test.
34
42
  const detailHtml = triage.map((t, i) => {
35
43
  const participant = participants.find(p => p.participantId === t.participantId);
36
- return renderDetail(t, participant, visualsRendered, /* defaultVisible */ i === 0);
44
+ return renderDetail(t, participant, config, visualsRendered, /* defaultVisible */ i === 0);
37
45
  }).join('\n');
38
46
 
39
47
  const html = `<!DOCTYPE html>
@@ -420,12 +428,12 @@ export async function renderHtmlIndex(summaries, triage, participants, config, v
420
428
  <tr><td>copy</td> <td>Clipboard copy from the page (soft signal).</td></tr>
421
429
  <tr><td>drop</td> <td>Drag-and-drop into the response field (hard signal).</td></tr>
422
430
  <tr><td>tab-away &ge;10s</td> <td>Left the page for &ge;10 seconds; counts toward soft score.</td></tr>
423
- <tr><td>tab-away 3&ndash;10s</td><td>Left the page briefly; counts toward soft score.</td></tr>
424
- <tr><td>flicker &lt;3s</td> <td>Very brief blur; reported but not scored.</td></tr>
431
+ <tr><td>tab-away (mid)</td> <td>Longer than the participant's tab-away threshold (3s by default, 5s strict) and under 10s; counts toward soft score.</td></tr>
432
+ <tr><td>flicker</td> <td>At or below the tab-away threshold (3s by default); reported but not scored.</td></tr>
425
433
  <tr><td>sidebar event</td> <td>Viewport width shrank suddenly &mdash; likely AI sidebar opened.</td></tr>
426
434
  <tr><td>AI extension</td> <td>Browser extension known for AI assistance detected.</td></tr>
427
435
  <tr><td>kb shortcut</td> <td>DevTools-adjacent keyboard shortcut used.</td></tr>
428
- <tr><td>layout shift</td> <td>Viewport layout changed substantially.</td></tr>
436
+ <tr><td>viewport shift</td> <td>Viewport width changed substantially (recorded as "layout shift" before 0.6.1).</td></tr>
429
437
  <tr><td>zoom change</td> <td>Browser zoom level changed.</td></tr>
430
438
  <tr><td>synthetic insertion</td><td>Text appeared without matching keystrokes.</td></tr>
431
439
  <tr><td>foreign input</td> <td>Keystroke outside the response field.</td></tr>
@@ -665,6 +673,79 @@ export async function renderHtmlIndex(summaries, triage, participants, config, v
665
673
  });
666
674
  })();
667
675
  </script>
676
+ <style>
677
+ /* Replay viewer (see replay-viewer.client.js) — matches report house style */
678
+ .replay-header { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 8px 0; }
679
+ .replay-badge { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px;
680
+ background: var(--ink); color: var(--surface); }
681
+ .replay-badge[data-tier="trace"] { background: var(--surface); color: var(--ink);
682
+ border: 1px solid var(--line); }
683
+ .replay-stage { position: relative; overflow: hidden; background: var(--surface);
684
+ border: 1px solid var(--line); border-radius: 4px; }
685
+ .replay-frame { position: absolute; top: 0; left: 0; border: 0; }
686
+ .replay-overlay { position: absolute; top: 0; left: 0; pointer-events: none; }
687
+ .replay-neutral { background: #e8e6e0; }
688
+ .replay-neutral-label { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
689
+ color: var(--dim); font-size: 13px; }
690
+ .replay-lane { display: block; margin-top: 6px; border-radius: 2px; }
691
+ .replay-scrub { display: block; margin: 2px 0 4px; }
692
+ .replay-ticker { font: 12px/1.3 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
693
+ color: var(--dim); font-variant-numeric: tabular-nums;
694
+ height: 1.4em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
695
+ .replay-note { font-size: 12px; color: var(--dim); }
696
+ .replay-warn { color: var(--hard); }
697
+ /* Controls share the report's flat, line-bordered button style */
698
+ .replay-play, .replay-load-btn, .replay-css-btn, .replay-trial-select, .replay-speed {
699
+ padding: 4px 10px; border: 1px solid var(--line); background: var(--surface);
700
+ color: var(--ink); border-radius: 4px; cursor: pointer; font-size: 13px; }
701
+ .replay-play:hover, .replay-load-btn:hover, .replay-css-btn:hover { background: var(--bg); }
702
+ .replay-play:disabled, .replay-load-btn:disabled { opacity: 0.6; cursor: default; }
703
+ .replay-css-btn { font-size: 12px; padding: 2px 8px; }
704
+ .replay-clock { font: 12px/1.3 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
705
+ font-variant-numeric: tabular-nums; }
706
+ </style>
707
+ <script>
708
+ ${REPLAY_CLIENT_SRC}
709
+ </script>
710
+ <script>
711
+ // Lazy loader: replay models are heavy (dom tier ≈ MBs), so each
712
+ // participant's model script loads only when the analyst asks for it.
713
+ // Script-tag injection works under file:// where fetch() is blocked.
714
+ (function () {
715
+ document.addEventListener('click', function (e) {
716
+ const btn = e.target.closest('.replay-load-btn');
717
+ if (!btn) return;
718
+ const block = btn.closest('.replay-block');
719
+ const src = block.dataset.replaySrc;
720
+ const pid = block.dataset.pid;
721
+ const mount = block.querySelector('.replay-mount');
722
+ btn.disabled = true;
723
+ btn.textContent = 'Loading…';
724
+ mount.setAttribute('aria-busy', 'true');
725
+ const fail = function (msg) {
726
+ mount.removeAttribute('aria-busy');
727
+ const p = document.createElement('p');
728
+ p.className = 'replay-note replay-warn';
729
+ p.textContent = msg;
730
+ mount.textContent = '';
731
+ mount.appendChild(p);
732
+ };
733
+ const s = document.createElement('script');
734
+ s.src = src;
735
+ s.onload = function () {
736
+ mount.removeAttribute('aria-busy');
737
+ const model = (window.__chReplay || {})[pid];
738
+ if (!model) { fail('Replay data failed to load (' + src + ').'); return; }
739
+ mount.textContent = '';
740
+ window.initChReplayViewer(mount, model);
741
+ };
742
+ s.onerror = function () {
743
+ fail('Replay asset missing (' + src + ') — was the report generated with the replay artifacts present?');
744
+ };
745
+ document.body.appendChild(s);
746
+ });
747
+ })();
748
+ </script>
668
749
  </body>
669
750
  </html>`;
670
751
 
@@ -692,17 +773,17 @@ const SIGNALS = [
692
773
  { key: 'totalCopyEvents', label: 'Copy', tone: 'warn', hint: 'Clipboard copy events from the task' },
693
774
  { key: 'totalDropEvents', label: 'Drop', tone: 'critical', hint: 'Drag-and-drop into inputs' },
694
775
  { key: 'tabAwayLongCount', label: 'Tab-away ≥10s', tone: 'critical', hint: 'Window blurred for 10+ seconds' },
695
- { key: 'tabAwayMediumCount', label: 'Tab-away 3–10s', tone: 'warn', hint: 'Short tab-aways — often a peek' },
696
- { key: 'tabAwayFlickerCount', label: 'Flicker <3s', tone: 'muted', hint: 'Very short visibility flickers' },
776
+ { key: 'tabAwayMediumCount', label: 'Tab-away (mid)', tone: 'warn', hint: 'Above the tab-away threshold (3s default, 5s strict), under 10s' },
777
+ { key: 'tabAwayFlickerCount', label: 'Flicker', tone: 'muted', hint: 'At or below the tab-away threshold (3s default) — not scored' },
697
778
  { key: 'trialsWithFastTyping', label: 'Fast typing', tone: 'warn', hint: 'Trials typed faster than the preset cps threshold' },
698
779
  { key: 'totalSyntheticInsertions', label: 'Synthetic', tone: 'warn', hint: 'Text appeared without preceding keystrokes' },
699
780
  { key: 'totalForeignInputEvents', label: 'Foreign input', tone: 'warn', hint: 'Typing landed outside the experiment container' },
700
781
  { key: 'sidebarEventCount', label: 'Sidebar', tone: 'warn', hint: 'innerWidth shrank — side panel opened' },
701
782
  { key: 'aiExtensionsCount', label: 'AI extensions', tone: 'critical', hint: 'Known AI extension selectors found in DOM' },
702
- { key: 'keyboardShortcutCount', label: 'Kb shortcuts', tone: 'warn', hint: 'Cmd/Ctrl-C/V / AI-specific keys' },
703
- { key: 'layoutShiftCount', label: 'Layout shifts', tone: 'muted', hint: 'Inferred layout compression events' },
783
+ { key: 'keyboardShortcutCount', label: 'Kb shortcuts', tone: 'warn', hint: 'DevTools hotkeys: Ctrl/Cmd+Shift+I/J/C, F12' },
784
+ { key: 'layoutShiftCount', label: 'Viewport shifts', tone: 'muted', hint: 'Viewport-width change events (recorded as layoutShifts before 0.6.1)' },
704
785
  { key: 'zoomChangeCount', label: 'Zoom changes', tone: 'muted', hint: 'Browser zoom level changed during task' },
705
- { key: 'devToolsEventCount', label: 'DevTools', tone: 'muted', hint: 'DevTools opened or related shortcut' },
786
+ { key: 'devToolsEventCount', label: 'DevTools', tone: 'muted', hint: 'Reserved (always 0) — DevTools opens are counted under Kb shortcuts' },
706
787
  { key: 'edgeExitCount', label: 'Edge exits', tone: 'muted', hint: 'Mouse exited window through a screen edge' }
707
788
  ];
708
789
 
@@ -753,9 +834,9 @@ function renderCohortList(triage, cohortCounts) {
753
834
  <div class="sort-wrap">
754
835
  <label>Sort:
755
836
  <select name="sort">
837
+ <option value="tier" selected>Tier (hard first)</option>
756
838
  <option value="score">Score &darr;</option>
757
839
  <option value="id">ID</option>
758
- <option value="tier">Tier</option>
759
840
  </select>
760
841
  </label>
761
842
  </div>
@@ -824,13 +905,14 @@ function sanitize(name) {
824
905
  // signals, paste evidence, and images. The `hidden` attribute is omitted on the
825
906
  // first pane so the report has a default selection on load; client JS toggles
826
907
  // `hidden` on the others when the user clicks a different cohort row.
827
- function renderDetail(t, participant, visualsRendered, defaultVisible) {
908
+ function renderDetail(t, participant, config, visualsRendered, defaultVisible) {
828
909
  const sanitized = sanitize(t.participantId);
829
910
  const tier = tierOf(t);
830
911
  const s = t.summary || {};
831
912
 
832
- // Image sections — three .image-block wrappers (tab timeline, typing profile,
833
- // mouse trajectories). The renderers in tab-timeline.js / typing-profile.js /
913
+ // Image sections — three .image-block wrappers (session timeline, typing
914
+ // profile, mouse trajectories). The renderers in session-timeline.js /
915
+ // typing-profile.js /
834
916
  // trajectories.js skip participants under various conditions, so not every
835
917
  // participant has every image. The wrapper exists so onerror can hide both
836
918
  // the heading and the image together — without it, a missing PNG would leave
@@ -849,9 +931,9 @@ function renderDetail(t, participant, visualsRendered, defaultVisible) {
849
931
  </a>
850
932
  </div>
851
933
  <div class="image-block">
852
- <h4 class="section-heading">Tab timeline</h4>
853
- <a href="images/tab_timeline_${sanitized}.png" class="zoomable">
854
- <img src="images/tab_timeline_${sanitized}.png" alt="Tab timeline"
934
+ <h4 class="section-heading">Session timeline</h4>
935
+ <a href="images/session_timeline_${sanitized}.png" class="zoomable">
936
+ <img src="images/session_timeline_${sanitized}.png" alt="Session timeline"
855
937
  onerror="this.closest('.image-block').style.display='none'">
856
938
  </a>
857
939
  </div>
@@ -867,16 +949,59 @@ function renderDetail(t, participant, visualsRendered, defaultVisible) {
867
949
  }
868
950
 
869
951
  return `<section class="participant" id="p-${sanitized}"${defaultVisible ? '' : ' hidden'}>
870
- ${renderDetailHeader(t, tier)}
952
+ ${renderDetailHeader(t, tier, participant, config)}
871
953
  ${renderSignalGrid(s, t)}
872
954
  ${renderScoreBreakdown(t)}
873
955
  ${renderReasonQuote(t)}
874
956
  ${renderSessionBlock(s, participant)}
875
957
  ${renderPasteEvidence(participant)}
876
958
  ${imagesHtml}
959
+ ${renderReplaySection(participant, sanitized)}
877
960
  </section>`;
878
961
  }
879
962
 
963
+ // Renders the per-participant "Session replay" section in one of three
964
+ // states: loadable (artifact attached), corrupted, or absent (with the
965
+ // saved_to reason from integrityReplayMeta when one exists, so the analyst
966
+ // can tell "never recorded" from "went to the participant's Downloads").
967
+ function renderReplaySection(participant, sanitized) {
968
+ const replay = participant?.replay;
969
+ if (replay && replay.recording) {
970
+ const tier = replay.recording.metadata?.tier || 'trace';
971
+ // assetPath is stamped by replay-assets.js (collision-deduped filename)
972
+ // and must be preferred — recomputing from the sanitized pid here would
973
+ // resurrect the lossy-name collision the assets renderer just resolved.
974
+ // Fallback uses the SHARED sanitizer (persistence/ingest/assets),
975
+ // not this file's image-oriented sanitize (which truncates + strips
976
+ // dots and would miss the asset filename for long/dotted pids).
977
+ const assetPath = replay.assetPath || `replay/${sanitizeId(participant.participantId)}.replay.js`;
978
+ return `<div class="image-block replay-block" data-pid="${esc(participant.participantId)}"
979
+ data-replay-src="${esc(assetPath)}">
980
+ <h4 class="section-heading">Session replay <span class="replay-note">(${esc(tier)} tier)</span></h4>
981
+ <div class="replay-mount">
982
+ <button class="replay-load-btn" type="button">Load replay</button>
983
+ </div>
984
+ </div>`;
985
+ }
986
+ if (replay && replay.error) {
987
+ return `<div class="image-block replay-block">
988
+ <h4 class="section-heading">Session replay</h4>
989
+ <p class="replay-note replay-warn">Replay artifact corrupted (${esc(replay.reason || replay.error)}) — file: ${esc(replay.file || 'unknown')}</p>
990
+ </div>`;
991
+ }
992
+ const meta = participant?.trials?.[0]?.integrityReplayMeta;
993
+ if (meta) {
994
+ return `<div class="image-block replay-block">
995
+ <h4 class="section-heading">Session replay</h4>
996
+ <p class="replay-note">No replay artifact on disk. The session reported saved_to: <strong>${esc(String(meta.saved_to))}</strong>${meta.saved_to === 'download' ? ' — the file went to the participant’s machine and is not recoverable' : ''}.</p>
997
+ </div>`;
998
+ }
999
+ return `<div class="image-block replay-block">
1000
+ <h4 class="section-heading">Session replay</h4>
1001
+ <p class="replay-note">No replay artifact (recording was not enabled for this session).</p>
1002
+ </div>`;
1003
+ }
1004
+
880
1005
  // Renders the participant's screenout reason as a left-bordered pull-quote.
881
1006
  // `t.reason` is set by triage.js; falls back to "clean" when absent. Always
882
1007
  // renders a quote (even for clean participants) so the visual rhythm is consistent.
@@ -955,12 +1080,26 @@ function formatSidebar(ev) {
955
1080
  }
956
1081
 
957
1082
  // Header strip: full participant id (mono), tier pill, big score number on the
958
- // top row; "{n} trials · {cps} cps · {off-task}" subline below.
959
- function renderDetailHeader(t, tier) {
1083
+ // top row; "{n} trials · {cps} cps · {off-task}" subline below. When BOTH
1084
+ // config.platformIdField is mapped AND config.showPlatformId is true, a
1085
+ // second subline shows the platform (Prolific/MTurk) ID — OFF by default
1086
+ // because reports circulate among collaborators more freely than raw data,
1087
+ // and the platform ID is the piece that re-identifies a participant.
1088
+ function renderDetailHeader(t, tier, participant, config) {
960
1089
  const s = t.summary || {};
961
1090
  const cps = (s.meanTypingSpeed || 0).toFixed(1);
962
1091
  const offTask = formatDuration(s.totalTabAwayDuration_ms || 0);
963
1092
  const tierLabel = tier === 'clean' ? 'clean' : tier.toUpperCase();
1093
+ let platformLine = '';
1094
+ if (config?.showPlatformId && config?.platformIdField) {
1095
+ const value = getByPath(participant?.metadata, config.platformIdField)
1096
+ ?? getByPath(participant, config.platformIdField)
1097
+ ?? getByPath(participant?.trials?.[0], config.platformIdField);
1098
+ if (value != null && value !== '') {
1099
+ platformLine = `
1100
+ <div class="detail-header-sub mono">platform ID: ${esc(String(value))}</div>`;
1101
+ }
1102
+ }
964
1103
  return `<div class="detail-header">
965
1104
  <div class="detail-header-top">
966
1105
  <span class="mono pid-full">${esc(t.participantId)}</span>
@@ -969,7 +1108,7 @@ function renderDetailHeader(t, tier) {
969
1108
  </div>
970
1109
  <div class="detail-header-sub">
971
1110
  ${s.trialCount ?? 0} trials · ${cps} cps · ${offTask} off-task
972
- </div>
1111
+ </div>${platformLine}
973
1112
  </div>`;
974
1113
  }
975
1114
 
@@ -1012,7 +1151,7 @@ function formatDuration(ms) {
1012
1151
  // Task 11's client JS will wire up to swap the preview for the full text.
1013
1152
  function renderPasteEvidence(participant) {
1014
1153
  // Flatten across trials, keeping [trialId] context for each paste. Match the
1015
- // event-log.csv convention: prefer trialId, fall back to ruleId (rule-gallery
1154
+ // event-log.csv convention: prefer trialId, fall back to ruleId (Shape-2
1016
1155
  // legacy data), then to '?' as a last resort. Paste text is coerced to string
1017
1156
  // because some malformed sources have non-string values.
1018
1157
  const pastes = [];
@@ -0,0 +1,177 @@
1
+ // src/cli/renderers/replay-assets.js
2
+ // Emits per-participant replay assets into <outputDir>/replay/ as
3
+ // JSONP-style scripts: window.__chReplay['<pid>'] = <viewer model>.
4
+ //
5
+ // Why script files instead of JSON + fetch(): the HTML report is opened
6
+ // via file:// where fetch() of local files is CORS-blocked, but <script>
7
+ // tags load fine. The report injects the script lazily when the analyst
8
+ // opens a participant's Replay section.
9
+ //
10
+ // This module is also the wire→viewer time conversion (the second of the
11
+ // two allowed conversion points): SessionRecording carries ms since
12
+ // session start; the viewer speaks trial-relative ms.
13
+
14
+ import { writeFileSync, mkdirSync } from 'fs';
15
+ import { join } from 'path';
16
+
17
+ import { sanitizeId as sanitize } from '../../shared/constants.js';
18
+
19
+ /**
20
+ * Wire SessionRecording → viewer model. Times become trial-relative
21
+ * (t − t_load); null anchors (standalone implicit trials) degrade to the
22
+ * first event's time so durations are always finite.
23
+ */
24
+ export function buildViewerModel(recording) {
25
+ const md = recording.metadata || {};
26
+ const ext = recording.ch_extensions || {};
27
+
28
+ // ── Camera seeding (central, per Sol round-1 finding 8) ──
29
+ // New recordings carry a per-trial view_state seed. Legacy recordings
30
+ // don't — but the full event stream is present, so each trial's starting
31
+ // camera is reconstructed by folding all PRIOR trials' window-scroll and
32
+ // resize events over the session-start viewport. Initial scroll is assumed
33
+ // 0 (a recording that starts pre-scrolled with no scroll events is
34
+ // unrecoverable — that's what the viewer's legacy banner covers).
35
+ const vp = recording.viewport || {};
36
+ const vv = vp.visual_viewport || {};
37
+ // Scrollbar delta: legacy resize events carry only innerWidth/Height; the
38
+ // layout (client) width is estimated as w minus the session-start delta
39
+ // between innerWidth and the layout width (visual_viewport.width).
40
+ const sbW = (vp.width && (vp.client_width || vv.width))
41
+ ? vp.width - (vp.client_width || vv.width) : 0;
42
+ const sbH = (vp.height && (vp.client_height || vv.height))
43
+ ? vp.height - (vp.client_height || vv.height) : 0;
44
+ let foldState = {
45
+ x: 0, y: 0,
46
+ w: vp.width || null, h: vp.height || null,
47
+ cw: vp.client_width || vv.width || vp.width || null,
48
+ ch: vp.client_height || vv.height || vp.height || null,
49
+ dpr: vp.dpr || 1
50
+ };
51
+ const foldEvent = (state, e) => {
52
+ if (e.kind === 'scroll' && e.el == null && e.redacted == null) {
53
+ state.x = Number(e.x) || 0;
54
+ state.y = Number(e.y) || 0;
55
+ } else if (e.kind === 'resize') {
56
+ state.w = e.w != null ? e.w : state.w;
57
+ state.h = e.h != null ? e.h : state.h;
58
+ state.cw = e.cw != null ? e.cw : (e.w != null ? e.w - sbW : state.cw);
59
+ state.ch = e.ch != null ? e.ch : (e.h != null ? e.h - sbH : state.ch);
60
+ if (e.dpr != null) state.dpr = e.dpr;
61
+ }
62
+ return state;
63
+ };
64
+
65
+ // Defensive against malformed/truncated artifacts (a hand-edited or
66
+ // partially-written recording): non-array trials/events and null entries must
67
+ // degrade to empty rather than throw and abort the whole cohort report.
68
+ const rawTrials = Array.isArray(recording.trials) ? recording.trials : [];
69
+ const trials = rawTrials.map((trial) => {
70
+ trial = trial || {};
71
+ // Sort by absolute time before anchoring. RAF-coalesced input events flush
72
+ // with an EARLIER timestamp than events pushed after they were enqueued, so
73
+ // the recorded array is not strictly time-ordered; the viewer scrubs by
74
+ // scanning until the first future event and would otherwise mis-apply an
75
+ // out-of-order event on a seek. Stable sort keeps equal-time order.
76
+ const events = (Array.isArray(trial.events) ? trial.events : [])
77
+ .filter((e) => e && typeof e === 'object')
78
+ .slice()
79
+ .sort((a, b) => (Number(a.t) || 0) - (Number(b.t) || 0));
80
+ const anchor = trial.t_load != null ? trial.t_load
81
+ : (events.length > 0 ? events[0].t : 0);
82
+ const lastT = events.length > 0 ? events[events.length - 1].t : anchor;
83
+ const end = trial.t_end != null ? trial.t_end : lastT;
84
+ // Camera seed for THIS trial: recorded view_state, else the folded state
85
+ // as of the end of the previous trial. A recorded view_state also
86
+ // RESYNCS the fold — in a mixed recording (some trials seeded, some
87
+ // not: truncation, version mixes) a later unseeded trial must inherit
88
+ // real observed state, not a fold that ignored every observation.
89
+ if (trial.view_state) foldState = Object.assign({}, foldState, trial.view_state);
90
+ const camera = trial.view_state
91
+ ? Object.assign({}, trial.view_state, { source: 'view_state' })
92
+ : Object.assign({}, foldState, { source: 'folded' });
93
+ // Advance the fold across this trial's events for the NEXT trial's seed.
94
+ events.forEach((e) => foldEvent(foldState, e));
95
+ return {
96
+ index: trial.trial_index,
97
+ id: trial.trial_id,
98
+ plugin: trial.plugin,
99
+ durMs: Math.max(0, Math.round((end - anchor) * 10) / 10),
100
+ initialDom: trial.initial_dom || '',
101
+ camera,
102
+ events: events.map((e) => {
103
+ const out = Object.assign({}, e);
104
+ out.t = Math.max(0, Math.round(((Number(e.t) || 0) - anchor) * 10) / 10);
105
+ return out;
106
+ })
107
+ };
108
+ });
109
+
110
+ return {
111
+ pid: md.participant_id != null ? String(md.participant_id) : 'unknown',
112
+ tier: md.tier || 'trace',
113
+ keys: md.keys || null,
114
+ startTime: md.start_time || null,
115
+ endReason: md.end_reason || null,
116
+ recorder: md.recorder || null,
117
+ viewport: recording.viewport || null,
118
+ // Legacy = ANY trial lacks a view_state seed (all-legacy recordings and
119
+ // mixed/truncated ones alike): those trials replay on folded camera
120
+ // state, so the reduced-guarantees banner must show.
121
+ legacy: !rawTrials.every((t) => t && t.view_state),
122
+ markerAttr: ext.marker_attr || null,
123
+ // Session scrollbar delta (innerWidth − layout width): the viewer uses
124
+ // the same fallback chain as the folding above for legacy resize events.
125
+ scrollbar: { w: sbW, h: sbH },
126
+ stylesheets: (recording.stylesheets && recording.stylesheets.initial) || [],
127
+ scoring: ext.scoring || null,
128
+ guardViolations: ext.guard_violations || [],
129
+ captureStopped: !!ext.capture_stopped,
130
+ captureFailures: (ext.capture_failures || []).map((f) => f.channel),
131
+ trials
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Writes replay/<sanitizedPid>.replay.js for every participant with an
137
+ * attached recording. Returns { count, totalBytes } so report.js can print
138
+ * an honest size line (replay assets dominate report size at dom tier).
139
+ */
140
+ export function renderReplayAssets(participants, outputDir) {
141
+ let count = 0;
142
+ let totalBytes = 0;
143
+ const withReplay = participants.filter((p) => p.replay && p.replay.recording);
144
+ if (withReplay.length === 0) return { count, totalBytes };
145
+
146
+ const replayDir = join(outputDir, 'replay');
147
+ mkdirSync(replayDir, { recursive: true });
148
+
149
+ // Sanitization is lossy ('a/b' and 'a_b' both map to a_b) — dedupe with a
150
+ // stable numeric suffix so a later write can never overwrite an earlier
151
+ // participant's asset. The actual path is stamped on the participant
152
+ // (replay.assetPath) and consumed by html-index, which must not recompute.
153
+ const usedNames = new Set();
154
+ for (const p of withReplay) {
155
+ const model = buildViewerModel(p.replay.recording);
156
+ // The store is keyed by the RAW participant id (what the report's
157
+ // loader passes); the filename uses the sanitized form.
158
+ // Null-prototype store: participant ids are untrusted, and a pid like
159
+ // "__proto__" on a plain object would mutate the store's prototype
160
+ // instead of creating an entry (prototype pollution).
161
+ const src =
162
+ 'window.__chReplay = window.__chReplay || Object.create(null);\n' +
163
+ 'window.__chReplay[' + JSON.stringify(String(p.participantId)) + '] = ' +
164
+ JSON.stringify(model).replace(/</g, '\\u003c') + ';\n';
165
+ let base = sanitize(p.participantId);
166
+ let name = base + '.replay.js';
167
+ for (let n = 2; usedNames.has(name.toLowerCase()); n++) {
168
+ name = base + '~' + n + '.replay.js';
169
+ }
170
+ usedNames.add(name.toLowerCase());
171
+ writeFileSync(join(replayDir, name), src);
172
+ p.replay.assetPath = 'replay/' + name;
173
+ count++;
174
+ totalBytes += Buffer.byteLength(src);
175
+ }
176
+ return { count, totalBytes };
177
+ }