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.
- package/CHANGELOG.md +156 -0
- package/CITATION.cff +29 -0
- package/LICENSE +21 -0
- package/README.md +80 -21
- package/bin/cyborg-hunter.js +6 -2
- package/dist/cyborg-hunter-replay.js +3 -0
- package/dist/cyborg-hunter.esm.js +114 -22
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/dist/extension-guard-friction.js +5 -5
- package/dist/extension-guard-honeypot.js +1 -1
- package/package.json +15 -3
- package/src/cli/analyzers/edge-exit.js +4 -1
- package/src/cli/analyzers/phase-scope.js +83 -0
- package/src/cli/analyzers/summary.js +161 -28
- package/src/cli/analyzers/triage.js +59 -27
- package/src/cli/config.js +26 -1
- package/src/cli/extract-core.js +552 -0
- package/src/cli/ingest.js +250 -193
- package/src/cli/init.js +1 -1
- package/src/cli/preview-entry.js +36 -0
- package/src/cli/renderers/event-log.js +18 -19
- package/src/cli/renderers/extensions.js +12 -3
- package/src/cli/renderers/html-index-core.js +1273 -0
- package/src/cli/renderers/html-index.js +13 -1047
- package/src/cli/renderers/replay-assets.js +67 -0
- package/src/cli/renderers/replay-viewer.client.js +1185 -0
- package/src/cli/renderers/session-timeline-core.js +907 -0
- package/src/cli/renderers/session-timeline.js +33 -0
- package/src/cli/renderers/summary-csv.js +5 -0
- package/src/cli/renderers/trajectories-core.js +717 -0
- package/src/cli/renderers/trajectories.js +31 -635
- package/src/cli/renderers/triage-md.js +10 -4
- package/src/cli/renderers/typing-profile-core.js +211 -0
- package/src/cli/renderers/typing-profile.js +16 -186
- package/src/cli/report.js +42 -8
- package/src/core/monitor.js +77 -7
- package/src/core/scoring.js +11 -2
- package/src/core/signals/browser.js +51 -18
- package/src/core/signals/clipboard.js +10 -2
- package/src/core/signals/dom-protection.js +9 -0
- package/src/core/signals/focus.js +16 -2
- package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
- package/src/jspsych/extension-cyborg-hunter.js +9 -2
- package/src/jspsych/extension-guard-friction.js +43 -14
- package/src/jspsych/extension-guard-honeypot.js +25 -1
- package/src/replay/capture-dom.js +575 -0
- package/src/replay/capture-trace.js +468 -0
- package/src/replay/index.js +104 -0
- package/src/replay/persistence.js +141 -0
- package/src/replay/recorder.js +315 -0
- package/src/replay/serializer.js +119 -0
- package/src/replay/viewer-model.js +125 -0
- package/src/shared/constants.js +12 -6
- package/src/shared/paths.js +20 -0
- package/src/shared/schema.js +5 -0
- package/src/shared/validation.js +55 -0
- package/src/cli/renderers/tab-timeline.js +0 -149
package/src/core/monitor.js
CHANGED
|
@@ -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: [],
|
|
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;
|
package/src/core/scoring.js
CHANGED
|
@@ -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 <=
|
|
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 <=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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 },
|