cyborg-hunter 0.5.0 → 0.7.2

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 (58) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/CITATION.cff +29 -0
  3. package/LICENSE +21 -0
  4. package/README.md +80 -21
  5. package/bin/cyborg-hunter.js +6 -2
  6. package/dist/cyborg-hunter-replay.js +3 -0
  7. package/dist/cyborg-hunter.esm.js +114 -22
  8. package/dist/cyborg-hunter.min.js +3 -3
  9. package/dist/extension-cyborg-hunter.js +1 -1
  10. package/dist/extension-guard-friction.js +5 -5
  11. package/dist/extension-guard-honeypot.js +1 -1
  12. package/package.json +15 -3
  13. package/src/cli/analyzers/edge-exit.js +4 -1
  14. package/src/cli/analyzers/phase-scope.js +83 -0
  15. package/src/cli/analyzers/summary.js +161 -28
  16. package/src/cli/analyzers/triage.js +59 -27
  17. package/src/cli/config.js +26 -1
  18. package/src/cli/extract-core.js +552 -0
  19. package/src/cli/ingest.js +250 -193
  20. package/src/cli/init.js +1 -1
  21. package/src/cli/preview-entry.js +36 -0
  22. package/src/cli/renderers/event-log.js +18 -19
  23. package/src/cli/renderers/extensions.js +12 -3
  24. package/src/cli/renderers/html-index-core.js +1273 -0
  25. package/src/cli/renderers/html-index.js +13 -1047
  26. package/src/cli/renderers/replay-assets.js +67 -0
  27. package/src/cli/renderers/replay-viewer.client.js +1185 -0
  28. package/src/cli/renderers/session-timeline-core.js +907 -0
  29. package/src/cli/renderers/session-timeline.js +33 -0
  30. package/src/cli/renderers/summary-csv.js +5 -0
  31. package/src/cli/renderers/trajectories-core.js +717 -0
  32. package/src/cli/renderers/trajectories.js +31 -635
  33. package/src/cli/renderers/triage-md.js +10 -4
  34. package/src/cli/renderers/typing-profile-core.js +211 -0
  35. package/src/cli/renderers/typing-profile.js +16 -186
  36. package/src/cli/report.js +42 -8
  37. package/src/core/monitor.js +77 -7
  38. package/src/core/scoring.js +11 -2
  39. package/src/core/signals/browser.js +51 -18
  40. package/src/core/signals/clipboard.js +10 -2
  41. package/src/core/signals/dom-protection.js +9 -0
  42. package/src/core/signals/focus.js +16 -2
  43. package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
  44. package/src/jspsych/extension-cyborg-hunter.js +9 -2
  45. package/src/jspsych/extension-guard-friction.js +43 -14
  46. package/src/jspsych/extension-guard-honeypot.js +25 -1
  47. package/src/replay/capture-dom.js +575 -0
  48. package/src/replay/capture-trace.js +468 -0
  49. package/src/replay/index.js +104 -0
  50. package/src/replay/persistence.js +141 -0
  51. package/src/replay/recorder.js +315 -0
  52. package/src/replay/serializer.js +119 -0
  53. package/src/replay/viewer-model.js +125 -0
  54. package/src/shared/constants.js +12 -6
  55. package/src/shared/paths.js +20 -0
  56. package/src/shared/schema.js +5 -0
  57. package/src/shared/validation.js +55 -0
  58. package/src/cli/renderers/tab-timeline.js +0 -149
@@ -7,14 +7,14 @@
7
7
 
8
8
  import { createStateMachine, deepCopy } from './state-machine.js';
9
9
  import { DEFAULT_THRESHOLDS, PRESETS, VERSION } from '../shared/constants.js';
10
- import { validateConfig } from '../shared/validation.js';
10
+ import { validateConfig, validateScoringShape } from '../shared/validation.js';
11
11
  import { computeTrialScores, shouldScreenout as _shouldScreenout } from './scoring.js';
