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.
- package/README.md +145 -0
- package/bin/cyborg-hunter.js +20 -0
- package/dist/cyborg-hunter.esm.js +1534 -0
- package/dist/cyborg-hunter.min.js +6 -0
- package/dist/jspsych-cyborg-hunter.js +1 -0
- package/package.json +56 -0
- package/src/cli/analyzers/edge-exit.js +56 -0
- package/src/cli/analyzers/summary.js +99 -0
- package/src/cli/analyzers/triage.js +104 -0
- package/src/cli/config.js +99 -0
- package/src/cli/ingest.js +343 -0
- package/src/cli/init.js +31 -0
- package/src/cli/renderers/event-log.js +66 -0
- package/src/cli/renderers/extensions.js +43 -0
- package/src/cli/renderers/html-index.js +329 -0
- package/src/cli/renderers/summary-csv.js +70 -0
- package/src/cli/renderers/tab-timeline.js +149 -0
- package/src/cli/renderers/trajectories.js +607 -0
- package/src/cli/renderers/triage-md.js +29 -0
- package/src/cli/renderers/typing-profile.js +200 -0
- package/src/cli/report.js +100 -0
- package/src/core/index.js +13 -0
- package/src/core/monitor.js +404 -0
- package/src/core/scoring.js +153 -0
- package/src/core/signals/browser.js +303 -0
- package/src/core/signals/clipboard.js +91 -0
- package/src/core/signals/dom-protection.js +285 -0
- package/src/core/signals/focus.js +101 -0
- package/src/core/signals/mouse.js +117 -0
- package/src/core/signals/typing.js +110 -0
- package/src/core/state-machine.js +88 -0
- package/src/jspsych/extension.js +199 -0
- package/src/shared/constants.js +162 -0
- package/src/shared/schema.js +56 -0
- package/src/shared/validation.js +73 -0
|
@@ -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
|
+
}
|