cyborg-hunter 0.7.4 → 0.8.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 (39) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/CITATION.cff +2 -2
  3. package/README.md +21 -6
  4. package/dist/cyborg-hunter-replay.js +3 -3
  5. package/dist/cyborg-hunter.esm.js +3 -2
  6. package/dist/cyborg-hunter.min.js +3 -3
  7. package/dist/extension-cyborg-hunter.js +1 -1
  8. package/dist/extension-guard-friction.js +5 -5
  9. package/package.json +10 -2
  10. package/src/cli/ingest.js +311 -57
  11. package/src/cli/renderers/html-index-core.js +66 -12
  12. package/src/cli/renderers/html-index.js +7 -5
  13. package/src/cli/renderers/replay-assets.js +61 -7
  14. package/src/cli/renderers/replay-client-source.js +102 -0
  15. package/src/cli/renderers/replay-viewer.client.js +1750 -595
  16. package/src/cli/report.js +6 -0
  17. package/src/core/monitor.js +12 -13
  18. package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
  19. package/src/jspsych/extension-cyborg-hunter.js +1 -1
  20. package/src/jspsych/extension-guard-friction.js +59 -1
  21. package/src/replay/capture-dom.js +470 -458
  22. package/src/replay/capture-trace.js +688 -271
  23. package/src/replay/delivery.js +82 -0
  24. package/src/replay/dom-instantiate.js +779 -0
  25. package/src/replay/index.js +88 -5
  26. package/src/replay/initial-state.js +295 -0
  27. package/src/replay/mutations.js +668 -0
  28. package/src/replay/node-registry.js +116 -0
  29. package/src/replay/persistence.js +19 -6
  30. package/src/replay/recorder.js +342 -73
  31. package/src/replay/redaction.js +165 -0
  32. package/src/replay/serializer.js +148 -43
  33. package/src/replay/snapshot.js +409 -0
  34. package/src/replay/span.js +55 -0
  35. package/src/replay/viewer-model.js +293 -102
  36. package/src/shared/constants.js +1 -1
  37. package/src/shared/inline-safe.js +80 -0
  38. package/src/shared/schema-v2-validator.js +595 -0
  39. package/tools/convert/jspsych-v1-to-v2.mjs +432 -0
package/src/cli/report.js CHANGED
@@ -150,6 +150,12 @@ export async function run(args) {
150
150
  if (replayAssets.count > 0) {
151
151
  console.log(` replay/ — ${replayAssets.count} session replays (${(replayAssets.totalBytes / 1024 / 1024).toFixed(1)} MB)`);
152
152
  }
153
+ // A skipped artifact is already visible in the report (the participant's
154
+ // replay section says why), but an analyst watching the CLI must not have
155
+ // to open the HTML to learn a recording did not make it.
156
+ for (const s of replayAssets.skipped) {
157
+ console.log(` replay/ — skipped ${s.participantId}: ${s.reason}`);
158
+ }
153
159
 
154
160
  // HTML index page — references images/ folder (not base64-embedded)
155
161
  const { renderHtmlIndex } = await import('./renderers/html-index.js');
@@ -64,7 +64,7 @@ export function init(userConfig) {
64
64
  }, userConfig.domProtection || {}),
