cyborg-hunter 0.5.0 → 0.7.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/CITATION.cff +29 -0
  3. package/LICENSE +21 -0
  4. package/README.md +78 -21
  5. package/package.json +10 -3
  6. package/src/cli/analyzers/edge-exit.js +4 -1
  7. package/src/cli/analyzers/phase-scope.js +83 -0
  8. package/src/cli/analyzers/summary.js +161 -28
  9. package/src/cli/analyzers/triage.js +59 -27
  10. package/src/cli/config.js +26 -1
  11. package/src/cli/ingest.js +623 -41
  12. package/src/cli/init.js +1 -1
  13. package/src/cli/renderers/event-log.js +18 -19
  14. package/src/cli/renderers/extensions.js +12 -3
  15. package/src/cli/renderers/html-index.js +163 -24
  16. package/src/cli/renderers/replay-assets.js +177 -0
  17. package/src/cli/renderers/replay-viewer.client.js +1022 -0
  18. package/src/cli/renderers/session-timeline.js +917 -0
  19. package/src/cli/renderers/summary-csv.js +5 -0
  20. package/src/cli/renderers/trajectories.js +68 -7
  21. package/src/cli/renderers/triage-md.js +10 -4
  22. package/src/cli/renderers/typing-profile.js +7 -1
  23. package/src/cli/report.js +42 -8
  24. package/src/core/monitor.js +60 -7
  25. package/src/core/scoring.js +11 -2
  26. package/src/core/signals/browser.js +51 -18
  27. package/src/core/signals/clipboard.js +10 -2
  28. package/src/core/signals/dom-protection.js +9 -0
  29. package/src/core/signals/focus.js +16 -2
  30. package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
  31. package/src/jspsych/extension-cyborg-hunter.js +9 -2
  32. package/src/jspsych/extension-guard-friction.js +32 -12
  33. package/src/jspsych/extension-guard-honeypot.js +25 -1
  34. package/src/replay/capture-dom.js +575 -0
  35. package/src/replay/capture-trace.js +468 -0
  36. package/src/replay/index.js +104 -0
  37. package/src/replay/persistence.js +141 -0
  38. package/src/replay/recorder.js +315 -0
  39. package/src/replay/serializer.js +119 -0
  40. package/src/shared/constants.js +12 -6
  41. package/src/shared/schema.js +5 -0
  42. package/src/shared/validation.js +55 -0
  43. package/dist/cyborg-hunter.esm.js +0 -1527
  44. package/dist/cyborg-hunter.min.js +0 -6
  45. package/dist/extension-cyborg-hunter.js +0 -1
  46. package/dist/extension-guard-friction.js +0 -36
  47. package/dist/extension-guard-honeypot.js +0 -1
  48. package/src/cli/renderers/tab-timeline.js +0 -149
