cyborg-hunter 0.3.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.
@@ -0,0 +1,101 @@
1
+ // src/core/signals/focus.js
2
+ // Tab-away detection and idle gap monitoring.
3
+ //
4
+ // Tab-away uses two complementary mechanisms:
5
+ // 1. visibilitychange — fires when tab is fully hidden (tab switch, app switch)
6
+ // 2. window blur/focus — fires when window loses focus without tab hiding
7
+ // (catches sidebar clicks, DevTools, browser menus)
8
+ //
9
+ // Both are logged with their type so downstream analysis can distinguish
10
+ // "left the tab entirely" from "clicked sidebar."
11
+
12
+ /**
13
+ * Attaches session-scoped tab-away detection.
14
+ * Called from monitor.js at startSession() time.
15
+ */
16
+ export function attachFocusSignals(ctx) {
17
+ var config = ctx.config;
18
+
19
+ if (config.signals.tabAway) {
20
+ var _tabAwayStart = null;
21
+ var _tabAwayType = null;
22
+
23
+ function _onLeave(type) {
24
+ if (_tabAwayStart !== null) return; // already tracking
25
+ _tabAwayStart = performance.now();
26
+ _tabAwayType = type;
27
+ ctx.fireSignal("tabAway", null, { type: type });
28
+ }
29
+
30
+ function _onReturn() {
31
+ if (_tabAwayStart === null) return;
32
+ var duration = performance.now() - _tabAwayStart;
33
+ var event = {
34
+ start: _tabAwayStart,
35
+ duration_ms: Math.round(duration),
36
+ type: _tabAwayType
37
+ };
38
+ // Push to current trial if one is active
39
+ if (ctx.getTrialData()) ctx.getTrialData().tabAwayEvents.push(event);
40
+ ctx.sessionData.tabAwaySums.push(Math.round(duration));
41
+ ctx.fireSignal("tabReturn", null, {
42
+ duration_ms: Math.round(duration),
43
+ type: _tabAwayType
44
+ });
45
+ _tabAwayStart = null;
46
+ _tabAwayType = null;
47
+ }
48
+
49
+ // Mechanism 1: full tab hide (tab switch, app switch)
50
+ ctx.addListener(document, "visibilitychange", function () {
51
+ if (document.hidden) {
52
+ _onLeave("tabHidden");
53
+ } else {
54
+ _onReturn();
55
+ }
56
+ });
57
+
58
+ // Mechanism 2: window blur (sidebar click, devtools, browser chrome)
59
+ ctx.addListener(window, "blur", function () {
60
+ _onLeave("windowBlur");
61
+ });
62
+ ctx.addListener(window, "focus", function () {
63
+ if (!document.hidden) _onReturn();
64
+ });
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Attaches trial-scoped idle gap detection.
70
+ * Flags periods of >10s with no input activity within a trial.
71
+ * Called from monitor.js at startTrial() time.
72
+ */
73
+ export function attachIdleGapSignals(ctx) {
74
+ var config = ctx.config;
75
+ var trialData = ctx.trialData;
76
+
77
+ if (config.signals.idleGaps) {
78
+ var idleThreshold = config.thresholds.idleGapMs;
79
+ var lastActivityTime = performance.now();
80
+
81
+ // Any input event resets the activity timer
82
+ ctx.addTrialListener(document, "keydown", function () {
83
+ lastActivityTime = performance.now();
84
+ });
85
+ ctx.addTrialListener(document, "mousemove", function () {
86
+ lastActivityTime = performance.now();
87
+ }, { passive: true });
88
+
89
+ var idleCheckId = setInterval(function () {
90
+ if (!ctx.getTrialData()) return; // trial ended
91
+ var gap = performance.now() - lastActivityTime;
92
+ if (gap > idleThreshold) {
93
+ trialData.idleGaps.push({
94
+ duration_ms: Math.round(gap), t: performance.now()
95
+ });
96
+ lastActivityTime = performance.now();
97
+ }
98
+ }, config.thresholds.idleCheckIntervalMs);
99
+ ctx.addInterval(idleCheckId);
100
+ }
101
+ }
@@ -0,0 +1,117 @@
1
+ // src/core/signals/mouse.js
2
+ // Mouse tracking (20Hz throttled) and bot metrics computation.
3
+ //
4
+ // Trial-scoped: mousemove, click, mousedown, mouseup events are recorded
5
+ // during each trial. The bot metrics (pathEfficiency, directionChanges,
6
+ // speedVariance) are computed at endTrial() time by computeMouseMetrics().
7
+
8
+ /**
9
+ * Attaches trial-scoped mouse tracking listeners.
10
+ * Called from monitor.js at startTrial() time.
11
+ */
12
+ export function attachMouseSignals(ctx) {
13
+ var config = ctx.config;
14
+ var trialData = ctx.trialData;
15
+
16
+ if (config.signals.mouseTracking) {
17
+ var mouseThrottle = config.thresholds.mouseThrottleMs;
18
+ var mouseMaxEvents = config.thresholds.mouseMaxEvents;
19
+ var lastMoveTime = 0;
20
+ var trialStartTime = trialData.startTime;
21
+ trialData.mouseTrackingCapped = false;
22
+ trialData.mouseTrackingCappedAtMs = null;
23
+
24
+ // Throttled mousemove — 20Hz by default, capped at mouseMaxEvents
25
+ ctx.addTrialListener(document, "mousemove", function (e) {
26
+ if (trialData.mouseEvents.length >= mouseMaxEvents) {
27
+ if (!trialData.mouseTrackingCapped) {
28
+ trialData.mouseTrackingCapped = true;
29
+ trialData.mouseTrackingCappedAtMs = Math.round(performance.now() - trialStartTime);
30
+ }
31
+ return;
32
+ }
33
+ var now = performance.now();
34
+ if (now - lastMoveTime < mouseThrottle) return;
35
+ lastMoveTime = now;
36
+ trialData.mouseEvents.push({
37
+ x: Math.round(e.pageX), y: Math.round(e.pageY),
38
+ t: Math.round(now - trialStartTime), type: "move"
39
+ });
40
+ }, { passive: true });
41
+
42
+ // Click/mousedown/mouseup — no throttling needed, low frequency
43
+ function mouseEventHandler(type) {
44
+ return function (e) {
45
+ if (trialData.mouseEvents.length >= mouseMaxEvents) return;
46
+ trialData.mouseEvents.push({
47
+ x: Math.round(e.pageX), y: Math.round(e.pageY),
48
+ t: Math.round(performance.now() - trialStartTime), type: type
49
+ });
50
+ };
51
+ }
52
+ ctx.addTrialListener(document, "click", mouseEventHandler("click"), { passive: true });
53
+ ctx.addTrialListener(document, "mousedown", mouseEventHandler("down"), { passive: true });
54
+ ctx.addTrialListener(document, "mouseup", mouseEventHandler("up"), { passive: true });
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Computes inline mouse bot metrics from recorded mouse events.
60
+ * Three lightweight O(n) features:
61
+ * 1. pathEfficiency: straight-line distance / total path (bots ≈ 1.0, humans ≈ 0.3-0.7)
62
+ * 2. directionChanges: sign reversals in dx/dy (bots ≈ 0, humans have many)
63
+ * 3. speedVariance: variance of inter-sample speeds (bots have near-zero variance)
64
+ *
65
+ * Called from monitor.js at endTrial() time.
66
+ */
67
+ export function computeMouseMetrics(mouseEvents, minEvents) {
68
+ var moveEvents = mouseEvents.filter(function (e) { return e.type === "move"; });
69
+ if (moveEvents.length < minEvents) return null;
70
+
71
+ var totalDist = 0;
72
+ var speeds = [];
73
+ var dxSignChanges = 0, dySignChanges = 0;
74
+ var prevDx = 0, prevDy = 0;
75
+
76
+ for (var mi = 1; mi < moveEvents.length; mi++) {
77
+ var dx = moveEvents[mi].x - moveEvents[mi - 1].x;
78
+ var dy = moveEvents[mi].y - moveEvents[mi - 1].y;
79
+ var dist = Math.sqrt(dx * dx + dy * dy);
80
+ totalDist += dist;
81
+
82
+ // Speed: pixels per millisecond between consecutive samples
83
+ var dt = moveEvents[mi].t - moveEvents[mi - 1].t;
84
+ if (dt > 0) speeds.push(dist / dt);
85
+
86
+ // Direction changes: sign reversal in dx or dy
87
+ if (mi > 1) {
88
+ if ((dx > 0 && prevDx < 0) || (dx < 0 && prevDx > 0)) dxSignChanges++;
89
+ if ((dy > 0 && prevDy < 0) || (dy < 0 && prevDy > 0)) dySignChanges++;
90
+ }
91
+ prevDx = dx;
92
+ prevDy = dy;
93
+ }
94
+
95
+ var first = moveEvents[0];
96
+ var last = moveEvents[moveEvents.length - 1];
97
+ var displacement = Math.sqrt(
98
+ Math.pow(last.x - first.x, 2) + Math.pow(last.y - first.y, 2)
99
+ );
100
+ var pathEfficiency = totalDist > 0 ? Math.round((displacement / totalDist) * 1000) / 1000 : 0;
101
+
102
+ // Speed variance using Welford's online algorithm for numerical stability
103
+ var speedMean = 0, speedM2 = 0;
104
+ for (var si = 0; si < speeds.length; si++) {
105
+ var sdelta = speeds[si] - speedMean;
106
+ speedMean += sdelta / (si + 1);
107
+ speedM2 += sdelta * (speeds[si] - speedMean);
108
+ }
109
+ var speedVariance = speeds.length > 1 ? Math.round((speedM2 / (speeds.length - 1)) * 10000) / 10000 : 0;
110
+
111
+ return {
112
+ pathEfficiency: pathEfficiency,
113
+ directionChanges: dxSignChanges + dySignChanges,
114
+ speedVariance: speedVariance,
115
+ moveCount: moveEvents.length
116
+ };
117
+ }
@@ -0,0 +1,110 @@
1
+ // src/core/signals/typing.js
2
+ // Typing speed measurement and synthetic insertion detection.
3
+ //
4
+ // Synthetic insertion: text inserted without a preceding keydown event.
5
+ // This catches execCommand('insertText'), clipboard-manager pastes,
6
+ // and browser extension injections that bypass the paste event.
7
+ //
8
+ // Typing speed: computed at endTrial() from edit timestamps. The threshold
9
+ // (default 10 cps) flags unusually fast typing that suggests AI assistance.
10
+
11
+ /**
12
+ * Attaches trial-scoped typing and synthetic insertion listeners.
13
+ * Called from monitor.js at startTrial() time.
14
+ *
15
+ * Note: The clipboardManager signal handles both synthetic insertion
16
+ * detection AND edit timestamp collection. When clipboardManager is off
17
+ * but typingSpeed is on, a simpler input listener collects timestamps.
18
+ */
19
+ export function attachTypingSignals(ctx) {
20
+ var config = ctx.config;
21
+ var trialData = ctx.trialData;
22
+
23
+ // ── Synthetic insertion detection + edit timestamps ──
24
+ if (config.signals.clipboardManager) {
25
+ var _lastKeydownTime = 0;
26
+ ctx.addTrialListener(document, "keydown", function () {
27
+ _lastKeydownTime = performance.now();
28
+ });
29
+ ctx.addTrialListener(document, "input", function (e) {
30
+ if (e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.isContentEditable)) {
31
+ // Record edit timestamp for typing speed computation
32
+ trialData.editTimestamps.push(performance.now());
33
+
34
+ // Check if this input arrived without a recent keydown
35
+ if (performance.now() - _lastKeydownTime > config.thresholds.syntheticGapMs && e.inputType === "insertText") {
36
+ var dataLen = (e.data || "").length;
37
+ trialData.syntheticInsertions.push({
38
+ type: "synthetic_insertion", t: performance.now(),
39
+ dataLength: dataLen
40
+ });
41
+ ctx.fireSignal("syntheticInsertion", e, { dataLength: dataLen });
42
+ }
43
+ }
44
+ }, true); // capture phase to catch before frameworks
45
+ }
46
+
47
+ // ── Fallback: keystroke timestamps for typing speed only ──
48
+ // If clipboardManager isn't enabled, still collect edit timestamps
49
+ // for typing speed computation via a simpler listener.
50
+ if (config.signals.typingSpeed && !config.signals.clipboardManager) {
51
+ ctx.addTrialListener(document, "input", function (e) {
52
+ if (e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.isContentEditable)) {
53
+ trialData.editTimestamps.push(performance.now());
54
+ }
55
+ }, true);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Attaches trial-scoped foreign input detection.
61
+ * When experimentContainer is set, tracks input events that land on
62
+ * elements OUTSIDE the experiment's DOM. This catches participants
63
+ * typing into AI extension sidebars, chatbot widgets, etc.
64
+ */
65
+ export function attachForeignInputSignals(ctx) {
66
+ var config = ctx.config;
67
+ var trialData = ctx.trialData;
68
+
69
+ ctx.addTrialListener(document, "input", function (e) {
70
+ var target = e.target;
71
+ if (!target) return;
72
+ var isTextInput = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable;
73
+ if (!isTextInput) return;
74
+ var known = ctx.isKnownInput(target);
75
+ if (known === false) {
76
+ var entry = {
77
+ t: performance.now(),
78
+ targetTag: target.tagName || "unknown",
79
+ targetId: (target.id || "").slice(0, 50),
80
+ targetClass: (target.className || "").toString().slice(0, 100),
81
+ inputType: e.inputType || ""
82
+ };
83
+ if (config.collectForPostHoc.foreignInputContent) {
84
+ entry.data = e.data || "";
85
+ }
86
+ trialData.foreignInputEvents.push(entry);
87
+ ctx.fireSignal("typingOutsideExperiment", e, {
88
+ targetTag: entry.targetTag,
89
+ targetId: entry.targetId,
90
+ targetClass: entry.targetClass,
91
+ data: e.data || ""
92
+ });
93
+ }
94
+ }, true);
95
+ }
96
+
97
+ /**
98
+ * Computes typing speed (chars per second) from edit timestamps.
99
+ * Called from monitor.js at endTrial() time.
100
+ * Returns the computed charsPerSec or null if insufficient data.
101
+ */
102
+ export function computeTypingSpeed(editTimestamps) {
103
+ if (editTimestamps.length < 2) return null;
104
+ var firstEdit = editTimestamps[0];
105
+ var lastEdit = editTimestamps[editTimestamps.length - 1];
106
+ var spanSec = (lastEdit - firstEdit) / 1000;
107
+ if (spanSec <= 0) return null;
108
+ var charCount = editTimestamps.length;
109
+ return Math.round((charCount / spanSec) * 10) / 10;
110
+ }
@@ -0,0 +1,88 @@
1
+ // src/core/state-machine.js
2
+ // Lifecycle state machine enforcing correct call order:
3
+ // init → startSession → startTrial/endTrial → destroy
4
+ // States: "created", "session", "trial", "destroyed"
5
+
6
+ // Valid state transitions. Each state maps to an array of states it can
7
+ // transition to. Any other transition is rejected with a warning.
8
+ export const VALID_TRANSITIONS = {
9
+ created: ["session", "destroyed"],
10
+ session: ["trial", "destroyed"],
11
+ trial: ["session", "destroyed"], // endTrial returns to session
12
+ destroyed: []
13
+ };
14
+
15
+ /**
16
+ * Creates a state machine instance.
17
+ * Returns an object with a `current` getter and a `transition(to)` method.
18
+ * transition() returns true on success, false on invalid transition.
19
+ */
20
+ export function createStateMachine() {
21
+ let state = "created";
22
+ return {
23
+ get current() { return state; },
24
+ transition(to) {
25
+ if (VALID_TRANSITIONS[state] && VALID_TRANSITIONS[state].indexOf(to) !== -1) {
26
+ state = to;
27
+ return true;
28
+ }
29
+ console.warn("[CyborgHunter] Invalid transition: " + state + " → " + to);
30
+ return false;
31
+ }
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Deep copy utility — prevents external mutation of internal state.
37
+ * Uses JSON round-trip for simplicity. Falls back to identity for
38
+ * non-serializable objects (shouldn't happen in practice).
39
+ */
40
+ export function deepCopy(obj) {
41
+ if (obj === null || typeof obj !== "object") return obj;
42
+ try { return JSON.parse(JSON.stringify(obj)); }
43
+ catch (e) { return obj; }
44
+ }
45
+
46
+ /**
47
+ * Storage helpers with localStorage/sessionStorage fallback.
48
+ * Some browsers disable localStorage in private browsing mode,
49
+ * so we fall back to sessionStorage, then to no-op.
50
+ */
51
+ export const STORAGE_PREFIX = "integrity_monitor_";
52
+
53
+ export function storageSet(key, value) {
54
+ var fullKey = STORAGE_PREFIX + key;
55
+ try {
56
+ localStorage.setItem(fullKey, JSON.stringify(value));
57
+ } catch (e) {
58
+ // localStorage unavailable (private browsing) — try sessionStorage
59
+ try {
60
+ sessionStorage.setItem(fullKey, JSON.stringify(value));
61
+ } catch (e2) { /* both unavailable — in-memory only */ }
62
+ }
63
+ }
64
+
65
+ export function storageGet(key) {
66
+ var fullKey = STORAGE_PREFIX + key;
67
+ try {
68
+ var raw = localStorage.getItem(fullKey);
69
+ if (raw) return JSON.parse(raw);
70
+ } catch (e) { /* fall through */ }
71
+ try {
72
+ var raw2 = sessionStorage.getItem(fullKey);
73
+ if (raw2) return JSON.parse(raw2);
74
+ } catch (e) { /* fall through */ }
75
+ return null;
76
+ }
77
+
78
+ export function storageClear(participantId) {
79
+ var prefix = STORAGE_PREFIX + participantId;
80
+ [localStorage, sessionStorage].forEach(function (store) {
81
+ try {
82
+ for (var i = store.length - 1; i >= 0; i--) {
83
+ var k = store.key(i);
84
+ if (k && k.startsWith(prefix)) store.removeItem(k);
85
+ }
86
+ } catch (e) { /* non-critical */ }
87
+ });
88
+ }
@@ -0,0 +1,199 @@
1
+ // src/jspsych/extension.js
2
+ // jsPsych extension adapter for Cyborg Hunter.
3
+ // Translates jsPsych's trial lifecycle into CyborgHunter API calls.
4
+ //
5
+ // Two main usage patterns:
6
+ // 1. Monitor everything (default) — every trial gets startTrial/endTrial
7
+ // 2. Explicit opt-in (autoMonitor: false) — monitor only specified trials
8
+ // (trials must pass { trialId: '...' } in their extension parameters)
9
+ //
10
+ // IMPORTANT: This file does NOT import from the core library. It references
11
+ // window.CyborgHunter at runtime. The researcher must load cyborg-hunter.min.js
12
+ // before this script. This avoids bundling the core into the extension.
13
+
14
+ class CyborgHunterExtension {
15
+ // jsPsych reads this static info object to register the extension.
16
+ // The `data` field declares what on_finish() will return — here, an
17
+ // `integrity` object containing the trial's signal report.
18
+ static info = {
19
+ name: 'cyborg-hunter',
20
+ version: '0.1.0',
21
+ data: { integrity: { type: 'object' } }
22
+ };
23
+
24
+ constructor(jsPsych) {
25
+ this.jsPsych = jsPsych;
26
+ this.monitor = null; // CyborgHunter monitor instance (set in initialize)
27
+ this.params = {}; // extension-level params from jsPsych config
28
+ this._monitoring = false; // whether the current trial is being monitored
29
+ this._trialStart_perfNow = null; // performance.now() captured at on_load
30
+ }
31
+
32
+ // Called once when jsPsych initializes extensions (before any trials run).
33
+ // `params` comes from the experiment's extensions config, e.g.:
34
+ // extensions: [{ type: jsPsychCyborgHunter, params: { preset: 'standard' } }]
35
+ //
36
+ // We pull out extension-specific keys (autoMonitor, excludeTrialTypes) and
37
+ // pass everything else through to CyborgHunter.init() as monitor config.
38
+ initialize(params) {
39
+ this.params = params;
40
+ const { autoMonitor, excludeTrialTypes, ...monitorConfig } = params;
41
+
42
+ // Look up the global CyborgHunter object (or its backward-compat alias).
43
+ // The researcher must include cyborg-hunter.min.js before this extension.
44
+ const CyborgHunter = window.CyborgHunter || window.IntegrityMonitor;
45
+ if (!CyborgHunter) {
46
+ throw new Error('CyborgHunter not found. Load cyborg-hunter.min.js before the jsPsych extension.');
47
+ }
48
+
49
+ // init() creates the monitor; startSession() begins session-scoped listeners.
50
+ this.monitor = CyborgHunter.init(monitorConfig);
51
+ this.monitor.startSession();
52
+ }
53
+
54
+ // jsPsych 7 unconditionally calls on_start for every trial that lists this
55
+ // extension — even though the work happens in on_load (after DOM render).
56
+ // Without this no-op, the very next trial after the first throws
57
+ // "this.extensions['cyborg-hunter'].on_start is not a function".
58
+ on_start(_params) {}
59
+
60
+ // Called at the start of each trial, after the trial's DOM is rendered.
61
+ // `params` here are per-trial extension parameters, e.g.:
62
+ // extensions: [{ type: jsPsychCyborgHunter, params: { trialId: 'rule-3' } }]
63
+ on_load(params) {
64
+ this._monitoring = false;
65
+
66
+ // In explicit opt-in mode (autoMonitor: false), only monitor trials
67
+ // that provide a trialId in their per-trial extension params.
68
+ if (this.params.autoMonitor === false) {
69
+ if (!params || !params.trialId) return;
70
+ }
71
+
72
+ // Auto-monitor with exclusions: skip trial types the researcher listed.
73
+ // This is useful for excluding instruction screens, fixation crosses, etc.
74
+ // Trial type name comes from the plugin's static info.name property.
75
+ if (this.params.excludeTrialTypes) {
76
+ const currentTrial = this.jsPsych.getCurrentTrial();
77
+ const typeName = currentTrial.type?.info?.name || '';
78
+ if (this.params.excludeTrialTypes.includes(typeName)) return;
79
+ }
80
+
81
+ // Start monitoring this trial. We pass through per-trial params so the
82
+ // core library knows the trial ID, phase, decoy text, and where to scope
83
+ // typing/mouse signals.
84
+ this._monitoring = true;
85
+ // P3: Record performance.now() at trial start so ingest can compute
86
+ // trial-relative tab-away timestamps by direct subtraction (no offset
87
+ // estimator needed). Captured on the same monotonic clock that
88
+ // tabAwayEvents[*].start uses, so the difference is exact.
89
+ this._trialStart_perfNow = performance.now();
90
+ const trialIndex = this.jsPsych.getProgress().current_trial_global;
91
+ this.monitor.startTrial({
92
+ trialId: (params && params.trialId) || `trial-${trialIndex}`,
93
+ phase: params?.phase || null,
94
+ decoyAnswer: params?.decoyAnswer || null,
95
+ experimentContainer: params?.experimentContainer || null
96
+ });
97
+ }
98
+
99
+ // Called when a trial finishes (after participant response, before data save).
100
+ // Returns the integrity report object, which jsPsych merges into trial data
101
+ // under the key declared in static info.data (i.e., data.integrity).
102
+ on_finish(params) {
103
+ if (!this._monitoring) return {};
104
+ this._monitoring = false;
105
+ const report = this.monitor.endTrial();
106
+ // P3: Attach the on_load performance.now() so downstream ingest can
107
+ // convert session-absolute event timestamps (e.g., tabAwayEvents[*].start)
108
+ // to trial-relative ms without heuristic offset estimation.
109
+ report.trialStart_perfNow = this._trialStart_perfNow;
110
+ this._trialStart_perfNow = null;
111
+ return { integrity: report };
112
+ }
113
+
114
+ // Called explicitly by the researcher from their initJsPsych({ on_finish })
115
+ // callback, BEFORE saving data. Example:
116
+ //
117
+ // const jsPsych = initJsPsych({
118
+ // extensions: [{ type: jsPsychCyborgHunter, params: { ... } }],
119
+ // on_finish: function () {
120
+ // jsPsych.extensions['cyborg-hunter'].finalize();
121
+ // jsPsych.data.get().localSave('csv', 'data.csv');
122
+ // }
123
+ // });
124
+ //
125
+ // Why this isn't automatic: jsPsych 7 has no on_finish_experiment extension
126
+ // hook (only initialize / on_start / on_load / on_finish exist). An earlier
127
+ // version of this wrapper defined on_finish_experiment and silently dropped
128
+ // session data because jsPsych never called it.
129
+ //
130
+ // Persists the full session report using Option A split attachment:
131
+ // - Scalars (counts, booleans, version) go to jsPsych.data.addProperties()
132
+ // so they land as columns on EVERY trial row — small, filterable in R/pandas.
133
+ // - Arrays and nested objects (sidebarEvents, integrityScore, etc.) go to
134
+ // jsPsych.data.addDataToLastTrial() so they're attached ONCE to the final
135
+ // trial row — avoids duplicating kilobytes of JSON across every row.
136
+ //
137
+ // After persisting, tears down all listeners and clears session state.
138
+ finalize() {
139
+ if (!this.monitor) return;
140
+
141
+ try {
142
+ // Single call — Task 5A made getSessionReport() the source of truth, so
143
+ // this object has both the accumulators AND the scoring summary.
144
+ const report = this.monitor.getSessionReport();
145
+
146
+ const {
147
+ // Arrays — go to last trial only (no CSV bloat).
148
+ sidebarEvents = [], keyboardShortcuts = [], windowPositions = [],
149
+ layoutShifts = [], zoomChanges = [], idleGaps = [], extensionInjections = [],
150
+ tabAwaySums = [], charsPerSec = [], aiExtensionsFound = [],
151
+ // Scoring summary — full object to last trial; key scalars also duplicated
152
+ // to every trial via addProperties below.
153
+ hardScore, softScore, anyHardTriggered, trialsCompleted, softScoreThreshold,
154
+ // Scalars.
155
+ pasteCount = 0, copyCount = 0, dropCount = 0,
156
+ libraryVersion, config,
157
+ // Anything else the monitor adds in the future — goes with the arrays
158
+ // so it's captured on the last trial without polluting every row.
159
+ ...extras
160
+ } = report;
161
+
162
+ // Scalars: one column per field, duplicated across every trial row.
163
+ // Cheap, useful for per-trial filtering and quick exclusion queries.
164
+ this.jsPsych.data.addProperties({
165
+ integrityPasteCount: pasteCount,
166
+ integrityCopyCount: copyCount,
167
+ integrityDropCount: dropCount,
168
+ integrityAnyHardTriggered: !!anyHardTriggered,
169
+ integritySoftScore: softScore,
170
+ cyborgHunterVersion: libraryVersion,
171
+ });
172
+
173
+ // Arrays + nested objects: attached once, on the final trial row.
174
+ this.jsPsych.data.addDataToLastTrial({
175
+ integritySession: {
176
+ pasteCount, copyCount, dropCount,
177
+ sidebarEvents, keyboardShortcuts, windowPositions,
178
+ layoutShifts, zoomChanges, idleGaps, extensionInjections,
179
+ tabAwaySums, charsPerSec, aiExtensionsFound,
180
+ ...extras
181
+ },
182
+ integrityScore: { hardScore, softScore, anyHardTriggered, trialsCompleted, softScoreThreshold },
183
+ });
184
+ } catch (e) {
185
+ // Never let a reporting error crash the experiment's end-of-session path.
186
+ console.warn('[cyborg-hunter] Failed to save session report:', e);
187
+ }
188
+
189
+ this.monitor.destroy();
190
+ }
191
+ }
192
+
193
+ // Export for both module and IIFE contexts.
194
+ // When loaded as a script tag, window.jsPsychCyborgHunter is the class
195
+ // that researchers pass to jsPsych's extensions array.
196
+ export { CyborgHunterExtension };
197
+ if (typeof window !== 'undefined') {
198
+ window.jsPsychCyborgHunter = CyborgHunterExtension;
199
+ }