65
65
  collectForPostHoc: Object.assign({
66
66
  fullKeystrokeTimestamps: false,
67
- rawMouseTrack: false,
67
+ rawMouseTrack: true, // see the privacy note at endTrial (flipped on 2026-09-02)
68
68
  responseText: false,
69
69
  windowPositionLog: true,
70
70
  elementTrace: false,
@@ -378,18 +378,17 @@ export function init(userConfig) {
378
378
  report.mouseMetrics = mouseMetrics;
379
379
  }
380
380
 
381
- // Privacy gate: the raw per-sample mouse coordinates are a much
382
- // higher-resolution behavioral trace than the derived mouseMetrics
383
- // above. Persist them ONLY when explicitly enabled
384
- // (collectForPostHoc.rawMouseTrack) — under the `mouseTrack` name,
385
- // which is what extract-core's field map already expects (mouseTrack →
386
- // mouseEvents), so a saved report round-trips through
387
- // extractIntegrityData() without new glue. Otherwise drop the raw
388
- // report.mouseEvents entirely — the mouseMetrics signal above still
389
- // works, but the {x,y,t,type} samples never leave the browser. Before
390
- // this gate, deepCopy(trialData) put the full raw track in every
391
- // report regardless of the documented off-by-default rawMouseTrack
392
- // toggle.
381
+ // Raw per-sample mouse coordinates persist under `mouseTrack` (the
382
+ // name extract-core's field map expects: mouseTrack → mouseEvents, so a
383
+ // saved report round-trips through extractIntegrityData() without new
384
+ // glue) unless collectForPostHoc.rawMouseTrack is set false, in which
385
+ // case only the derived mouseMetrics above ship and the {x,y,t,type}
386
+ // samples never leave the browser. ON by default since 2026-09-02: a
387
+ // researcher who adds CH to an experiment has already adjudicated
388
+ // collecting behavioural traces, and an off-by-default gate made the
389
+ // report's trajectory panels read "no mouse data" for everyone who
390
+ // never found the toggle (found by CK on the bench harness). The
391
+ // toggle stays for protocols that need less.
393
392
  if (config.collectForPostHoc.rawMouseTrack) {
394
393
  report.mouseTrack = report.mouseEvents;
395
394
  }
@@ -35,7 +35,7 @@ import { buildReplayMeta } from '../replay/persistence.js';
35
35
  class CyborgHunterReplayExtension {
36
36
  static info = {
37
37
  name: 'cyborg-hunter-replay',
38
- version: '0.7.4',
38
+ version: '0.8.0',
39
39
  data: {} // per-trial return is {}; session meta goes via addProperties
40
40
  };
41
41
 
@@ -47,9 +47,37 @@ class CyborgHunterReplayExtension {
47
47
  this._monitoring = false;
48
48
  }
49
49
 
50
+ // Keyframe cadence on a jsPsych host: every TRIAL segment, always.
51
+ //
52
+ // The standalone recorder keyframes adaptively (spec §3's size trigger plus a
53
+ // segment fallback) because a persistent DOM makes a continuation cheap: the
54
+ // player carries the previous segment's tree forward and applies deltas. A
55
+ // jsPsych trial wipes the display and builds a new one, so there is nothing
56
+ // to carry forward — a continuation would make the player reconstruct each
57
+ // trial by replaying the wipe as a removal of everything followed by an
58
+ // insertion of everything, and would leave a seek into any trial dependent on
59
+ // the trials before it. §3 says as much: wiping hosts SHOULD keyframe every
60
+ // segment, which is also jsPsych-v1's own behaviour.
61
+ //
62
+ // Forced over researcher config rather than merged under it: the reason is a
63
+ // property of the host, not a tuning preference, so a value passed here is a
64
+ // misunderstanding rather than a choice. It is not discarded silently.
65
+ //
66
+ // "Every TRIAL segment" is exact, not loose. A jsPsych recording can also
67
+ // contain UNBRACKETED segments — the inter-trial interval, where the display
68
+ // wipe fires mutations between one trial's `on_finish` and the next trial's
69
+ // `on_load` — and those are recorded as CONTINUATIONS whatever this setting
70
+ // says (capture-dom's implicit-segment rule). That is correct: an ITI segment
71
+ // genuinely continues the outgoing trial's DOM as it is torn down, and its
72
+ // patches were numbered against that span before the segment existed.
50
73
  initialize(params) {
51
74
  this.params = params || {};
52
- this.api = CHReplay.attach(this.params);
75
+ if (this.params.keyframeEvery != null && this.params.keyframeEvery !== 1) {
76
+ console.warn('[cyborg-hunter-replay] ignoring keyframeEvery: ' +
77
+ this.params.keyframeEvery + ' — jsPsych wipes the display between ' +
78
+ 'trials, so every trial segment is recorded as a keyframe.');
79
+ }
80
+ this.api = CHReplay.attach(Object.assign({}, this.params, { keyframeEvery: 1 }));
53
81
  this.api.startSession();
54
82
  this._stashChMonitor();
55
83
  }
@@ -64,6 +92,36 @@ class CyborgHunterReplayExtension {
64
92
  } catch (e) { /* standalone replay is fine */ }
65
93
  }
66
94
 
95
+ // Spec §2's `host`: the runtime the recorder was embedded in, as opposed to
96
+ // `recorder`, which is this library. The recorder core is host-agnostic by
97
+ // construction and never sniffs for globals, so the adapter — the one piece
98
+ // that exists because jsPsych does — is where the answer comes from.
99
+ //
100
+ // §2 types host as `{name, version} | null` with version a required STRING,
101
+ // so a runtime that reports no version gets no host record rather than a
102
+ // half-record a conforming consumer would trip over.
103
+ //
104
+ // `version()` is the only shape checked for, because it is the only shape
105
+ // jsPsych has: 7.3.1 defines `version() { return version; }` on the JsPsych
106
+ // class and 8.x keeps the same core-API accessor. An earlier draft also
107
+ // accepted a plain string field; nothing produces one, so it was speculative
108
+ // dead code and went.
109
+ //
110
+ // Never throws: finalize()'s outer catch turns any throw into
111
+ // replayFinalizeError with NO autosave, and the least important field in
112
+ // the file must not be able to cost the recording.
113
+ _detectHost() {
114
+ try {
115
+ var jp = this.jsPsych;
116
+ if (!jp || typeof jp.version !== 'function') return null;
117
+ var v = jp.version();
118
+ if (typeof v !== 'string' || !v) return null;
119
+ return { name: 'jspsych', version: v };
120
+ } catch (e) {
121
+ return null;
122
+ }
123
+ }
124
+
67
125
  // jsPsych 7 calls on_start unconditionally for every trial that lists the
68
126
  // extension — must exist even as a no-op (see extension-cyborg-hunter.js).
69
127
  on_start(_params) {}
@@ -105,7 +163,10 @@ class CyborgHunterReplayExtension {
105
163
  } catch (e) {
106
164
  console.warn('[cyborg-hunter-replay] could not pull CH session report:', e);
107
165
  }
108
- var result = await this.api.autoSaveNow({ chSessionReport: chReport });
166
+ var result = await this.api.autoSaveNow({
167
+ chSessionReport: chReport,
168
+ host: this._detectHost()
169
+ });
109
170
  this._lastRecording = result.recording;
110
171
  this.jsPsych.data.addProperties({ integrityReplayMeta: result.meta });
111
172
  } catch (e) {
@@ -10,7 +10,7 @@ class CyborgHunterExtension {
10
10
  // and package.json). Hand-bumped on each release; if you forget, the
11
11
  // jsPsych developer console shows a stale number — the actual version
12
12
  // attached to data is read from window.CyborgHunter.VERSION at runtime.
13
- version: '0.7.4',
13
+ version: '0.8.0',
14
14
  data: { integrity: { type: 'object' } }
15
15
  };
16
16
 
@@ -72,6 +72,23 @@ export function fullscreenElementOf(doc) {
72
72
  || null;
73
73
  }
74
74
 
75
+ // Mirrors requestFullscreen()'s prefix fallback on the exit side: Safari
76
+ // <16.4 exposes only webkitExitFullscreen, older Firefox
77
+ // mozCancelFullScreen. Pure + exported for unit testing, like
78
+ // fullscreenElementOf above. Returns the METHOD, unbound — every caller
79
+ // must invoke it as fn.call(document) (see exitFullscreen below): a
80
+ // detached call (fn()) throws "Illegal invocation" in real Chrome, because
81
+ // document.exitFullscreen is a native method that requires document as its
82
+ // receiver.
83
+ export function exitFullscreenFnOf(doc) {
84
+ doc = doc || (typeof document !== 'undefined' ? document : null);
85
+ if (!doc) return null;
86
+ return doc.exitFullscreen
87
+ || doc.webkitExitFullscreen
88
+ || doc.mozCancelFullScreen
89
+ || null;
90
+ }
91
+
75
92
  (function (global) {
76
93
  'use strict';
77
94
 
@@ -789,6 +806,42 @@ export function fullscreenElementOf(doc) {
789
806
  }
790
807
  }
791
808
 
809
+ // Counterpart to requestFullscreen() above, on the exit side. Two guards
810
+ // before ever touching the native method: a no-op when nothing is
811
+ // fullscreen (fullscreenElementOf(document) is null — nothing to exit),
812
+ // and a no-op while the guard is armed (state.active, the same flag
813
+ // start()/stop() maintain and getCurrentState() reports). The second
814
+ // guard matters because check() runs on every fullscreenchange: exiting
815
+ // while armed would immediately log a false 'not_fullscreen' violation
816
+ // against the participant — a footgun for any study calling this
817
+ // mid-timeline. Callers must stop() first. Never throws.
818
+ function exitFullscreen() {
819
+ if (!fullscreenElementOf(document)) {
820
+ logDebug('exit_fullscreen.not_fullscreen', { diagnostics: getDiagnostics() });
821
+ return;
822
+ }
823
+ if (state.active) {
824
+ logDebug('exit_fullscreen.refused_guard_active', { diagnostics: getDiagnostics() });
825
+ return;
826
+ }
827
+ const exit = exitFullscreenFnOf(document);
828
+ if (exit) {
829
+ try {
830
+ logDebug('exit_fullscreen.attempt', { diagnostics: getDiagnostics() });
831
+ // .call(document) is mandatory, not stylistic — see
832
+ // exitFullscreenFnOf's comment above.
833
+ exit.call(document);
834
+ } catch (e) {
835
+ logDebug('exit_fullscreen.error', {
836
+ message: e && e.message ? e.message : String(e),
837
+ diagnostics: getDiagnostics(),
838
+ });
839
+ }
840
+ } else {
841
+ logDebug('exit_fullscreen.unavailable_api', { diagnostics: getDiagnostics() });
842
+ }
843
+ }
844
+
792
845
  function start(opts = {}) {
793
846
  state.jsPsych = opts.jsPsych || state.jsPsych;
794
847
  if (typeof opts.sidebarTolerance === 'number') {
@@ -1050,6 +1103,11 @@ export function fullscreenElementOf(doc) {
1050
1103
  stop: function (token) { stop(token); },
1051
1104
  setJsPsych: function (j) { state.jsPsych = j; },
1052
1105
  requestFullscreen: function () { requestFullscreen(); },
1106
+ // Counterpart to requestFullscreen() above. Call this AFTER stop() —
1107
+ // it no-ops while the guard is still armed (see exitFullscreen's
1108
+ // guard above), so the ordering (not just the refusal) is what a
1109
+ // caller should rely on.
1110
+ exitFullscreen: function () { exitFullscreen(); },
1053
1111
  injectRefusalNotices: function () { injectRefusalNotices(); },
1054
1112
  createEntryTrial: function (opts) { return createEntryTrial(opts); },
1055
1113
  onViolation: function (handler) { return onViolation(handler); },
@@ -1086,7 +1144,7 @@ class GuardFrictionExtension {
1086
1144
  // Hand-bumped on each release to track the package version
1087
1145
  // (package.json / src/shared/constants.js). Shown in the jsPsych developer
1088
1146
  // console only; the runtime library version is independent.
1089
- version: '0.6.0',
1147
+ version: '0.7.0',
1090
1148
  data: {}
1091
1149
  };
1092
1150