@@ -0,0 +1,141 @@
1
+ // src/replay/persistence.js
2
+ // Artifact naming, autosave, and the integrityReplayMeta pointer.
3
+ //
4
+ // Delivery semantics are honest by design (design §10): the artifact has the
5
+ // same delivery guarantees as the jsPsych data itself. There is no fallback
6
+ // chain — the configured mode either works or reports saved_to:'failed', and
7
+ // the analyst-facing meta pointer always states what actually happened.
8
+
9
+ var DATAPIPE_URL = 'https://pipe.jspsych.org/api/data/';
10
+
11
+ import { sanitizeId } from '../shared/constants.js';
12
+
13
+ /**
14
+ * <pid>-replay-<sessionStartEpochMs>.json — the epoch suffix makes reloads
15
+ * produce distinct artifacts instead of overwriting (CLI picks the latest
16
+ * and warns on multiples).
17
+ */
18
+ export function replayFilename(recording) {
19
+ var pid = sanitizeId(recording.metadata.participant_id);
20
+ var epoch = Date.parse(recording.metadata.start_time);
21
+ return pid + '-replay-' + epoch + '.json';
22
+ }
23
+
24
+ /**
25
+ * The small pointer that rides along in the jsPsych data (one
26
+ * addProperties call) so the analyst can tell from the CSV alone whether a
27
+ * replay exists, how big it was, and where it went.
28
+ */
29
+ export function buildReplayMeta(recording, savedTo) {
30
+ return {
31
+ schema_version: recording.schema_version,
32
+ tier: recording.metadata.tier,
33
+ saved_to: savedTo,
34
+ bytes_uncompressed: JSON.stringify(recording).length,
35
+ capture_failures: (recording.ch_extensions.capture_failures || [])
36
+ .map(function (f) { return f.channel; }),
37
+ capture_stopped: !!recording.ch_extensions.capture_stopped
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Saves the recording using the configured mode. Never throws — failures
43
+ * come back as { saved_to: 'failed', error } so the experiment's finish
44
+ * path is never interrupted.
45
+ *
46
+ * Modes:
47
+ * datapipe — window.jsPsychPipe.saveData if loaded, else a direct POST to
48
+ * the DataPipe API. Sends PLAIN JSON (DataPipe stores strings;
49
+ * base64-gzip would make the OSF file unreadable for analysts).
50
+ * download — synthetic-anchor download (participant's machine!). Useful
51
+ * for local piloting; the CLI warns if it sees this in meta.
52
+ * none — recording stays in memory; researcher handles it.
53
+ */
54
+ export async function autoSave(recording, autoSaveConfig) {
55
+ var cfg = autoSaveConfig || { mode: 'none' };
56
+ var filename = replayFilename(recording);
57
+ var w = typeof window !== 'undefined' ? window : {};
58
+
59
+ if (cfg.mode === 'none') {
60
+ return { saved_to: 'none', filename: filename };
61
+ }
62
+
63
+ if (cfg.mode === 'datapipe') {
64
+ if (!cfg.experimentId) {
65
+ return { saved_to: 'failed', filename: filename,
66
+ error: 'autoSave.mode "datapipe" requires autoSave.experimentId' };
67
+ }
68
+ try {
69
+ // Inside the try: JSON.stringify can throw (circular ref, BigInt) and
70
+ // this function is contractually never-throw — a serialization failure
71
+ // must come back as saved_to:'failed', not break the experiment's finish.
72
+ var json = JSON.stringify(recording);
73
+ if (w.jsPsychPipe && typeof w.jsPsychPipe.saveData === 'function') {
74
+ await w.jsPsychPipe.saveData(cfg.experimentId, filename, json);
75
+ } else {
76
+ var fetchFn = w.fetch || (typeof fetch !== 'undefined' ? fetch : null);
77
+ if (!fetchFn) throw new Error('no fetch available for DataPipe POST');
78
+ var res = await fetchFn(DATAPIPE_URL, {
79
+ method: 'POST',
80
+ headers: { 'Content-Type': 'application/json', Accept: '*/*' },
81
+ body: JSON.stringify({
82
+ experimentID: cfg.experimentId,
83
+ filename: filename,
84
+ data: json
85
+ })
86
+ });
87
+ if (res && res.ok === false) throw new Error('DataPipe HTTP error');
88
+ }
89
+ return { saved_to: 'datapipe:' + cfg.experimentId + '/' + filename, filename: filename };
90
+ } catch (e) {
91
+ console.error('[cyborg-hunter-replay] DataPipe autosave failed:', e);
92
+ return { saved_to: 'failed', filename: filename,
93
+ error: e && e.message ? e.message : String(e) };
94
+ }
95
+ }
96
+
97
+ if (cfg.mode === 'download') {
98
+ try {
99
+ var doc = typeof document !== 'undefined' ? document : null;
100
+ var BlobImpl = w.Blob || (typeof Blob !== 'undefined' ? Blob : null);
101
+ var urlApi = w.URL || (typeof URL !== 'undefined' ? URL : null);
102
+ if (!doc || !BlobImpl || !urlApi) throw new Error('download mode needs a browser');
103
+ var blob = new BlobImpl([JSON.stringify(recording)], { type: 'application/json' });
104
+ var a = doc.createElement('a');
105
+ a.href = urlApi.createObjectURL(blob);
106
+ a.download = filename;
107
+ doc.body.appendChild(a);
108
+ a.click();
109
+ doc.body.removeChild(a);
110
+ urlApi.revokeObjectURL(a.href);
111
+ return { saved_to: 'download', filename: filename };
112
+ } catch (e) {
113
+ console.error('[cyborg-hunter-replay] download autosave failed:', e);
114
+ return { saved_to: 'failed', filename: filename,
115
+ error: e && e.message ? e.message : String(e) };
116
+ }
117
+ }
118
+
119
+ return { saved_to: 'failed', filename: filename,
120
+ error: 'unknown autoSave.mode "' + cfg.mode + '"' };
121
+ }
122
+
123
+ /**
124
+ * Gzip the recording via CompressionStream when the browser has it;
125
+ * falls back to a plain-JSON Blob (callers check blob.type).
126
+ */
127
+ export async function compressRecording(recording) {
128
+ var json = JSON.stringify(recording);
129
+ var w = typeof window !== 'undefined' ? window : {};
130
+ var CS = w.CompressionStream ||
131
+ (typeof CompressionStream !== 'undefined' ? CompressionStream : null);
132
+ var BlobImpl = w.Blob || (typeof Blob !== 'undefined' ? Blob : null);
133
+ if (!BlobImpl) return null;
134
+ if (!CS) {
135
+ return new BlobImpl([json], { type: 'application/json' });
136
+ }
137
+ var stream = new BlobImpl([json]).stream().pipeThrough(new CS('gzip'));
138
+ var ResponseImpl = w.Response || (typeof Response !== 'undefined' ? Response : null);
139
+ var buf = await new ResponseImpl(stream).arrayBuffer();
140
+ return new BlobImpl([buf], { type: 'application/gzip' });
141
+ }
@@ -0,0 +1,315 @@
1
+ // src/replay/recorder.js
2
+ // Recorder engine: lifecycle state, event buffer, caps, listener registry.
3
+ //
4
+ // Deliberately DOM-free: it never touches document/window except to read
5
+ // viewport geometry (guarded), so it unit-tests in plain node. Capture
6
+ // modules (capture-trace.js, capture-dom.js) own all DOM listeners and
7
+ // register them here via addListener/addInterval so destroy() can tear
8
+ // everything down in one place. Assembly (singleton + capture wiring +
9
+ // window global) lives in index.js.
10
+ //
11
+ // Time base: every event carries an ABSOLUTE performance.now() `t`. This is
12
+ // the same clock CH core uses, so CH-derived data merges with no conversion;
13
+ // serializer.js converts to ms-since-session-start on the wire.
14
+
15
+ export const REPLAY_DEFAULTS = {
16
+ participantId: 'unknown',
17
+ tier: 'trace', // 'trace' | 'dom' (canvas reserved for v0.8)
18
+ keys: 'full', // 'full' | 'off'
19
+ mouseHz: 30, // mousemove sampling ceiling
20
+ redactSelector: '[data-ch-redact]',
21
+ keepBait: false, // keep honeypot/decoy nodes in DOM snapshots
22
+ root: null, // capture root; resolved at startSession (default document.body)
23
+ autoSave: { mode: 'none' }, // 'datapipe' | 'download' | 'none'
24
+ maxEventsPerTrial: 50000,
25
+ // Size ceiling per trial, measured in CHARACTERS (JS string length / UTF-16
26
+ // code units) — an approximate, monotonic proxy for payload size, deliberately
27
+ // not called "bytes" since UTF-8 byte size can exceed it for non-ASCII content.
28
+ // The event-count cap alone can't bound a recording that stays under 50k events
29
+ // but stores multi-megabyte values (a giant textarea edited repeatedly, a large
30
+ // DOM subtree). The budget covers events AND the initial DOM snapshot. ~8M chars
31
+ // is far above any normal trial yet bounds a runaway before it breaks the tab or
32
+ // the DataPipe/localStorage upload. Set null to disable.
33
+ maxCharsPerTrial: 8000000
34
+ };
35
+
36
+ // States: created → session ⇄ trial → stopped; destroyed is terminal.
37
+ const VALID = {
38
+ created: ['session', 'destroyed'],
39
+ session: ['trial', 'stopped', 'destroyed'],
40
+ trial: ['session', 'stopped', 'destroyed'],
41
+ stopped: ['destroyed'],
42
+ destroyed: []
43
+ };
44
+
45
+ export function createRecorder(userConfig) {
46
+ var config = Object.assign({}, REPLAY_DEFAULTS, userConfig || {});
47
+ config.autoSave = Object.assign({}, REPLAY_DEFAULTS.autoSave, (userConfig || {}).autoSave || {});
48
+
49
+ var state = 'created';
50
+ var listeners = [];
51
+ var intervals = [];
52
+ var trialCounter = 0;
53
+ var trialStartHooks = []; // capture modules subscribe (e.g. DOM snapshot)
54
+ // Running byte estimate for the OPEN trial (reset per trial). Kept off the
55
+ // serialized trial object (a WeakMap) so it never pollutes the wire payload.
56
+ var trialChars = new WeakMap();
57
+ // Trials that hit a per-trial cap. Tracking this per-trial (not via the
58
+ // session-wide captureStopped flag) means one oversized trial stops only
59
+ // itself; a subsequent trial captures fresh. captureStopped remains as a
60
+ // session-level "something was truncated somewhere" signal for the meta.
61
+ var stoppedTrials = new WeakSet();
62
+ function estimateEventChars(o) {
63
+ try { return JSON.stringify(o).length; } catch (e) { return 0; }
64
+ }
65
+
66
+ // The in-memory session buffer. Serialized by serializer.js at the end.
67
+ var session = {
68
+ participantId: config.participantId,
69
+ tier: config.tier,
70
+ keys: config.keys,
71
+ sessionStart: null, // performance.now() at startSession
72
+ sessionStartEpoch: null, // Date.now() at startSession (wire metadata + filename)
73
+ viewport: null,
74
+ stylesheets: [], // filled by capture-dom at startSession (tier dom)
75
+ trials: [],
76
+ guardViolations: [], // filled via GuardFriction.onViolation subscription
77
+ captureFailures: [],
78
+ captureStopped: false,
79
+ endReason: null,
80
+ markerAttr: null // set by capture-dom (serialization markers)
81
+ };
82
+ var currentTrial = null;
83
+ // Opaque marker registry (capture-dom owns it; capture-trace reads it for
84
+ // interaction anchors). The recorder never inspects it — staying DOM-free.
85
+ var markers = null;
86
+
87
+ function transition(to) {
88
+ if (VALID[state].indexOf(to) === -1) {
89
+ throw new Error(
90
+ '[cyborg-hunter-replay] invalid lifecycle call: ' + state + ' → ' + to +
91
+ '. Expected order: attach() → startSession() → (startTrial → endTrial)* → stopSession() → destroy().' +
92
+ (state === 'created' ? ' Did you forget startSession()?' : '')
93
+ );
94
+ }
95
+ state = to;
96
+ }
97
+
98
+ function newTrial(opts, implicit) {
99
+ trialCounter++;
100
+ return {
101
+ trialIndex: trialCounter - 1,
102
+ trialId: (opts && opts.trialId) || (implicit ? '__session__' : 'trial-' + (trialCounter - 1)),
103
+ plugin: (opts && opts.plugin) || 'ch:standalone',
104
+ implicit: !!implicit,
105
+ // tLoad is THE trial time origin (see design §10). tStart/tDomReady are
106
+ // #3661-parity fields; null when the host has no pre-render hook.
107
+ tLoad: implicit ? session.sessionStart : performance.now(),
108
+ tStart: (opts && opts.tStart) != null ? opts.tStart : null,
109
+ tDomReady: (opts && opts.tDomReady) != null ? opts.tDomReady : null,
110
+ tEnd: null,
111
+ initialDom: '',
112
+ events: []
113
+ };
114
+ }
115
+
116
+ function closeTrial() {
117
+ currentTrial.tEnd = performance.now();
118
+ session.trials.push(currentTrial);
119
+ currentTrial = null;
120
+ }
121
+
122
+ // Fire trial-start subscribers (used by capture-dom for the initial DOM
123
+ // snapshot). A throwing hook is contained like any capture failure.
124
+ function fireTrialStart(trial) {
125
+ for (var i = 0; i < trialStartHooks.length; i++) {
126
+ try { trialStartHooks[i](trial); } catch (e) {
127
+ session.captureFailures.push({
128
+ channel: 'trial_start_hook',
129
+ message: e && e.message ? String(e.message) : String(e),
130
+ t: performance.now()
131
+ });
132
+ }
133
+ }
134
+ }
135
+
136
+ var recorder = {
137
+ config: config,
138
+
139
+ startSession: function () {
140
+ transition('session');
141
+ session.sessionStart = performance.now();
142
+ session.sessionStartEpoch = Date.now();
143
+ // Viewport geometry, if a window exists (absent in node tests).
144
+ var w = typeof window !== 'undefined' ? window : null;
145
+ // documentElement.clientWidth/Height = the LAYOUT width the page was
146
+ // actually formatted against (innerWidth minus any classic scrollbar) —
147
+ // the viewer sizes its reconstruction by this, not innerWidth.
148
+ var de = typeof document !== 'undefined' && document.documentElement
149
+ ? document.documentElement : null;
150
+ session.viewport = w ? {
151
+ width: w.innerWidth || null,
152
+ height: w.innerHeight || null,
153
+ client_width: de ? de.clientWidth || null : null,
154
+ client_height: de ? de.clientHeight || null : null,
155
+ dpr: w.devicePixelRatio || 1,
156
+ visual_viewport: w.visualViewport ? {
157
+ width: w.visualViewport.width, height: w.visualViewport.height,
158
+ scale: w.visualViewport.scale
159
+ } : null
160
+ } : { width: null, height: null, client_width: null, client_height: null,
161
+ dpr: null, visual_viewport: null };
162
+ if (config.autoSave.mode === 'none') {
163
+ console.warn('[cyborg-hunter-replay] autoSave.mode is "none" — the recording will be lost unless you call getRecording() yourself.');
164
+ }
165
+ },
166
+
167
+ startTrial: function (opts) {
168
+ if (state === 'trial') {
169
+ // Standalone users may forget endTrial(); auto-close so events never
170
+ // bleed across trials, and leave an auditable marker.
171
+ this.pushEvent('ch:lifecycle_error', { reason: 'startTrial_without_endTrial' });
172
+ closeTrial();
173
+ state = 'session';
174
+ } else if (currentTrial) {
175
+ // An implicit trial is open (session-scoped events arrived before
176
+ // the first explicit startTrial — e.g. a guard violation during the
177
+ // instructions screen). Close it; overwriting currentTrial would
178
+ // orphan those events. This is the NORMAL path, not an error.
179
+ closeTrial();
180
+ }
181
+ transition('trial');
182
+ currentTrial = newTrial(opts, false);
183
+ fireTrialStart(currentTrial);
184
+ },
185
+
186
+ endTrial: function () {
187
+ transition('session');
188
+ if (currentTrial) closeTrial();
189
+ },
190
+
191
+ stopSession: function (reason) {
192
+ if (state === 'trial') {
193
+ state = 'session';
194
+ }
195
+ // Close whatever trial is open — explicit (state was 'trial') or
196
+ // implicit (state 'session' with a lazily-opened trial).
197
+ if (currentTrial) closeTrial();
198
+ transition('stopped');
199
+ session.endReason = reason || 'finished';
200
+ },
201
+
202
+ // Central event sink used by all capture modules.
203
+ // Events land in the current trial; unbracketed events lazily open a
204
+ // single implicit trial that spans the session (design §5).
205
+ pushEvent: function (kind, payload, tOverride) {
206
+ if (state === 'destroyed') {
207
+ throw new Error('[cyborg-hunter-replay] pushEvent called on a destroyed recorder');
208
+ }
209
+ if (state === 'created' || state === 'stopped') return; // not recording
210
+ if (!currentTrial) {
211
+ currentTrial = newTrial(null, true);
212
+ fireTrialStart(currentTrial);
213
+ }
214
+ // Per-trial stop (not the session-wide flag): a trial that already hit a
215
+ // cap drops further events, but a fresh trial is unaffected.
216
+ if (stoppedTrials.has(currentTrial)) return;
217
+ if (currentTrial.events.length >= config.maxEventsPerTrial) {
218
+ stoppedTrials.add(currentTrial);
219
+ session.captureStopped = true;
220
+ currentTrial.events.push({
221
+ t: tOverride != null ? tOverride : performance.now(),
222
+ kind: 'ch:capture_stopped',
223
+ limit: config.maxEventsPerTrial
224
+ });
225
+ return;
226
+ }
227
+ var e = Object.assign(
228
+ { t: tOverride != null ? tOverride : performance.now(), kind: kind },
229
+ payload || {});
230
+ // Size cap: stop capturing once the trial's estimated serialized length
231
+ // exceeds maxCharsPerTrial, so a few huge values can't blow up the payload
232
+ // while staying under the event-count cap. The budget is SEEDED with the
233
+ // initial DOM snapshot (stored on the trial, not pushed as an event) so a
234
+ // multi-megabyte snapshot counts too rather than bypassing the cap.
235
+ if (config.maxCharsPerTrial != null) {
236
+ var soFar = trialChars.get(currentTrial);
237
+ if (soFar == null) {
238
+ soFar = currentTrial.initialDom ? currentTrial.initialDom.length : 0;
239
+ }
240
+ soFar += estimateEventChars(e);
241
+ if (soFar > config.maxCharsPerTrial) {
242
+ stoppedTrials.add(currentTrial);
243
+ session.captureStopped = true;
244
+ currentTrial.events.push({
245
+ t: e.t, kind: 'ch:capture_stopped', limit_chars: config.maxCharsPerTrial
246
+ });
247
+ return;
248
+ }
249
+ trialChars.set(currentTrial, soFar);
250
+ }
251
+ currentTrial.events.push(e);
252
+ },
253
+
254
+ // Capture-channel failure: record and keep going. Recording must never
255
+ // break an experiment (design §6).
256
+ captureFailure: function (channel, err) {
257
+ session.captureFailures.push({
258
+ channel: channel,
259
+ message: err && err.message ? String(err.message) : String(err),
260
+ t: performance.now()
261
+ });
262
+ },
263
+
264
+ onTrialStart: function (fn) {
265
+ trialStartHooks.push(fn);
266
+ },
267
+
268
+ setStylesheets: function (sheets) {
269
+ session.stylesheets = sheets || [];
270
+ },
271
+
272
+ // Marker registry passthrough (opaque — see comment on `markers` above).
273
+ setMarkers: function (reg) { markers = reg; },
274
+ getMarkers: function () { return markers; },
275
+ setMarkerAttr: function (attr) { session.markerAttr = attr || null; },
276
+
277
+ // Listener/interval registry — single teardown point.
278
+ addListener: function (target, event, handler, options) {
279
+ target.addEventListener(event, handler, options || false);
280
+ listeners.push({ target: target, event: event, handler: handler, options: options || false });
281
+ },
282
+
283
+ addInterval: function (id) {
284
+ intervals.push(id);
285
+ },
286
+
287
+ // Read-only view for serializer + tests. Trials array includes the open
288
+ // trial so mid-session getRecording() sees everything so far.
289
+ // Deliberately readable AFTER destroy(): the buffer survives teardown
290
+ // (only capture stops), mirroring CH core's getSessionReport contract —
291
+ // destroy-then-serialize returns complete data instead of losing it.
292
+ getState: function () {
293
+ var trials = session.trials.slice();
294
+ if (currentTrial) trials.push(currentTrial);
295
+ return Object.assign({}, session, { trials: trials, state: state });
296
+ },
297
+
298
+ destroy: function () {
299
+ if (state === 'destroyed') return;
300
+ transition('destroyed');
301
+ listeners.forEach(function (l) {
302
+ if (l.options && l.options._isObserver) {
303
+ l.handler.disconnect();
304
+ } else {
305
+ l.target.removeEventListener(l.event, l.handler, l.options);
306
+ }
307
+ });
308
+ listeners = [];
309
+ intervals.forEach(function (id) { clearInterval(id); });
310
+ intervals = [];
311
+ }
312
+ };
313
+
314
+ return recorder;
315
+ }
@@ -0,0 +1,119 @@
1
+ // src/replay/serializer.js
2
+ // In-memory recorder state → SessionRecording v1 (jsPsych PR #3661 wire
3
+ // format) with a ch_extensions namespace for CH-only data.
4
+ //
5
+ // This is one of exactly two places in the system that convert time bases
6
+ // (the other is the CLI ingest): in-memory events carry absolute
7
+ // performance.now() values; the wire carries ms-since-session-start.
8
+
9
+ import { VERSION } from '../shared/constants.js';
10
+
11
+ // Convert an absolute performance.now() value to wire time (ms since
12
+ // session start), rounded to 1 decimal to keep JSON compact.
13
+ function wireT(t, sessionStart) {
14
+ if (t == null) return null;
15
+ return Math.round((t - sessionStart) * 10) / 10;
16
+ }
17
+
18
+ // Session-scoped CH arrays whose entries carry absolute `t` — converted to
19
+ // session-relative on the way into ch_extensions.
20
+ function convertTimes(entries, sessionStart) {
21
+ return (entries || []).map(function (e) {
22
+ var out = Object.assign({}, e);
23
+ if (typeof out.t === 'number') out.t = wireT(out.t, sessionStart);
24
+ if (typeof out.start === 'number') out.start = wireT(out.start, sessionStart);
25
+ return out;
26
+ });
27
+ }
28
+
29
+ /**
30
+ * @param {Object} state - recorder.getState() output
31
+ * @param {Object} opts - { chSessionReport } — pull-model CH merge; the
32
+ * caller (jsPsych adapter or researcher) provides CH's session report,
33
+ * the serializer never reaches into CH itself.
34
+ */
35
+ export function serialize(state, opts) {
36
+ opts = opts || {};
37
+ var s0 = state.sessionStart;
38
+ if (s0 == null) {
39
+ throw new Error('[cyborg-hunter-replay] cannot serialize before startSession() — call startSession() first, or the timestamps would be meaningless (Unix epoch).');
40
+ }
41
+ var ch = opts.chSessionReport || null;
42
+
43
+ var lastT = s0;
44
+ var trials = state.trials.map(function (trial) {
45
+ var events = trial.events.slice().sort(function (a, b) { return a.t - b.t; });
46
+ var lastEventT = events.length > 0 ? events[events.length - 1].t : trial.tLoad;
47
+ if (lastEventT > lastT) lastT = lastEventT;
48
+ return {
49
+ trial_index: trial.trialIndex,
50
+ trial_id: trial.trialId,
51
+ plugin: trial.plugin,
52
+ // t_load is the trial time origin (design §10). t_start/t_dom_ready are
53
+ // #3661-parity fields, null when the host has no pre-render hook.
54
+ t_start: wireT(trial.tStart, s0),
55
+ t_dom_ready: wireT(trial.tDomReady, s0),
56
+ t_load: wireT(trial.tLoad, s0),
57
+ // A trial still open at serialization time (tab closed, mid-session
58
+ // getRecording) ends at its last observed event.
59
+ t_end: wireT(trial.tEnd != null ? trial.tEnd : lastEventT, s0),
60
+ // Scroll/viewport state at trial start — the viewer's camera seed.
61
+ // Times inside are absolute-free (plain state), so no conversion.
62
+ view_state: trial.viewState || null,
63
+ initial_dom: trial.initialDom || '',
64
+ events: events.map(function (e) {
65
+ var out = Object.assign({}, e);
66
+ out.t = wireT(e.t, s0);
67
+ return out;
68
+ }),
69
+ trial_data: {}
70
+ };
71
+ });
72
+
73
+ var chExtensions = {
74
+ replay_version: VERSION,
75
+ // Serialization-marker attribute name (per-recording nonce); the viewer
76
+ // harvests these markers out of the reconstructed DOM for stable
77
+ // mutation/anchor resolution. Null on trace-tier or legacy recordings.
78
+ marker_attr: state.markerAttr || null,
79
+ ch_version: ch ? (ch.libraryVersion || null) : null,
80
+ preset: ch && ch.config ? ch.config.preset : null,
81
+ scoring: ch ? {
82
+ hard_score: ch.hardScore || {},
83
+ soft_score: ch.softScore != null ? ch.softScore : null,
84
+ soft_score_threshold: ch.softScoreThreshold != null ? ch.softScoreThreshold : null,
85
+ any_hard_triggered: !!ch.anyHardTriggered,
86
+ trials_completed: ch.trialsCompleted != null ? ch.trialsCompleted : null
87
+ } : null,
88
+ session_signals: ch ? {
89
+ sidebar_events: convertTimes(ch.sidebarEvents, s0),
90
+ ai_extensions_found: convertTimes(ch.aiExtensionsFound, s0),
91
+ devtools_events: convertTimes(ch.devToolsEvents, s0),
92
+ window_positions: convertTimes(ch.windowPositions, s0),
93
+ layout_shifts: convertTimes(ch.layoutShifts, s0),
94
+ zoom_changes: convertTimes(ch.zoomChanges, s0),
95
+ extension_injections: convertTimes(ch.extensionInjections, s0)
96
+ } : null,
97
+ guard_violations: convertTimes(state.guardViolations, s0),
98
+ capture_failures: convertTimes(state.captureFailures, s0),
99
+ capture_stopped: !!state.captureStopped
100
+ };
101
+
102
+ return {
103
+ schema_version: 1,
104
+ metadata: {
105
+ start_time: new Date(state.sessionStartEpoch).toISOString(),
106
+ end_time: new Date(state.sessionStartEpoch + Math.max(0, Math.round(lastT - s0))).toISOString(),
107
+ end_reason: state.endReason || 'aborted',
108
+ participant_id: state.participantId,
109
+ recorder: 'cyborg-hunter-replay@' + VERSION,
110
+ tier: state.tier,
111
+ keys: state.keys
112
+ },
113
+ viewport: state.viewport,
114
+ stylesheets: { initial: state.stylesheets || [], events: [] },
115
+ rng_calls: [], // shape parity with #3661; CH never re-executes code
116
+ trials: trials,
117
+ ch_extensions: chExtensions
118
+ };
119
+ }
@@ -2,7 +2,7 @@
2
2
  // Default thresholds and preset configurations for Cyborg Hunter.
3
3
  // Single source of truth — used by both the browser library and CLI report tool.
4
4
 
5
- export const VERSION = "0.5.0";
5
+ export const VERSION = "0.7.0";
6
6
 
7
7
  // Default detection thresholds shared across presets.
8
8
  // Researchers can override any value at init() time.
@@ -11,6 +11,7 @@ export const DEFAULT_THRESHOLDS = {
11
11
  dropMinChars: 0, // record ALL drops
12
12
  sidebarGapPx: 100, // outerWidth - innerWidth above this = sidebar
13
13
  layoutCompressionPx: 20, // innerWidth - clientWidth above scrollbar width
14
+ viewportShiftDebounceMs: 250, // quiet period before a viewport-width shift is logged (one gesture = one event)
14
15
  syntheticGapMs: 100, // keydown-to-input gap above this = synthetic insertion
15
16
  idleGapMs: 10000, // no input for this long = idle gap
16
17
  idleCheckIntervalMs: 5000, // how often to check for idle gaps
@@ -37,7 +38,7 @@ export const PRESETS = {
37
38
  permissive: {
38
39
  // Collect everything, screen out nobody. For calibration/pilot studies.
39
40
  signals: {
40
- paste: true, copy: true, tabAway: true, typingSpeed: true,
41
+ paste: true, copy: true, drop: true, tabAway: true, typingSpeed: true,
41
42
  devTools: true, aiExtensions: true, sidebarGap: true,
42
43
  keyboardShortcuts: true, mouseTracking: true, idleGaps: true,
43
44
  windowPosition: true, clipboardManager: true,
@@ -66,7 +67,7 @@ export const PRESETS = {
66
67
  standard: {
67
68
  // Balanced detection. Default for most studies.
68
69
  signals: {
69
- paste: true, copy: true, tabAway: true, typingSpeed: true,
70
+ paste: true, copy: true, drop: true, tabAway: true, typingSpeed: true,
70
71
  devTools: true, aiExtensions: true, sidebarGap: true,
71
72
  keyboardShortcuts: true, mouseTracking: true, idleGaps: true,
72
73
  windowPosition: true, clipboardManager: true,
@@ -93,7 +94,7 @@ export const PRESETS = {
93
94
  strict: {
94
95
  // Low thresholds, all signals. For high-stakes studies.
95
96
  signals: {
96
- paste: true, copy: true, tabAway: true, typingSpeed: true,
97
+ paste: true, copy: true, drop: true, tabAway: true, typingSpeed: true,
97
98
  devTools: true, aiExtensions: true, sidebarGap: true,
98
99
  keyboardShortcuts: true, mouseTracking: true, idleGaps: true,
99
100
  windowPosition: true, clipboardManager: true,
@@ -158,5 +159,10 @@ export const BENIGN_TAGS = [
158
159
  "dashlane", "roboform", "honey", "ublock", "adblock"
159
160
  ];
160
161
 
161
- // Storage key prefix for localStorage/sessionStorage persistence
162
- export const STORAGE_PREFIX = "integrity_monitor_";
162
+
163
+ // Filesystem/CSS-safe participant-id sanitizer. Single source of truth for
164
+ // every artifact filename (browser persistence, CLI ingest, report assets)
165
+ // — divergent copies caused a live fallback-path mismatch (autoreview 0.7.0).
166
+ export function sanitizeId(id) {
167
+ return String(id).replace(/[^a-zA-Z0-9_.-]/g, '_');
168
+ }
@@ -33,13 +33,16 @@ export const TRIAL_REPORT_FIELDS = {
33
33
  // All fields have sensible defaults — minimal config is just dataDir + filePattern.
34
34
  export const DEFAULT_CLI_CONFIG = {
35
35
  dataDir: "./data",
36
+ replayDir: null, // replay artifacts dir; defaults to dataDir
36
37
  filePattern: "*.json",
37
38
  participantIdField: "participantId",
38
39
  trialIdField: "trialId",
39
40
  trialOrderField: "trialIndex",
40
41
  integrityField: "integrity",
42
+ sessionIntegrityPath: null,
41
43
  trialsPerParticipant: null,
42
44
  platformIdField: null,
45
+ showPlatformId: false,
43
46
  conditionField: null,
44
47
  groupField: null,
45
48
  typingSpeedThreshold_cps: 10,
@@ -48,9 +51,11 @@ export const DEFAULT_CLI_CONFIG = {
48
51
  suspiciouslyFastRT_ms: 2000,
49
52
  scoring: null, // uses library defaults
50
53
  signals: null, // all enabled
54
+ phaseScope: null, // {include: [...], exclude: [...]} — scope scores to phases
51
55
  trajectoryGrid: "auto",
52
56
  trajectoryTrialLabel: "trialId",
53
57
  trajectoryResponseField: null,
58
+ trajectoryDisplayOrder: "rule",
54
59
  outputDir: "./cyborg-hunter-report",
55
60
  outputFormat: "html"
56
61
  };