cyborg-hunter 0.9.1 → 0.10.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/CHANGELOG.md +98 -0
- package/CITATION.cff +1 -1
- package/README.md +33 -32
- package/dist/ch.js +41 -0
- package/dist/cyborg-hunter-replay.js +3 -3
- package/dist/cyborg-hunter.esm.js +1 -1
- package/dist/cyborg-hunter.min.js +4 -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 +11 -5
- package/src/cli/extract-core.js +70 -4
- package/src/cli/renderers/replay-client-source.js +2 -2
- package/src/cli/segment-reassembly.js +204 -0
- package/src/jspsych/extension-cyborg-hunter-replay.js +1 -1
- package/src/jspsych/extension-cyborg-hunter.js +1 -1
- package/src/jspsych/extension-guard-friction.js +21 -7
- package/src/jspsych/extension-guard-honeypot.js +13 -6
- package/src/oneliner/adapters/jspsych-extension.js +196 -0
- package/src/oneliner/adapters/jspsych.js +483 -0
- package/src/oneliner/adapters/vanilla.js +420 -0
- package/src/oneliner/api.js +151 -0
- package/src/oneliner/boot.js +270 -0
- package/src/oneliner/config.js +122 -0
- package/src/oneliner/debug.js +137 -0
- package/src/oneliner/entry.js +8 -0
- package/src/oneliner/errors.js +219 -0
- package/src/oneliner/guards.js +67 -0
- package/src/oneliner/participant-id.js +49 -0
- package/src/oneliner/replay-loader.js +296 -0
- package/src/oneliner/segment-diff.js +68 -0
- package/src/oneliner/segmenter.js +186 -0
- package/src/replay/dom-instantiate.js +10 -1
- package/src/replay/index.js +4 -2
- package/src/replay/recorder.js +5 -1
- package/src/shared/constants.js +1 -1
- package/src/shared/schema-v2-validator.js +14 -9
package/package.json
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cyborg-hunter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"workspaces": [
|
|
5
|
+
"packages/*"
|
|
6
|
+
],
|
|
4
7
|
"description": "Detect LLM-assisted 'cyborg' behavior in online behavioral experiments",
|
|
5
8
|
"main": "dist/cyborg-hunter.min.js",
|
|
6
9
|
"module": "dist/cyborg-hunter.esm.js",
|
|
@@ -9,16 +12,21 @@
|
|
|
9
12
|
},
|
|
10
13
|
"scripts": {
|
|
11
14
|
"build": "node build.js",
|
|
15
|
+
"sync:conformance": "node scripts/sync-conformance.mjs",
|
|
16
|
+
"pretest": "node scripts/sync-conformance.mjs --check",
|
|
17
|
+
"prepack": "node scripts/sync-conformance.mjs",
|
|
18
|
+
"test:pack-smoke": "node scripts/pack-smoke.mjs",
|
|
12
19
|
"demo:preview": "node tools/build-preview-core.mjs",
|
|
13
|
-
"test:browser": "echo 'Open tests/browser/integrity-monitor.test.html in a browser'",
|
|
14
20
|
"test:cli": "node --test tests/cli/*.test.js",
|
|
15
21
|
"test:core": "node --test tests/core/*.test.js",
|
|
16
22
|
"test:jspsych": "node --test tests/jspsych/*.test.js",
|
|
23
|
+
"test:oneliner": "node --test tests/oneliner/*.test.js",
|
|
17
24
|
"test:tools": "node --test tests/tools/*.test.js",
|
|
18
25
|
"test:demo": "node --test tests/demo/*.test.js",
|
|
19
|
-
"test": "npm run test:core && npm run test:replay && npm run test:cli && npm run test:jspsych && npm run test:tools && npm run test:demo && npm run test:schema",
|
|
26
|
+
"test": "npm run test:core && npm run test:replay && npm run test:cli && npm run test:jspsych && npm run test:oneliner && npm run test:tools && npm run test:demo && npm run test:schema && npm run test:package",
|
|
20
27
|
"test:replay": "node --test tests/replay/*.test.js",
|
|
21
28
|
"test:schema": "node --test tests/replay/schema-v2/*.test.js",
|
|
29
|
+
"test:package": "npm test --workspace @cyborg-hunter/sessionrecording-conformance",
|
|
22
30
|
"test:browser:replay": "node tests/browser/replay/run-replay-tests.mjs",
|
|
23
31
|
"test:browser:e2e": "node tests/browser/replay/e2e-dogfood.mjs",
|
|
24
32
|
"test:browser:alignment": "node tests/browser/replay/cursor-alignment.battery.mjs",
|
|
@@ -26,7 +34,6 @@
|
|
|
26
34
|
"test:browser:canvas": "node tests/browser/replay/viewer-canvas.battery.mjs",
|
|
27
35
|
"test:browser:seek": "node tests/browser/replay/seek-budget.battery.mjs",
|
|
28
36
|
"test:browser:capture": "node tests/browser/replay/capture-chromium.battery.mjs",
|
|
29
|
-
"test:browser:fork": "node tests/browser/replay/capture-fork-smoke.mjs",
|
|
30
37
|
"test:e2e": "playwright test"
|
|
31
38
|
},
|
|
32
39
|
"files": [
|
|
@@ -66,7 +73,6 @@
|
|
|
66
73
|
},
|
|
67
74
|
"devDependencies": {
|
|
68
75
|
"@playwright/test": "^1.62.0",
|
|
69
|
-
"ajv": "^8.20.0",
|
|
70
76
|
"esbuild": "^0.21.0",
|
|
71
77
|
"happy-dom": "^20.9.0"
|
|
72
78
|
},
|
package/src/cli/extract-core.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { TRIAL_REPORT_FIELDS } from '../shared/schema.js';
|
|
17
17
|
import { getByPath } from '../shared/paths.js';
|
|
18
|
+
import { collectSegments, reassembleSegments, rebaseTrialReport } from './segment-reassembly.js';
|
|
18
19
|
|
|
19
20
|
// Extracts integrity trial data from a single participant's raw JSON.
|
|
20
21
|
// Returns { participantId, trials, warnings, metadata }.
|
|
@@ -53,6 +54,27 @@ export function extractIntegrityData(raw, config) {
|
|
|
53
54
|
trials = raw.trials
|
|
54
55
|
.filter(t => t && t[intField])
|
|
55
56
|
.map(t => ({ ...t, ...t[intField] }));
|
|
57
|
+
// One-line setup (0.10.0) across several pages: each page has its own
|
|
58
|
+
// performance.now() origin, recorded as integritySegment.pageOrigin.
|
|
59
|
+
// Re-base trials from later pages onto the first page's origin so the
|
|
60
|
+
// session timeline and the per-trial anchors below stay on one clock.
|
|
61
|
+
// Only the page-clock fields move (anchors + absolute-time event arrays,
|
|
62
|
+
// listed in segment-reassembly.js); trial-relative mouseTrack/elementTrace
|
|
63
|
+
// times must NOT move, or trajectories misplace every later-page trial.
|
|
64
|
+
// Single-page data, and data without segments, is untouched. A row whose
|
|
65
|
+
// trial was not segmented itself (it carries only the closing
|
|
66
|
+
// integritySegmentFinal) takes that segment's page.
|
|
67
|
+
const rowOrigin = t => t?.integritySegment?.pageOrigin ?? t?.integritySegmentFinal?.pageOrigin;
|
|
68
|
+
if (raw.trials.some(t => typeof rowOrigin(t) === 'number')) {
|
|
69
|
+
const origin0 = collectSegments(raw)[0]?.pageOrigin;
|
|
70
|
+
if (typeof origin0 === 'number') {
|
|
71
|
+
trials = trials.map(trial => {
|
|
72
|
+
const origin = rowOrigin(trial);
|
|
73
|
+
return (typeof origin === 'number' && origin !== origin0)
|
|
74
|
+
? rebaseTrialReport(trial, origin - origin0) : trial;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
56
78
|
// jsPsych extension data carries trialStart_perfNow per trial (set by the
|
|
57
79
|
// wrapper's on_load), so normalization takes the exact-subtraction fast
|
|
58
80
|
// path. Without it, renderers see only session-absolute `start` values
|
|
@@ -156,7 +178,7 @@ export function extractIntegrityData(raw, config) {
|
|
|
156
178
|
}
|
|
157
179
|
}
|
|
158
180
|
|
|
159
|
-
const { session, score } = findSessionData(raw, config);
|
|
181
|
+
const { session, score } = findSessionData(raw, config, warnings);
|
|
160
182
|
if (session === null && trials.length > 0) {
|
|
161
183
|
warnings.push('No session-level integrity data — some signals unavailable (did the experiment call getSessionReport()?)');
|
|
162
184
|
}
|
|
@@ -173,6 +195,16 @@ export function extractIntegrityData(raw, config) {
|
|
|
173
195
|
if (finalizeError) {
|
|
174
196
|
warnings.push(`finalize() failed for this participant (cyborgHunterFinalizeError): ${finalizeError} — session data may be incomplete`);
|
|
175
197
|
}
|
|
198
|
+
// The one-line setup (0.10.0) has no finalize(); when it hits an internal
|
|
199
|
+
// error it drops a `cyborgHunterError` marker instead. Same three locations.
|
|
200
|
+
const oneLinerError = raw.cyborgHunterError
|
|
201
|
+
?? raw.metadata?.cyborgHunterError
|
|
202
|
+
?? (Array.isArray(raw.trials)
|
|
203
|
+
? raw.trials.find(t => t?.cyborgHunterError)?.cyborgHunterError
|
|
204
|
+
: undefined);
|
|
205
|
+
if (oneLinerError) {
|
|
206
|
+
warnings.push(`one-line setup reported an error for this participant (cyborgHunterError): ${oneLinerError} — data after that point may be incomplete`);
|
|
207
|
+
}
|
|
176
208
|
|
|
177
209
|
return {
|
|
178
210
|
participantId,
|
|
@@ -227,10 +259,17 @@ function looksLikeSessionData(obj) {
|
|
|
227
259
|
// 4. raw.cyborgHunter — native top-level location used by raw-DOM
|
|
228
260
|
// adopters before they adopt the
|
|
229
261
|
// metadata.integritySession mirror. Added 2026-05-26.
|
|
230
|
-
//
|
|
231
|
-
|
|
262
|
+
// 5. Rolling snapshot (0.10.0 one-line setup) — per-row integritySegment
|
|
263
|
+
// deltas, reassembled by src/cli/segment-reassembly.js.
|
|
264
|
+
// Returns { session, score }, both null if not found. `warnings` receives a
|
|
265
|
+
// note when a dumped session and rolling segments are both present. Not
|
|
266
|
+
// exported (ingest.js re-exports only extractIntegrityData and
|
|
267
|
+
// ruleChronologicalCompare); its one caller always passes `warnings`, and the
|
|
268
|
+
// `= []` default only keeps a warnings-less call from throwing.
|
|
269
|
+
function findSessionData(raw, config, warnings = []) {
|
|
232
270
|
let session = null;
|
|
233
271
|
let score = null;
|
|
272
|
+
let usedSegments = false;
|
|
234
273
|
|
|
235
274
|
// 0. Analyst-specified location, e.g. "payload.cyborgHunter" for pipelines
|
|
236
275
|
// that nest the getSessionReport() output somewhere non-standard. Falls
|
|
@@ -273,6 +312,26 @@ function findSessionData(raw, config) {
|
|
|
273
312
|
session = raw.cyborgHunter;
|
|
274
313
|
score = null;
|
|
275
314
|
}
|
|
315
|
+
// 5. Rolling snapshot (0.10.0 one-line setup): per-row integritySegment deltas,
|
|
316
|
+
// concatenated in segmentIndex order into the same shape finalize() dumps.
|
|
317
|
+
else {
|
|
318
|
+
const segs = collectSegments(raw);
|
|
319
|
+
if (segs.length > 0) {
|
|
320
|
+
const rolled = reassembleSegments(segs);
|
|
321
|
+
session = rolled.session;
|
|
322
|
+
score = rolled.score;
|
|
323
|
+
usedSegments = true;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// A file carrying both is a study that mixed manual finalize() with the
|
|
328
|
+
// one-line setup. The dumped session is complete by construction, so it
|
|
329
|
+
// wins; say so rather than silently ignoring the segments. This also fires
|
|
330
|
+
// when branch 0 (the analyst's sessionIntegrityPath) found the session,
|
|
331
|
+
// since that leaves usedSegments false too.
|
|
332
|
+
if (session && !usedSegments && collectSegments(raw).length > 0) {
|
|
333
|
+
warnings.push('both a dumped integritySession and rolling segments (integritySegment) were found — the dumped integritySession was used; this file mixes manual mode and the one-line setup');
|
|
334
|
+
}
|
|
276
335
|
|
|
277
336
|
// When no separate integrityScore blob was saved, synthesize the authoritative
|
|
278
337
|
// score from the session object itself. getSessionReport() embeds the scoring
|
|
@@ -320,7 +379,14 @@ function scoreFromSession(session) {
|
|
|
320
379
|
// (or metadata). Each entry is { reason, start, end, duration, in_progress };
|
|
321
380
|
// the renderer keys off `t` (perfNow ms), so map start → t. Apps that hand-write
|
|
322
381
|
// a top-level `raw.guardFriction` object still take precedence over this.
|
|
382
|
+
// One-line setup across several pages (0.10.0 vanilla host): each page's
|
|
383
|
+
// entries carry that page's `pageOrigin`, and `t` is re-based onto the first
|
|
384
|
+
// segment's origin like the rest of the session; entries without one keep
|
|
385
|
+
// their `start`.
|
|
323
386
|
function findGuardViolations(raw) {
|
|
387
|
+
const origin0 = collectSegments(raw)[0]?.pageOrigin;
|
|
388
|
+
const offset = (v) => (typeof v.pageOrigin === 'number' && typeof origin0 === 'number')
|
|
389
|
+
? v.pageOrigin - origin0 : 0;
|
|
324
390
|
const parseArr = (v) => {
|
|
325
391
|
if (Array.isArray(v)) return v;
|
|
326
392
|
if (typeof v === 'string') { try { return JSON.parse(v); } catch { return null; } }
|
|
@@ -353,7 +419,7 @@ function findGuardViolations(raw) {
|
|
|
353
419
|
if (Array.isArray(arr)) {
|
|
354
420
|
const violations = arr
|
|
355
421
|
.filter(v => v && typeof v.start === 'number')
|
|
356
|
-
.map(v => ({ t: v.start, reason: v.reason || 'unknown', phase: 'unknown', duration_ms: v.duration }));
|
|
422
|
+
.map(v => ({ t: v.start + offset(v), reason: v.reason || 'unknown', phase: 'unknown', duration_ms: v.duration }));
|
|
357
423
|
// Fall through to the next source on an empty/violation-less candidate
|
|
358
424
|
// instead of locking onto it — otherwise an empty placeholder (e.g. a
|
|
359
425
|
// metadata mirror set to []) would shadow real violations on the trial rows.
|
|
@@ -25,11 +25,11 @@
|
|
|
25
25
|
// tools/assemble-demo-site.mjs → this module (writes the ASSEMBLED file,
|
|
26
26
|
// which demo/results.js fetches by name)
|
|
27
27
|
// cursor-alignment.battery.mjs → this module (Task 8's harness)
|
|
28
|
-
//
|
|
28
|
+
// cyborg-hunter-lab: probes/investigate/probe-support.mjs → NOT a consumer of the assembly: it
|
|
29
29
|
// extracts the literal `srcdocCsp()` out
|
|
30
30
|
// of the client source and needs the raw
|
|
31
31
|
// file, not the bundle.
|
|
32
|
-
//
|
|
32
|
+
// cyborg-hunter-lab: probes/investigate/cursor-alignment-probe.mjs → superseded (Task 1).
|
|
33
33
|
|
|
34
34
|
import { readFileSync } from 'fs';
|
|
35
35
|
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// src/cli/segment-reassembly.js
|
|
2
|
+
// CLI side of the rolling snapshot (0.10.0 one-line setup). The browser cuts
|
|
3
|
+
// the monitor's session arrays into per-segment deltas
|
|
4
|
+
// (src/oneliner/segment-diff.js) and saves one `integritySegment` per data
|
|
5
|
+
// row; this module puts them back together into the same session object
|
|
6
|
+
// finalize() dumps as `integritySession`, plus the score.
|
|
7
|
+
//
|
|
8
|
+
// Pure: no imports, no Node APIs — extract-core.js (bundled by the browser
|
|
9
|
+
// demo) depends on it.
|
|
10
|
+
|
|
11
|
+
// Field names that hold a performance.now()-style time. Everything else
|
|
12
|
+
// (duration_ms, ISO `timestamp` strings, counts) is left alone when re-basing.
|
|
13
|
+
// rebaseTimes() applies this at any depth, which is right for the SESSION
|
|
14
|
+
// arrays (every entry's t/start is a raw page performance.now()), but not for
|
|
15
|
+
// a whole trial report — use rebaseTrialReport() for those.
|
|
16
|
+
// Bare-number arrays are never shifted: editTimestamps holds absolute
|
|
17
|
+
// performance.now() values too, but the CLI only uses their differences
|
|
18
|
+
// (typing speed), so they are intentionally left on their own page's clock.
|
|
19
|
+
const TIME_KEYS = new Set(['t', 'start', 'startTime', 'trialStart_perfNow']);
|
|
20
|
+
|
|
21
|
+
// Mirrors ALIAS_KEYS in src/oneliner/segment-diff.js (a test pins the two
|
|
22
|
+
// equal): the alias key is not shipped in segments, so it is restored here
|
|
23
|
+
// pointing at its canonical key.
|
|
24
|
+
export const ALIAS_KEYS = { layoutShifts: 'viewportWidthShifts' };
|
|
25
|
+
|
|
26
|
+
// Trial-report fields that carry the page's performance.now() clock and so
|
|
27
|
+
// move with the page origin. Time base of each, from src/core:
|
|
28
|
+
// startTime performance.now() at startTrial (monitor.js startTrial)
|
|
29
|
+
// trialStart_perfNow performance.now() at on_load (jspsych extension on_load)
|
|
30
|
+
// pasteEvents[].t performance.now() (signals/clipboard.js)
|
|
31
|
+
// copyEvents[].t performance.now() (signals/clipboard.js)
|
|
32
|
+
// dropEvents[].t performance.now() (signals/clipboard.js)
|
|
33
|
+
// tabAwayEvents[].start performance.now() at leave (signals/focus.js)
|
|
34
|
+
// idleGaps[].t performance.now() (signals/focus.js)
|
|
35
|
+
// syntheticInsertions[].t performance.now() (signals/typing.js)
|
|
36
|
+
// foreignInputEvents[].t performance.now() (signals/typing.js)
|
|
37
|
+
// NOT shifted (left as they are):
|
|
38
|
+
// mouseTrack/mouseEvents[].t ms since trial start (signals/mouse.js)
|
|
39
|
+
// elementTrace[].t ms since trial start (signals/browser.js)
|
|
40
|
+
// mouseTrackingCappedAtMs, duration_ms durations
|
|
41
|
+
// editTimestamps absolute, bare numbers — see TIME_KEYS note
|
|
42
|
+
// anything else on the row (integrity, integritySegment, jsPsych columns)
|
|
43
|
+
const TRIAL_ANCHOR_KEYS = ['startTime', 'trialStart_perfNow'];
|
|
44
|
+
const TRIAL_PAGE_TIME_ARRAYS = ['pasteEvents', 'copyEvents', 'dropEvents', 'tabAwayEvents',
|
|
45
|
+
'idleGaps', 'syntheticInsertions', 'foreignInputEvents'];
|
|
46
|
+
|
|
47
|
+
// Returns a shallow copy of a merged trial report with only the page-clock
|
|
48
|
+
// fields above shifted by offsetMs. Offset 0 returns the input itself.
|
|
49
|
+
export function rebaseTrialReport(trial, offsetMs) {
|
|
50
|
+
if (!offsetMs || !trial || typeof trial !== 'object') return trial;
|
|
51
|
+
const out = { ...trial };
|
|
52
|
+
for (const k of TRIAL_ANCHOR_KEYS) {
|
|
53
|
+
if (typeof out[k] === 'number') out[k] += offsetMs;
|
|
54
|
+
}
|
|
55
|
+
for (const k of TRIAL_PAGE_TIME_ARRAYS) {
|
|
56
|
+
if (Array.isArray(out[k])) out[k] = rebaseTimes(out[k], offsetMs);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Returns a deep copy of `value` with every numeric property named t, start,
|
|
62
|
+
// startTime or trialStart_perfNow shifted by offsetMs, at any depth.
|
|
63
|
+
// Offset 0 returns the input itself (no copy), so a single-page session costs
|
|
64
|
+
// nothing.
|
|
65
|
+
export function rebaseTimes(value, offsetMs) {
|
|
66
|
+
if (!offsetMs) return value;
|
|
67
|
+
return shift(value, offsetMs);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function shift(value, offsetMs) {
|
|
71
|
+
if (Array.isArray(value)) return value.map(v => shift(v, offsetMs));
|
|
72
|
+
if (!value || typeof value !== 'object') return value;
|
|
73
|
+
const out = {};
|
|
74
|
+
for (const [k, v] of Object.entries(value)) {
|
|
75
|
+
out[k] = (TIME_KEYS.has(k) && typeof v === 'number') ? v + offsetMs : shift(v, offsetMs);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isSegment(x) {
|
|
81
|
+
return !!x && typeof x === 'object' && !Array.isArray(x) && typeof x.segmentIndex === 'number';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Gathers every segment saved in a participant file, sorted by segmentIndex.
|
|
85
|
+
// Sources: raw.trials[*].integritySegment, raw.trials[*].integritySegmentFinal
|
|
86
|
+
// (any row) and raw.integritySegments (array). Non-objects are skipped — a CSV
|
|
87
|
+
// cell that failed to JSON.parse stays a string. When the same index appears
|
|
88
|
+
// twice, the first occurrence wins.
|
|
89
|
+
export function collectSegments(raw) {
|
|
90
|
+
const found = [];
|
|
91
|
+
if (raw && Array.isArray(raw.trials)) {
|
|
92
|
+
for (const row of raw.trials) {
|
|
93
|
+
if (!row || typeof row !== 'object') continue;
|
|
94
|
+
if (isSegment(row.integritySegment)) found.push(row.integritySegment);
|
|
95
|
+
if (isSegment(row.integritySegmentFinal)) found.push(row.integritySegmentFinal);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (raw && Array.isArray(raw.integritySegments)) {
|
|
99
|
+
for (const s of raw.integritySegments) if (isSegment(s)) found.push(s);
|
|
100
|
+
}
|
|
101
|
+
// Array.prototype.sort is stable, so on equal indices the earlier-found
|
|
102
|
+
// segment stays first and the duplicate check below keeps it.
|
|
103
|
+
found.sort((a, b) => a.segmentIndex - b.segmentIndex);
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const s of found) {
|
|
106
|
+
if (out.length > 0 && out[out.length - 1].segmentIndex === s.segmentIndex) continue;
|
|
107
|
+
out.push(s);
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Concatenates segment deltas in segmentIndex order into the finalize() dump
|
|
113
|
+
// shape: { pasteCount, copyCount, dropCount, <every array key>, layoutShifts,
|
|
114
|
+
// config } — score fields and libraryVersion are NOT in the session (finalize()
|
|
115
|
+
// stores them separately). Returns { session, score, pageOrigins } or null for
|
|
116
|
+
// no segments. Segments from a later page (different pageOrigin) have their
|
|
117
|
+
// times re-based to the first page's origin. Counters and score add up the
|
|
118
|
+
// pages (pageTotals below); single-page data keeps the last segment's score
|
|
119
|
+
// object as it is.
|
|
120
|
+
export function reassembleSegments(segments) {
|
|
121
|
+
if (!Array.isArray(segments) || segments.length === 0) return null;
|
|
122
|
+
const sorted = segments.slice().sort((a, b) => a.segmentIndex - b.segmentIndex);
|
|
123
|
+
|
|
124
|
+
const pageOrigins = [];
|
|
125
|
+
for (const s of sorted) {
|
|
126
|
+
if (!pageOrigins.includes(s.pageOrigin)) pageOrigins.push(s.pageOrigin);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const session = {};
|
|
130
|
+
for (const s of sorted) {
|
|
131
|
+
const offset = (typeof s.pageOrigin === 'number' && typeof pageOrigins[0] === 'number')
|
|
132
|
+
? s.pageOrigin - pageOrigins[0] : 0;
|
|
133
|
+
for (const [key, entries] of Object.entries(s.deltas || {})) {
|
|
134
|
+
if (!Array.isArray(entries)) continue;
|
|
135
|
+
session[key] = (session[key] || []).concat(rebaseTimes(entries, offset));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const [alias, canonical] of Object.entries(ALIAS_KEYS)) {
|
|
139
|
+
if (!session[canonical]) session[canonical] = [];
|
|
140
|
+
session[alias] = session[canonical]; // same array, as in the monitor
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Counters and score are cumulative per monitor, and every page load runs a
|
|
144
|
+
// new monitor: the session total is the sum over pages of each page's last
|
|
145
|
+
// segment. `?? 0` matches finalize()'s destructuring defaults.
|
|
146
|
+
const lasts = pageLasts(sorted);
|
|
147
|
+
for (const k of ['pasteCount', 'copyCount', 'dropCount']) {
|
|
148
|
+
session[k] = lasts.reduce((sum, s) => sum + ((s.counters || {})[k] ?? 0), 0);
|
|
149
|
+
}
|
|
150
|
+
const withConfig = sorted.find(s => s.config);
|
|
151
|
+
session.config = withConfig ? withConfig.config : undefined;
|
|
152
|
+
|
|
153
|
+
return { session, score: sessionScore(lasts), pageOrigins };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The last segment of each page, in page order. A page is identified by its
|
|
157
|
+
// pageOrigin, not by a run of consecutive segments: a page shown again from
|
|
158
|
+
// the back/forward cache keeps its origin and its monitor, so its last
|
|
159
|
+
// segment already holds everything that monitor counted, earlier visit
|
|
160
|
+
// included (summing per run would count the first visit twice).
|
|
161
|
+
function pageLasts(sorted) {
|
|
162
|
+
const byOrigin = new Map();
|
|
163
|
+
for (const s of sorted) byOrigin.set(s.pageOrigin, s); // sorted: the last one wins
|
|
164
|
+
return [...byOrigin.values()].sort((a, b) => a.segmentIndex - b.segmentIndex);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// One page: that segment's score object, untouched. Several pages: the same
|
|
168
|
+
// shape as the monitor's (src/core/scoring.js:130-139, monitor.js:413-420),
|
|
169
|
+
// added up. Each hard signal's count is the sum of the pages' counts, its
|
|
170
|
+
// threshold the last page's, triggered = count >= threshold; softScore and
|
|
171
|
+
// trialsCompleted are sums; softScoreThreshold is the last page's.
|
|
172
|
+
function sessionScore(lasts) {
|
|
173
|
+
const scored = lasts.filter(s => s.score && typeof s.score === 'object');
|
|
174
|
+
if (scored.length === 0) return lasts[lasts.length - 1].score ?? null;
|
|
175
|
+
const last = scored[scored.length - 1].score;
|
|
176
|
+
if (lasts.length === 1) return last;
|
|
177
|
+
|
|
178
|
+
const hardScore = {};
|
|
179
|
+
let softScore = 0;
|
|
180
|
+
let trialsCompleted = 0;
|
|
181
|
+
for (const { score } of scored) {
|
|
182
|
+
for (const [k, h] of Object.entries(score.hardScore || {})) {
|
|
183
|
+
if (!h || typeof h !== 'object') continue;
|
|
184
|
+
if (!hardScore[k]) hardScore[k] = { count: 0, threshold: undefined, triggered: false };
|
|
185
|
+
hardScore[k].count += h.count ?? 0;
|
|
186
|
+
if (h.threshold !== undefined) hardScore[k].threshold = h.threshold;
|
|
187
|
+
}
|
|
188
|
+
softScore += score.softScore ?? 0;
|
|
189
|
+
trialsCompleted += score.trialsCompleted ?? 0;
|
|
190
|
+
}
|
|
191
|
+
for (const k of Object.keys(hardScore)) {
|
|
192
|
+
const lastThreshold = last.hardScore?.[k]?.threshold;
|
|
193
|
+
if (lastThreshold !== undefined) hardScore[k].threshold = lastThreshold;
|
|
194
|
+
hardScore[k].triggered = typeof hardScore[k].threshold === 'number' && hardScore[k].count >= hardScore[k].threshold;
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
...last,
|
|
198
|
+
hardScore,
|
|
199
|
+
softScore,
|
|
200
|
+
softScoreThreshold: last.softScoreThreshold,
|
|
201
|
+
anyHardTriggered: Object.values(hardScore).some(h => h.triggered),
|
|
202
|
+
trialsCompleted
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -35,7 +35,7 @@ import { buildReplayMeta } from '../replay/persistence.js';
|
|
|
35
35
|
class CyborgHunterReplayExtension {
|
|
36
36
|
static info = {
|
|
37
37
|
name: 'cyborg-hunter-replay',
|
|
38
|
-
version: '0.
|
|
38
|
+
version: '0.10.0',
|
|
39
39
|
data: {} // per-trial return is {}; session meta goes via addProperties
|
|
40
40
|
};
|
|
41
41
|
|
|
@@ -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.10.0',
|
|
14
14
|
data: { integrity: { type: 'object' } }
|
|
15
15
|
};
|
|
16
16
|
|
|
@@ -954,6 +954,9 @@ export function exitFullscreenFnOf(doc) {
|
|
|
954
954
|
active: state.active,
|
|
955
955
|
in_violation: !!state.currentViolation,
|
|
956
956
|
current_reason: state.currentViolation ? state.currentViolation.reason : null,
|
|
957
|
+
// true while violations are only logged (no curtain); the entry
|
|
958
|
+
// trial switches it off — enforcement starts at that mark.
|
|
959
|
+
observe_only: state.observeOnly,
|
|
957
960
|
};
|
|
958
961
|
}
|
|
959
962
|
|
|
@@ -981,7 +984,11 @@ export function exitFullscreenFnOf(doc) {
|
|
|
981
984
|
logDebug('entry_trial.on_finish', { diagnostics: getDiagnostics() });
|
|
982
985
|
requestFullscreen();
|
|
983
986
|
_setTimeout(() => {
|
|
984
|
-
|
|
987
|
+
// observeOnly: false explicitly — start() keeps the prior
|
|
988
|
+
// value when the option is absent, so a session started
|
|
989
|
+
// observe-only (the one-liner's friction before its mark)
|
|
990
|
+
// would otherwise never enforce from this mark on.
|
|
991
|
+
const token = start({ jsPsych: state.jsPsych, observeOnly: false });
|
|
985
992
|
// Non-enumerable stash so the experiment's on_finish can
|
|
986
993
|
// pass the token to stop() without exposing it on a
|
|
987
994
|
// discoverable property name. configurable:true so the
|
|
@@ -1118,11 +1125,18 @@ export function exitFullscreenFnOf(doc) {
|
|
|
1118
1125
|
};
|
|
1119
1126
|
|
|
1120
1127
|
Object.freeze(api);
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1128
|
+
// A second copy of this core on the page (ch.js bundles it, so ch.js plus
|
|
1129
|
+
// this file's own <script> tag, in either order) keeps the first
|
|
1130
|
+
// definition instead of throwing "Cannot redefine property".
|
|
1131
|
+
if (!global.GuardFriction) {
|
|
1132
|
+
Object.defineProperty(global, 'GuardFriction', {
|
|
1133
|
+
value: api,
|
|
1134
|
+
writable: false,
|
|
1135
|
+
configurable: false,
|
|
1136
|
+
});
|
|
1137
|
+
} else {
|
|
1138
|
+
console.error('[cyborg-hunter] Not redefining GuardFriction: GuardFriction is already defined, so two scripts on this page include the friction guard. Fix: keep one of them (ch.js already contains the friction guard). https://github.com/cyborg-hunter/cyborg-hunter/blob/main/docs/advanced-integration.md#double-load');
|
|
1139
|
+
}
|
|
1126
1140
|
})(window);
|
|
1127
1141
|
// ----- jsPsych extension adapter -----
|
|
1128
1142
|
// Wraps the GuardFriction core (above) in the jsPsych extension
|
|
@@ -1144,7 +1158,7 @@ class GuardFrictionExtension {
|
|
|
1144
1158
|
// Hand-bumped on each release to track the package version
|
|
1145
1159
|
// (package.json / src/shared/constants.js). Shown in the jsPsych developer
|
|
1146
1160
|
// console only; the runtime library version is independent.
|
|
1147
|
-
version: '0.
|
|
1161
|
+
version: '0.10.0',
|
|
1148
1162
|
data: {}
|
|
1149
1163
|
};
|
|
1150
1164
|
|
|
@@ -421,11 +421,18 @@
|
|
|
421
421
|
};
|
|
422
422
|
|
|
423
423
|
Object.freeze(api);
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
424
|
+
// A second copy of this core on the page (ch.js bundles it, so ch.js plus
|
|
425
|
+
// this file's own <script> tag, in either order) keeps the first
|
|
426
|
+
// definition instead of throwing "Cannot redefine property".
|
|
427
|
+
if (!global.GuardHoneypot) {
|
|
428
|
+
Object.defineProperty(global, 'GuardHoneypot', {
|
|
429
|
+
value: api,
|
|
430
|
+
writable: false,
|
|
431
|
+
configurable: false,
|
|
432
|
+
});
|
|
433
|
+
} else {
|
|
434
|
+
console.error('[cyborg-hunter] Not redefining GuardHoneypot: GuardHoneypot is already defined, so two scripts on this page include the honeypot. Fix: keep one of them (ch.js already contains the honeypot). https://github.com/cyborg-hunter/cyborg-hunter/blob/main/docs/advanced-integration.md#double-load');
|
|
435
|
+
}
|
|
429
436
|
})(window);
|
|
430
437
|
// ----- jsPsych extension adapter -----
|
|
431
438
|
// Wraps the GuardHoneypot core (above) in the jsPsych extension
|
|
@@ -442,7 +449,7 @@
|
|
|
442
449
|
class GuardHoneypotExtension {
|
|
443
450
|
static info = {
|
|
444
451
|
name: 'guard-honeypot',
|
|
445
|
-
version: '0.
|
|
452
|
+
version: '0.10.0',
|
|
446
453
|
// Per-trial fields written by on_finish. Reflect violations for this
|
|
447
454
|
// trial only; jsPsych spreads them onto each trial row (CSV columns).
|
|
448
455
|
// Session totals are written as global properties by finalize().
|