12
12
  import { attachClipboardSignals } from './signals/clipboard.js';
13
13
  import { attachFocusSignals, attachIdleGapSignals } from './signals/focus.js';
14
14
  import { attachMouseSignals, computeMouseMetrics } from './signals/mouse.js';
15
15
  import { attachTypingSignals, attachForeignInputSignals, computeTypingSpeed } from './signals/typing.js';
16
16
  import { attachBrowserSignals, attachElementTrace } from './signals/browser.js';
17
- import { injectDecoy, removeDecoyElement } from './signals/dom-protection.js';
17
+ import { injectDecoy, removeDecoyElement, resetDecoyState } from './signals/dom-protection.js';
18
18
 
19
19
  let _activeInstance = null; // Track for idempotent init
20
20
 
@@ -88,6 +88,14 @@ export function init(userConfig) {
88
88
  var warnings = validateConfig(userConfig, KNOWN_KEYS);
89
89
  warnings.forEach(function (w) { console.warn("[cyborg-hunter] " + w); });
90
90
 
91
+ // Shape-check the MERGED scoring config: a nested typo in an override
92
+ // (e.g. countThreshhold) silently disables a screenout rule because the
93
+ // rule object is replaced wholesale and top-level key validation can't see
94
+ // inside it. Warn so the misconfiguration is visible instead of silent.
95
+ validateScoringShape(mergedScoring).forEach(function (w) {
96
+ console.warn("[cyborg-hunter] " + w);
97
+ });
98
+
91
99
  // Freeze config
92
100
  Object.freeze(config);
93
101
  Object.freeze(config.signals);
@@ -141,20 +149,32 @@ export function init(userConfig) {
141
149
 
142
150
  // ── Internal state ──
143
151
  var sm = createStateMachine();
152
+ // viewportWidthShifts is the canonical name since 0.6.1 (the signal measures
153
+ // viewport-width changes, not Web-Vitals-style layout shift). layoutShifts is
154
+ // kept as a deprecated alias pointing at the SAME array, so serialized
155
+ // reports carry both keys and downstream consumers reading either keep
156
+ // working. The alias will be removed in a future major version.
157
+ var _viewportWidthShifts = [];
144
158
  var sessionData = {
145
159
  pasteCount: 0, copyCount: 0, dropCount: 0,
146
- tabAwaySums: [], charsPerSec: [],
160
+ tabAwaySums: [], tabAwayEvents: [], charsPerSec: [],
147
161
  sidebarEvents: [], devToolsEvents: [],
148
162
  aiExtensionsFound: [], keyboardShortcuts: [],
149
163
  windowPositions: [], idleGaps: [],
150
- extensionInjections: [], layoutShifts: [],
164
+ extensionInjections: [],
165
+ viewportWidthShifts: _viewportWidthShifts,
166
+ layoutShifts: _viewportWidthShifts,
151
167
  zoomChanges: [],
152
168
  hardScore: {}, softScore: 0, trialsCompleted: 0
153
169
  };
154
170
  var trialData = null;
155
171
  var listeners = [];
156
172
  var trialListeners = [];
157
- var intervals = [];
173
+ var intervals = []; // session-scoped, cleared at destroy()
174
+ var trialIntervals = []; // trial-scoped, cleared at endTrial() AND destroy()
175
+
176
+ // Module-level decoy state (cal-{N} numbering) must restart per instance.
177
+ resetDecoyState();
158
178
 
159
179
  function transition(to) {
160
180
  if (!sm.transition(to)) {
@@ -185,11 +205,17 @@ export function init(userConfig) {
185
205
  intervals.push(id);
186
206
  }
187
207
 
208
+ function addTrialInterval(id) {
209
+ trialIntervals.push(id);
210
+ }
211
+
188
212
  function removeTrialListeners() {
189
213
  trialListeners.forEach(function (l) {
190
214
  l.target.removeEventListener(l.event, l.handler, l.options);
191
215
  });
192
216
  trialListeners = [];
217
+ trialIntervals.forEach(function (id) { clearInterval(id); });
218
+ trialIntervals = [];
193
219
  removeDecoyElement();
194
220
  }
195
221
 
@@ -208,7 +234,7 @@ export function init(userConfig) {
208
234
  function buildTrialCtx() {
209
235
  return {
210
236
  config, sessionData, trialData, listeners,
211
- addTrialListener, addInterval, fireSignal, isKnownInput,
237
+ addTrialListener, addInterval, addTrialInterval, fireSignal, isKnownInput,
212
238
  getTrialData: function () { return trialData; }
213
239
  };
214
240
  }
@@ -316,6 +342,14 @@ export function init(userConfig) {
316
342
  report.duration_ms = performance.now() - trialData.startTime;
317
343
  report.libraryVersion = VERSION;
318
344
  report.participantId = config.participantId;
345
+ // Wall-clock stamp at trial end (ISO 8601). Downstream renderers anchor
346
+ // per-rule phase bands with Date.parse(trial.timestamp); before 0.6.1
347
+ // the report carried only performance.now() values, so payloads whose
348
+ // app layer didn't stamp its own timestamp produced NaN anchors.
349
+ // Note: in Shape-1 ingest the integrity sub-object wins key collisions,
350
+ // so for new data this stamp shadows an app-level trial timestamp —
351
+ // both are trial-end wall-clocks, so they agree to within milliseconds.
352
+ report.timestamp = new Date().toISOString();
319
353
 
320
354
  // Compute typing speed
321
355
  var cps = computeTypingSpeed(trialData.editTimestamps);
@@ -324,6 +358,17 @@ export function init(userConfig) {
324
358
  sessionData.charsPerSec.push(cps);
325
359
  }
326
360
 
361
+ // Privacy gate: the raw per-edit timestamps are keystroke-timing data.
362
+ // Persist them ONLY when keystroke dynamics are explicitly enabled
363
+ // (signals.keystrokeDynamics, or collectForPostHoc.fullKeystrokeTimestamps).
364
+ // Otherwise keep only the derived charsPerSec — the typing-speed signal
365
+ // still works, but the raw timings never leave the browser. Without this,
366
+ // the documented "off by default in permissive/standard" privacy toggle was
367
+ // inert and the timings were saved verbatim regardless.
368
+ if (!config.signals.keystrokeDynamics && !config.collectForPostHoc.fullKeystrokeTimestamps) {
369
+ report.editTimestamps = [];
370
+ }
371
+
327
372
  // Compute mouse bot metrics
328
373
  var mouseMetrics = computeMouseMetrics(
329
374
  trialData.mouseEvents,
@@ -333,6 +378,23 @@ export function init(userConfig) {
333
378
  report.mouseMetrics = mouseMetrics;
334
379
  }
335
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.
393
+ if (config.collectForPostHoc.rawMouseTrack) {
394
+ report.mouseTrack = report.mouseEvents;
395
+ }
396
+ delete report.mouseEvents;
397
+
336
398
  // Accumulate into session
337
399
  sessionData.trialsCompleted++;
338
400
 
@@ -370,9 +432,17 @@ export function init(userConfig) {
370
432
  return sessionData.hardScore[k].triggered;
371
433
  });
372
434
  report.trialsCompleted = sessionData.trialsCompleted;
435
+ // Carry the effective runtime settings the CLI report needs to interpret
436
+ // this participant's data with the SAME thresholds the library screened
437
+ // them against (e.g. a strict-preset participant's tab-away cutoff), rather
438
+ // than analyst-side CLI defaults.
373
439
  report.config = {
374
440
  preset: config.preset,
375
- participantId: config.participantId
441
+ participantId: config.participantId,
442
+ thresholds: {
443
+ tabAwayDurationMs: config.thresholds.tabAwayDurationMs,
444
+ typingSpeedCps: config.thresholds.typingSpeedCps
445
+ }
376
446
  };
377
447
  report.libraryVersion = VERSION;
378
448
  return report;
@@ -81,11 +81,20 @@ export function computeTrialScores(trialData, sessionData, config, report) {
81
81
  };
82
82
  }
83
83
 
84
+ // ── Trial window upper bound ──
85
+ // Session-scoped events (sidebar, devTools shortcuts) count toward this
86
+ // trial only if they fall inside [startTime, startTime + duration]. Using
87
+ // performance.now() here would silently widen the window if scoring were
88
+ // ever deferred past endTrial.
89
+ var trialEnd = trialData.startTime + (report && report.duration_ms != null
90
+ ? report.duration_ms
91
+ : performance.now() - trialData.startTime);
92
+
84
93
  // ── Soft signal: sidebarEvent ──
85
94
  // Counts sidebar events that occurred during this trial's time window.
86
95
  if (scoring.soft.sidebarEvent) {
87
96
  var trialSidebarHits = sessionData.sidebarEvents.filter(function (e) {
88
- return e.t >= trialData.startTime && e.t <= performance.now();
97
+ return e.t >= trialData.startTime && e.t <= trialEnd;
89
98
  }).length;
90
99
  if (trialSidebarHits > 0) {
91
100
  var sidebarScore = scoring.soft.sidebarEvent.weight;
@@ -98,7 +107,7 @@ export function computeTrialScores(trialData, sessionData, config, report) {
98
107
  // Counts keyboard shortcuts detected during this trial's time window.
99
108
  if (scoring.soft.devTools) {
100
109
  var trialDevToolsHits = sessionData.keyboardShortcuts.filter(function (e) {
101
- return e.t >= trialData.startTime && e.t <= performance.now();
110
+ return e.t >= trialData.startTime && e.t <= trialEnd;
102
111
  }).length;
103
112
  if (trialDevToolsHits > 0) {
104
113
  var devToolsScore = scoring.soft.devTools.weight;
@@ -24,8 +24,11 @@ export function attachBrowserSignals(ctx) {
24
24
  // ── Sidebar gap detection (2s polling) ──
25
25
  // Tracks state TRANSITIONS in innerWidth. A sidebar opening shrinks
26
26
  // innerWidth by 300-500px. Also checks layout compression (extension
27
- // padding/margin on <html> element).
28
- if (config.signals.sidebarGap || config.signals.devTools) {
27
+ // padding/margin on <html> element) and zoom changes. None of these are
28
+ // DevTools-related, so this interval is gated by `sidebarGap` alone — the
29
+ // earlier `|| devTools` here was a mis-routed toggle (DevTools evidence comes
30
+ // from the keyboard-shortcut listener below, not from viewport polling).
31
+ if (config.signals.sidebarGap) {
29
32
  var _lastZoom = window.devicePixelRatio || 1;
30
33
  var _baselineIW = window.innerWidth;
31
34
  var _sidebarOpen = false;
@@ -138,9 +141,12 @@ export function attachBrowserSignals(ctx) {
138
141
  ctx.addInterval(setInterval(scanExtensions, config.thresholds.extensionScanMs));
139
142
  }
140
143
 
141
- // ── Keyboard shortcut detection ──
142
- // Catches DevTools shortcuts (Ctrl+Shift+I/J/C, F12)
143
- if (config.signals.keyboardShortcuts) {
144
+ // ── Keyboard shortcut / DevTools-hotkey detection ──
145
+ // Catches DevTools shortcuts (Ctrl+Shift+I/J/C, F12). This listener IS the
146
+ // "DevTools" detector, so EITHER the `keyboardShortcuts` or the `devTools`
147
+ // signal enables it (the soft `devTools` weight then scores the captured
148
+ // hotkeys). Records into sessionData.keyboardShortcuts.
149
+ if (config.signals.keyboardShortcuts || config.signals.devTools) {
144
150
  ctx.addListener(document, "keydown", function (e) {
145
151
  var dominated = e.ctrlKey || e.metaKey;
146
152
  if (dominated && e.shiftKey && (e.key === "I" || e.key === "J" || e.key === "C")) {
@@ -237,25 +243,50 @@ export function attachBrowserSignals(ctx) {
237
243
  });
238
244
  }
239
245
 
240
- // ── ResizeObserver for layout compression ──
246
+ // ── ResizeObserver for viewport-width shifts ──
241
247
  // Fires when <html> element dimensions change. Catches extensions that
242
248
  // add margin-right/padding-right to <html> to make room for their panel.
249
+ //
250
+ // Renamed from "layoutShifts" in 0.6.1: the signal measures VIEWPORT-WIDTH
251
+ // changes, not Web-Vitals CLS-style layout shift — the old name collided
252
+ // with that semantics. Records go to sessionData.viewportWidthShifts
253
+ // (canonical); sessionData.layoutShifts aliases the same array.
254
+ //
255
+ // Debounced (0.6.1): a drag-resize fires the observer once per frame, so a
256
+ // single gesture used to log 5+ shift events. Now the shift is logged only
257
+ // after viewportShiftDebounceMs of quiet, as ONE event carrying the net
258
+ // old→new change of the whole gesture. A shift still settling when
259
+ // destroy() runs is dropped (the debounce timer is cleared with the other
260
+ // intervals) — acceptable, since destroy() means the session is over.
243
261
  if (config.signals.sidebarGap) {
244
262
  var baselineWidth = document.documentElement.clientWidth;
263
+ var _pendingWidth = null;
264
+ var _shiftTimer = null;
265
+ var flushShift = function () {
266
+ _shiftTimer = null;
267
+ if (_pendingWidth === null) return;
268
+ var settledWidth = _pendingWidth;
269
+ _pendingWidth = null;
270
+ var delta = baselineWidth - settledWidth;
271
+ if (Math.abs(delta) > config.thresholds.layoutCompressionPx) {
272
+ sessionData.viewportWidthShifts.push({
273
+ oldWidth: Math.round(baselineWidth),
274
+ newWidth: Math.round(settledWidth),
275
+ delta: Math.round(delta),
276
+ t: performance.now()
277
+ });
278
+ baselineWidth = settledWidth;
279
+ }
280
+ };
245
281
  var resizeObs = new ResizeObserver(function (entries) {
246
282
  for (var i = 0; i < entries.length; i++) {
247
- var currentWidth = entries[i].contentRect.width;
248
- var delta = baselineWidth - currentWidth;
249
- if (Math.abs(delta) > config.thresholds.layoutCompressionPx) {
250
- sessionData.layoutShifts.push({
251
- oldWidth: Math.round(baselineWidth),
252
- newWidth: Math.round(currentWidth),
253
- delta: Math.round(delta),
254
- t: performance.now()
255
- });
256
- baselineWidth = currentWidth;
257
- }
283
+ _pendingWidth = entries[i].contentRect.width;
258
284
  }
285
+ if (_shiftTimer) clearTimeout(_shiftTimer);
286
+ _shiftTimer = setTimeout(flushShift, config.thresholds.viewportShiftDebounceMs);
287
+ // Tracked so destroy() clears a still-pending debounce (clearInterval
288
+ // and clearTimeout are interchangeable per the HTML spec).
289
+ ctx.addInterval(_shiftTimer);
259
290
  });
260
291
  resizeObs.observe(document.documentElement);
261
292
  ctx.listeners.push({
@@ -298,6 +329,8 @@ export function attachElementTrace(ctx) {
298
329
  }
299
330
  } catch (e) { /* elementsFromPoint not supported */ }
300
331
  }, config.thresholds.elementTraceHz);
301
- ctx.addInterval(elementTraceId);
332
+ // Trial-scoped: cleared at endTrial. Registering session-scoped here
333
+ // leaked one live interval per trial until destroy().
334
+ ctx.addTrialInterval(elementTraceId);
302
335
  }
303
336
  }
@@ -57,17 +57,25 @@ export function attachClipboardSignals(ctx) {
57
57
  });
58
58
 
59
59
  // Cut is logged under the copy signal (same data concern — text leaving
60
- // the page). Stored in copyEvents alongside copy entries.
60
+ // the page). Stored in copyEvents alongside copy entries, and — like copy —
61
+ // increments the session copyCount so the hard-copy screenout counts cuts
62
+ // too. Without this, a cut showed up in the per-trial hard `trialHits` and
63
+ // in the soft-copy score but never in the session total that actually
64
+ // triggers, so strict-preset hard-copy could not fire on cuts.
61
65
  ctx.addTrialListener(document, "cut", function (e) {
62
66
  trialData.copyEvents.push({
63
67
  type: "cut", t: performance.now()
64
68
  });
69
+ ctx.sessionData.copyCount++;
65
70
  ctx.fireSignal("cut", e, {});
66
71
  });
67
72
  }
68
73
 
69
74
  // ── Drop detection ──
70
- if (config.signals.paste) {
75
+ // Gated on its own signal flag (presets declare drop explicitly). Was
76
+ // previously gated on paste by copy-paste error, so disabling paste
77
+ // silently disabled drop — a separately hard-scored signal.
78
+ if (config.signals.drop) {
71
79
  ctx.addTrialListener(document, "drop", function (e) {
72
80
  var text = "";
73
81
  try { text = e.dataTransfer.getData("text"); } catch (err) {}
@@ -143,6 +143,15 @@ function scanDomForDecoy(excludeSelectors) {
143
143
  // Internal trial counter for Tier 3/4 template variable
144
144
  var _decoyTrialIndex = 0;
145
145
 
146
+ /**
147
+ * Resets module-level decoy state. Called from monitor.js init() so a second
148
+ * CyborgHunter.init() in the same page load restarts cal-{N} numbering at 1
149
+ * instead of continuing where the previous instance left off.
150
+ */
151
+ export function resetDecoyState() {
152
+ _decoyTrialIndex = 0;
153
+ }
154
+
146
155
  /**
147
156
  * Main decoy injection function. Called from monitor.js at startTrial().
148
157
  *
@@ -33,10 +33,22 @@ export function attachFocusSignals(ctx) {
33
33
  var event = {
34
34
  start: _tabAwayStart,
35
35
  duration_ms: Math.round(duration),
36
- type: _tabAwayType
36
+ type: _tabAwayType,
37
+ // Wall-clock at the LEAVE moment (ISO 8601), reconstructed at return
38
+ // time. Lets timelines place session-level events absolutely without
39
+ // a perfNow→wall-clock offset estimator.
40
+ timestamp: new Date(Date.now() - duration).toISOString()
37
41
  };
38
42
  // Push to current trial if one is active
39
43
  if (ctx.getTrialData()) ctx.getTrialData().tabAwayEvents.push(event);
44
+ // Session-level record (0.6.1). tabAwaySums keeps only durations, so
45
+ // events OUTSIDE startTrial/endTrial (consent, tutorial, comprehension,
46
+ // gallery study) used to lose their timing entirely — timelines could
47
+ // count them but not place them. tabAwayEvents preserves the full
48
+ // {start, duration_ms, type, timestamp} for every tab-away in the
49
+ // session, on-trial or off. tabAwaySums is kept alongside for backward
50
+ // compatibility.
51
+ ctx.sessionData.tabAwayEvents.push(event);
40
52
  ctx.sessionData.tabAwaySums.push(Math.round(duration));
41
53
  ctx.fireSignal("tabReturn", null, {
42
54
  duration_ms: Math.round(duration),
@@ -96,6 +108,8 @@ export function attachIdleGapSignals(ctx) {
96
108
  lastActivityTime = performance.now();
97
109
  }
98
110
  }, config.thresholds.idleCheckIntervalMs);
99
- ctx.addInterval(idleCheckId);
111
+ // Trial-scoped: cleared at endTrial. Registering session-scoped here
112
+ // leaked one live interval per trial until destroy().
113
+ ctx.addTrialInterval(idleCheckId);
100
114
  }
101
115
  }
@@ -0,0 +1,135 @@
1
+ // jsPsych extension adapter for the cyborg-hunter session replay recorder.
2
+ // Bundled SELF-CONTAINED (imports the replay core), following the guard-
3
+ // extension precedent: one dist file provides both window.CyborgHunterReplay
4
+ // (standalone use) and window.jsPsychCyborgHunterReplay (jsPsych use).
5
+ //
6
+ // Wiring (order matters at finalize; declaration order is free):
7
+ //
8
+ // const jsPsych = initJsPsych({
9
+ // extensions: [
10
+ // { type: jsPsychCyborgHunter, params: { participantId, preset } },
11
+ // { type: jsPsychGuardFriction },
12
+ // { type: jsPsychGuardHoneypot },
13
+ // { type: jsPsychCyborgHunterReplay, params: {
14
+ // participantId, tier: 'dom',
15
+ // autoSave: { mode: 'datapipe', experimentId: 'ABC123' } } }
16
+ // ],
17
+ // on_finish: async function () {
18
+ // jsPsych.extensions['guard-friction'].finalize();
19
+ // jsPsych.extensions['guard-honeypot'].finalize();
20
+ // jsPsych.extensions['cyborg-hunter'].finalize();
21
+ // await jsPsych.extensions['cyborg-hunter-replay'].finalize();
22
+ // jsPsych.data.get().localSave('csv', 'data.csv');
23
+ // }
24
+ // });
25
+ //
26
+ // Replay finalizes LAST: it pulls CH's session report through a monitor
27
+ // reference stashed at initialize() — never through window.CyborgHunter,
28
+ // whose singleton slot is nulled by CH's destroy(). getSessionReport()
29
+ // remains callable after destroy() (sessionData survives teardown), so the
30
+ // pull works even though CH finalized first.
31
+
32
+ import * as CHReplay from '../replay/index.js';
33
+ import { buildReplayMeta } from '../replay/persistence.js';
34
+
35
+ class CyborgHunterReplayExtension {
36
+ static info = {
37
+ name: 'cyborg-hunter-replay',
38
+ version: '0.7.2',
39
+ data: {} // per-trial return is {}; session meta goes via addProperties
40
+ };
41
+
42
+ constructor(jsPsych) {
43
+ this.jsPsych = jsPsych;
44
+ this.api = null;
45
+ this._chMonitor = null;
46
+ this._lastRecording = null;
47
+ this._monitoring = false;
48
+ }
49
+
50
+ initialize(params) {
51
+ this.params = params || {};
52
+ this.api = CHReplay.attach(this.params);
53
+ this.api.startSession();
54
+ this._stashChMonitor();
55
+ }
56
+
57
+ // The cyborg-hunter extension initializes before this one when declared
58
+ // earlier in the extensions array; if not, retry the stash at finalize.
59
+ _stashChMonitor() {
60
+ try {
61
+ var ch = this.jsPsych && this.jsPsych.extensions &&
62
+ this.jsPsych.extensions['cyborg-hunter'];
63
+ if (ch && ch.monitor) this._chMonitor = ch.monitor;
64
+ } catch (e) { /* standalone replay is fine */ }
65
+ }
66
+
67
+ // jsPsych 7 calls on_start unconditionally for every trial that lists the
68
+ // extension — must exist even as a no-op (see extension-cyborg-hunter.js).
69
+ on_start(_params) {}
70
+
71
+ on_load(params) {
72
+ if (!this.api) return;
73
+ this._monitoring = true;
74
+ var trialIndex = 0;
75
+ var plugin = 'unknown';
76
+ try { trialIndex = this.jsPsych.getProgress().current_trial_global; } catch (e) {}
77
+ try {
78
+ plugin = (this.jsPsych.getCurrentTrial() || {}).type?.info?.name || 'unknown';
79
+ } catch (e) {}
80
+ this.api.startTrial({
81
+ trialId: (params && params.trialId) || 'trial-' + trialIndex,
82
+ plugin: plugin
83
+ });
84
+ }
85
+
86
+ on_finish(_params) {
87
+ if (this.api && this._monitoring) {
88
+ this._monitoring = false;
89
+ this.api.endTrial();
90
+ }
91
+ return {};
92
+ }
93
+
94
+ // Called manually from the experiment's on_finish, AFTER the other three
95
+ // extensions (see header). Serializes, autosaves, attaches the meta
96
+ // pointer. Never throws — the experiment's save path must always run.
97
+ async finalize() {
98
+ if (!this.api) return;
99
+ try {
100
+ try { this.api.stopSession('finished'); } catch (e) { /* already stopped */ }
101
+ if (!this._chMonitor) this._stashChMonitor();
102
+ var chReport = null;
103
+ try {
104
+ chReport = this._chMonitor ? this._chMonitor.getSessionReport() : null;
105
+ } catch (e) {
106
+ console.warn('[cyborg-hunter-replay] could not pull CH session report:', e);
107
+ }
108
+ var result = await this.api.autoSaveNow({ chSessionReport: chReport });
109
+ this._lastRecording = result.recording;
110
+ this.jsPsych.data.addProperties({ integrityReplayMeta: result.meta });
111
+ } catch (e) {
112
+ console.warn('[cyborg-hunter-replay] finalize failed:', e);
113
+ try {
114
+ this.jsPsych.data.addProperties({
115
+ replayFinalizeError: String(e && e.message ? e.message : e)
116
+ });
117
+ } catch (_) { /* nothing left to do */ }
118
+ }
119
+ try { this.api.destroy(); } catch (e) { /* teardown best-effort */ }
120
+ // Null the handle so a second finalize() (e.g. duplicated on_finish
121
+ // wiring) is a clean no-op instead of re-serializing and re-saving.
122
+ this.api = null;
123
+ }
124
+
125
+ // Debug/test access to the finalized recording (also handy in the console
126
+ // during piloting: jsPsych.extensions['cyborg-hunter-replay'].getLastRecording()).
127
+ getLastRecording() {
128
+ return this._lastRecording;
129
+ }
130
+ }
131
+
132
+ export { CyborgHunterReplayExtension };
133
+ if (typeof window !== 'undefined') {
134
+ window.jsPsychCyborgHunterReplay = CyborgHunterReplayExtension;
135
+ }
@@ -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.3.0',
13
+ version: '0.7.2',
14
14
  data: { integrity: { type: 'object' } }
15
15
  };
16
16
 
@@ -71,7 +71,10 @@ class CyborgHunterExtension {
71
71
  this.monitor.startTrial({
72
72
  trialId: (params && params.trialId) || `trial-${trialIndex}`,
73
73
  phase: params?.phase || null,
74
- decoyAnswer: params?.decoyAnswer || null,
74
+ // Nullish coalescing (not ||) so an explicit `decoyAnswer: false` — the
75
+ // per-trial "skip the decoy" opt-out — reaches the core as false. With
76
+ // `|| null` it became null, and the core auto-generated a decoy instead.
77
+ decoyAnswer: params?.decoyAnswer ?? null,
75
78
  experimentContainer: params?.experimentContainer || null
76
79
  });
77
80
  }
@@ -154,6 +157,10 @@ class CyborgHunterExtension {
154
157
  sidebarEvents, keyboardShortcuts, windowPositions,
155
158
  layoutShifts, zoomChanges, idleGaps, extensionInjections,
156
159
  tabAwaySums, charsPerSec, aiExtensionsFound,
160
+ // Persist the effective runtime settings (preset + thresholds the CLI
161
+ // consumes) so the report can interpret this participant with the
162
+ // thresholds the library actually used, not analyst-side defaults.
163
+ config,
157
164
  ...extras
158
165
  },
159
166
  integrityScore: { hardScore, softScore, anyHardTriggered, trialsCompleted, softScoreThreshold },