cyborg-hunter 0.4.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.
- package/CHANGELOG.md +110 -0
- package/CITATION.cff +29 -0
- package/LICENSE +21 -0
- package/README.md +117 -79
- package/package.json +10 -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/ingest.js +623 -41
- package/src/cli/init.js +1 -1
- package/src/cli/renderers/event-log.js +18 -19
- package/src/cli/renderers/extensions.js +12 -3
- package/src/cli/renderers/html-index.js +163 -24
- package/src/cli/renderers/replay-assets.js +177 -0
- package/src/cli/renderers/replay-viewer.client.js +1022 -0
- package/src/cli/renderers/session-timeline.js +917 -0
- package/src/cli/renderers/summary-csv.js +5 -0
- package/src/cli/renderers/trajectories.js +69 -8
- package/src/cli/renderers/triage-md.js +10 -4
- package/src/cli/renderers/typing-profile.js +7 -1
- package/src/cli/report.js +42 -8
- package/src/core/monitor.js +60 -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.js → extension-cyborg-hunter.js} +9 -2
- package/src/jspsych/extension-guard-friction.js +1164 -0
- package/src/jspsych/extension-guard-honeypot.js +492 -0
- 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/shared/constants.js +12 -6
- package/src/shared/schema.js +5 -0
- package/src/shared/validation.js +55 -0
- package/dist/cyborg-hunter.esm.js +0 -1527
- package/dist/cyborg-hunter.min.js +0 -6
- package/dist/jspsych-cyborg-hunter.js +0 -1
- package/src/cli/renderers/tab-timeline.js +0 -149
|
@@ -42,6 +42,11 @@ const COLUMNS = [
|
|
|
42
42
|
['zoom_change_count', (s, t) => s.zoomChangeCount || 0],
|
|
43
43
|
['dev_tools_event_count', (s, t) => s.devToolsEventCount || 0],
|
|
44
44
|
['authoritative_soft_score', (s, t) => s.authoritativeSoftScore ?? ''],
|
|
45
|
+
// Guard-honeypot self-disclosure. Three states: 'YES' = box ticked,
|
|
46
|
+
// 'no' = honeypot present but unticked (negative evidence), '' = honeypot
|
|
47
|
+
// extension not used for this participant (honeypotAiUse is null).
|
|
48
|
+
['honeypot_ai_use', (s, t) => s.honeypotAiUse == null ? '' : (s.honeypotAiUse ? 'YES' : 'no')],
|
|
49
|
+
['honeypot_ai_report', (s, t) => s.honeypotAiReport || ''],
|
|
45
50
|
];
|
|
46
51
|
|
|
47
52
|
export async function renderSummaryCSV(summaries, triage, config) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Mouse trajectory rendering (node-canvas). One PNG per participant, grid of
|
|
2
2
|
// per-trial panels. Mirrors the visual conventions of the Python reference
|
|
3
|
-
// pipeline in
|
|
3
|
+
// pipeline used during development (not included in this repo).
|
|
4
4
|
//
|
|
5
5
|
// Per-panel visual elements:
|
|
6
6
|
// - Time-colored path (blue → red gradient over the trial's duration)
|
|
@@ -16,13 +16,14 @@
|
|
|
16
16
|
|
|
17
17
|
import { writeFileSync } from 'fs';
|
|
18
18
|
import { join } from 'path';
|
|
19
|
+
import { ruleChronologicalCompare } from '../ingest.js';
|
|
19
20
|
|
|
20
21
|
// Panel dimensions (pixels).
|
|
21
22
|
const PANEL_W = 400;
|
|
22
23
|
const PANEL_H = 300;
|
|
23
24
|
const PANEL_PAD = 40;
|
|
24
25
|
const TITLE_HEIGHT = 40;
|
|
25
|
-
const HEADER_HEIGHT =
|
|
26
|
+
const HEADER_HEIGHT = 96; // accommodates 3-line legend
|
|
26
27
|
const PANEL_MARGIN = 15; // inset between panel border and content area
|
|
27
28
|
|
|
28
29
|
// Color palette. Hoisted from inline string literals to give the rendering
|
|
@@ -50,12 +51,50 @@ const COLORS = {
|
|
|
50
51
|
pauseCircle: '#ccc',
|
|
51
52
|
};
|
|
52
53
|
|
|
54
|
+
// Panel-background tints by trial phase (0.6.1, retro item 9) — same palette
|
|
55
|
+
// as the session-timeline phase strip (session-timeline.js C.phase*), so a
|
|
56
|
+
// panel's tint and the timeline band for the same phase read as one system.
|
|
57
|
+
// Trials without a recognized phase keep the neutral panelBg.
|
|
58
|
+
const PHASE_TINTS = {
|
|
59
|
+
gallery: '#ffe0b2', // peach — matches C.phaseGallery
|
|
60
|
+
post_gallery_query: '#e1bee7', // light purple — matches C.phaseTyping
|
|
61
|
+
typing: '#e1bee7',
|
|
62
|
+
classification: '#bbdefb', // light blue — matches C.phaseClass
|
|
63
|
+
end_requery: '#eceff1', // bluish grey — matches C.phasePre/Post
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Orders a participant's trials for the panel grid (0.6.1, retro item 3).
|
|
67
|
+
// config.trajectoryDisplayOrder:
|
|
68
|
+
// 'rule' (default) chronological by rule — (rulePosition, phase-rank,
|
|
69
|
+
// trialNumber), the order each rule was actually experienced.
|
|
70
|
+
// Same comparator the ingest uses when merging phaseTrials, so
|
|
71
|
+
// already-sorted data keeps its order (stable sort).
|
|
72
|
+
// 'time' wall-clock trial timestamps (fallback: trialNumber)
|
|
73
|
+
// 'insertion' raw ingest order (pre-0.6.1 behavior)
|
|
74
|
+
// Exported for tests.
|
|
75
|
+
export function orderTrials(trials, config) {
|
|
76
|
+
const mode = config.trajectoryDisplayOrder || 'rule';
|
|
77
|
+
if (mode === 'insertion') return trials;
|
|
78
|
+
const ordered = trials.slice();
|
|
79
|
+
if (mode === 'time') {
|
|
80
|
+
ordered.sort((a, b) => {
|
|
81
|
+
const at = a?.timestamp ? Date.parse(a.timestamp) : NaN;
|
|
82
|
+
const bt = b?.timestamp ? Date.parse(b.timestamp) : NaN;
|
|
83
|
+
if (Number.isFinite(at) && Number.isFinite(bt) && at !== bt) return at - bt;
|
|
84
|
+
return (a.trialNumber ?? 0) - (b.trialNumber ?? 0);
|
|
85
|
+
});
|
|
86
|
+
} else {
|
|
87
|
+
ordered.sort(ruleChronologicalCompare);
|
|
88
|
+
}
|
|
89
|
+
return ordered;
|
|
90
|
+
}
|
|
91
|
+
|
|
53
92
|
export async function renderTrajectories(participants, triage, config) {
|
|
54
93
|
const { createCanvas } = await import('canvas');
|
|
55
94
|
const triageMap = new Map(triage.map(t => [t.participantId, t]));
|
|
56
95
|
|
|
57
96
|
for (const p of participants) {
|
|
58
|
-
const trials = p.trials;
|
|
97
|
+
const trials = orderTrials(p.trials, config);
|
|
59
98
|
if (trials.length === 0) continue;
|
|
60
99
|
|
|
61
100
|
// Compute grid layout
|
|
@@ -90,6 +129,10 @@ export async function renderTrajectories(participants, triage, config) {
|
|
|
90
129
|
'Teal outer rect=screen Purple dashed rect=browser window Red panel frame=hard signal triggered on this trial',
|
|
91
130
|
PANEL_PAD, 64
|
|
92
131
|
);
|
|
132
|
+
ctx.fillText(
|
|
133
|
+
'Panel tint=phase: peach gallery, purple typing/query, blue classification, grey re-query (neutral = unphased) — matches the session-timeline strip',
|
|
134
|
+
PANEL_PAD, 80
|
|
135
|
+
);
|
|
93
136
|
|
|
94
137
|
// session.windowPositions is the 2-second-poll-plus-resize-event record
|
|
95
138
|
// from core/signals/browser.js. Per-trial geometry uses the sample whose
|
|
@@ -128,7 +171,10 @@ export async function renderTrajectories(participants, triage, config) {
|
|
|
128
171
|
function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
|
|
129
172
|
const mouse = trial.mouseEvents || [];
|
|
130
173
|
const tabAways = trial.tabAwayEvents || [];
|
|
131
|
-
|
|
174
|
+
// String() guard: a numeric trialId/ruleId (from a hand-edited payload or a
|
|
175
|
+
// dynamicTyping CSV) would otherwise throw on .substring() below and, with no
|
|
176
|
+
// per-participant boundary, abort every remaining visual.
|
|
177
|
+
const trialId = String(trial.trialId || trial.ruleId || '?');
|
|
132
178
|
const rt = (trial.duration_ms || trial.responseTime_ms || 0) / 1000;
|
|
133
179
|
const moves = mouse.filter(e => e.type === 'move');
|
|
134
180
|
const downs = mouse.filter(e => e.type === 'down');
|
|
@@ -141,8 +187,9 @@ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
|
|
|
141
187
|
ctx.lineWidth = anyHardHit ? 3 : 1;
|
|
142
188
|
ctx.strokeRect(x0, y0 + TITLE_HEIGHT, PANEL_W, PANEL_H);
|
|
143
189
|
|
|
144
|
-
// Panel background
|
|
145
|
-
|
|
190
|
+
// Panel background — tinted by trial phase (see PHASE_TINTS), neutral for
|
|
191
|
+
// unphased trials.
|
|
192
|
+
ctx.fillStyle = PHASE_TINTS[trial.phase] || COLORS.panelBg;
|
|
146
193
|
ctx.fillRect(x0 + 1, y0 + TITLE_HEIGHT + 1, PANEL_W - 2, PANEL_H - 2);
|
|
147
194
|
|
|
148
195
|
// Title
|
|
@@ -403,10 +450,24 @@ function sanitize(name) {
|
|
|
403
450
|
// The screenWidth/screenHeight fields are conditionally included — the
|
|
404
451
|
// April 2026 monitor change added sw/sh capture, but older recordings
|
|
405
452
|
// won't have it. Omitting (rather than zero-filling) lets chooseScreenFrame
|
|
406
|
-
// fall through its
|
|
453
|
+
// fall through its compatibility/auto-fit branches honestly.
|
|
407
454
|
export function pickWindowGeometryForTrial(trial, windowPositions) {
|
|
408
455
|
if (!Array.isArray(windowPositions) || windowPositions.length === 0) return null;
|
|
409
|
-
|
|
456
|
+
// Time anchor for matching the right windowPositions sample.
|
|
457
|
+
//
|
|
458
|
+
// jsPsych extension data carries `trialStart_perfNow` (set by the wrapper's
|
|
459
|
+
// on_load). Raw-DOM extension users save
|
|
460
|
+
// `startTime` directly from `performance.now()` at trial start. Both are in
|
|
461
|
+
// the same reference frame as `windowPositions[].t` (performance.now()), so
|
|
462
|
+
// either works.
|
|
463
|
+
//
|
|
464
|
+
// Without this fallback, raw-DOM users see auto-fit or stale session-level
|
|
465
|
+
// window dimensions — symptom: the dashed "window" box in the trajectory
|
|
466
|
+
// PNG is sized for the pre-fullscreen viewport even when the trial happened
|
|
467
|
+
// in fullscreen, so mouse dots plot outside the box.
|
|
468
|
+
const anchor = (typeof trial?.trialStart_perfNow === 'number')
|
|
469
|
+
? trial.trialStart_perfNow
|
|
470
|
+
: (typeof trial?.startTime === 'number' ? trial.startTime : null);
|
|
410
471
|
if (typeof anchor !== 'number') return null;
|
|
411
472
|
|
|
412
473
|
// Selection strategy (in priority order):
|
|
@@ -11,15 +11,21 @@ export async function renderTriage(triage, config) {
|
|
|
11
11
|
'',
|
|
12
12
|
`_${triage.length} participants analyzed_`,
|
|
13
13
|
'',
|
|
14
|
-
'
|
|
15
|
-
'
|
|
14
|
+
'**Tier** is the library\'s two-tier screening verdict: `HARD` = a hard signal',
|
|
15
|
+
'(paste/drop/copy) crossed its count threshold; `soft` = library soft score ≥',
|
|
16
|
+
'its threshold; `clean` = neither. **Score** is the CLI\'s separate ranking',
|
|
17
|
+
'heuristic (5×paste + 5×copy + 3×sidebar + 1×tab-away) — it orders rows',
|
|
18
|
+
'*within* a tier and is not the library soft score.',
|
|
19
|
+
'',
|
|
20
|
+
'| Rank | Participant | Tier | Score | Reason |',
|
|
21
|
+
'|------|-------------|------|-------|--------|',
|
|
16
22
|
];
|
|
17
23
|
|
|
18
24
|
triage.forEach((t, i) => {
|
|
19
|
-
const
|
|
25
|
+
const tier = t.hardTriggered ? '**HARD**' : t.softFlagged ? 'soft' : 'clean';
|
|
20
26
|
// Escape pipe characters in reason text to avoid breaking the table
|
|
21
27
|
const reason = t.reason.replace(/\|/g, '\\|');
|
|
22
|
-
lines.push(`| ${i + 1} | ${t.participantId} | ${
|
|
28
|
+
lines.push(`| ${i + 1} | ${t.participantId} | ${tier} | ${t.score} | ${reason} |`);
|
|
23
29
|
});
|
|
24
30
|
|
|
25
31
|
lines.push('');
|
|
@@ -15,12 +15,18 @@ const LEFT_PAD = 60;
|
|
|
15
15
|
|
|
16
16
|
export async function renderTypingProfiles(participants, config) {
|
|
17
17
|
const { createCanvas } = await import('canvas');
|
|
18
|
-
const threshold = config.typingSpeedThreshold_cps || 10;
|
|
19
18
|
|
|
20
19
|
for (const p of participants) {
|
|
21
20
|
const trials = p.trials;
|
|
22
21
|
if (trials.length === 0) continue;
|
|
23
22
|
|
|
23
|
+
// Per-participant threshold line: prefer the typing-speed cutoff the library
|
|
24
|
+
// actually screened this participant with (saved in session.config.thresholds)
|
|
25
|
+
// so the red "fast typing" line matches summary.csv's count; fall back to the
|
|
26
|
+
// CLI/default. A strict participant (8 cps) otherwise shows a 10 cps line.
|
|
27
|
+
const threshold =
|
|
28
|
+
p.session?.config?.thresholds?.typingSpeedCps ?? config.typingSpeedThreshold_cps ?? 10;
|
|
29
|
+
|
|
24
30
|
// Per-trial state. We distinguish three cases:
|
|
25
31
|
// "typed" — charsPerSec is a real number, draw a normal bar
|
|
26
32
|
// "paste-only" — charsPerSec is null but there's a paste event; draw a
|
package/src/cli/report.js
CHANGED
|
@@ -12,6 +12,7 @@ import { ingest } from './ingest.js';
|
|
|
12
12
|
import { computeSummary } from './analyzers/summary.js';
|
|
13
13
|
import { detectEdgeExits } from './analyzers/edge-exit.js';
|
|
14
14
|
import { rankTriage } from './analyzers/triage.js';
|
|
15
|
+
import { applyPhaseScope, describePhaseScope, findUnmatchedPhaseScopePhases } from './analyzers/phase-scope.js';
|
|
15
16
|
import { VERSION } from '../shared/constants.js';
|
|
16
17
|
|
|
17
18
|
export async function run(args) {
|
|
@@ -44,19 +45,43 @@ export async function run(args) {
|
|
|
44
45
|
process.exit(1);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
// 3. Analyze — compute summaries, detect edge exits, rank by triage priority
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
// 3. Analyze — compute summaries, detect edge exits, rank by triage priority.
|
|
49
|
+
// config.phaseScope (0.6.1) filters which trials feed the analyzers so
|
|
50
|
+
// scores can honor pre-registered phase scoping; renderers below still get
|
|
51
|
+
// the FULL participants, so the visual evidence is never hidden.
|
|
52
|
+
const scoredParticipants = applyPhaseScope(participants, config.phaseScope);
|
|
53
|
+
if (scoredParticipants !== participants) {
|
|
54
|
+
console.log(`\nPhase scope active (${describePhaseScope(config.phaseScope)}):`);
|
|
55
|
+
console.log(` scores count per-trial signals inside the scope only; ambient session`);
|
|
56
|
+
console.log(` signals (sidebar, shortcuts, viewport shifts, zoom) stay session-wide.`);
|
|
57
|
+
// A configured phase name that matches no trial is almost always a typo. For
|
|
58
|
+
// an `include`, it silently filters every trial and reports the cohort clean.
|
|
59
|
+
const unmatched = findUnmatchedPhaseScopePhases(participants, config.phaseScope);
|
|
60
|
+
if (unmatched.length > 0) {
|
|
61
|
+
console.warn(`[cyborg-hunter] phaseScope names match NO trial in the data: ` +
|
|
62
|
+
`${unmatched.join(', ')} — likely a typo; scored trials may be wrongly emptied.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const summaries = computeSummary(scoredParticipants, config);
|
|
66
|
+
const edgeExits = detectEdgeExits(scoredParticipants, config);
|
|
50
67
|
const triage = rankTriage(summaries, edgeExits, config);
|
|
51
68
|
|
|
52
69
|
const flaggedHard = triage.filter(t => t.hardTriggered).length;
|
|
53
70
|
const flaggedSoft = triage.filter(t => !t.hardTriggered && t.softFlagged).length;
|
|
54
71
|
const clean = triage.length - flaggedHard - flaggedSoft;
|
|
55
72
|
|
|
73
|
+
// Two DIFFERENT numbers live in this report and used to share the word
|
|
74
|
+
// "flagged": the tier counts below come from the LIBRARY's two-tier
|
|
75
|
+
// screening (hard count thresholds / soft score vs its threshold), while
|
|
76
|
+
// triage.md is ORDERED by the CLI's separate composite triage score
|
|
77
|
+
// (5×paste + 5×copy + 3×sidebar + 1×tab-away) within each tier. Label both
|
|
78
|
+
// explicitly so the console summary can't be read as "top N of triage.md".
|
|
56
79
|
console.log(`\nAnalyzing...`);
|
|
57
|
-
console.log(`
|
|
58
|
-
console.log(`
|
|
59
|
-
console.log(`
|
|
80
|
+
console.log(` Hard-flagged (hard signal crossed its count threshold): ${flaggedHard}`);
|
|
81
|
+
console.log(` Soft-flagged (library soft score >= its threshold): ${flaggedSoft}`);
|
|
82
|
+
console.log(` Clean: ${clean}`);
|
|
83
|
+
console.log(` Triage.md orders tier-first (hard > soft > clean), then by the CLI`);
|
|
84
|
+
console.log(` triage score (5xpaste + 5xcopy + 3xsidebar + 1xtab-away) within a tier.`);
|
|
60
85
|
|
|
61
86
|
// 4. Render outputs
|
|
62
87
|
console.log(`\nRendering...`);
|
|
@@ -92,19 +117,28 @@ export async function run(args) {
|
|
|
92
117
|
|
|
93
118
|
if (canvas) {
|
|
94
119
|
const { renderTrajectories } = await import('./renderers/trajectories.js');
|
|
95
|
-
const {
|
|
120
|
+
const { renderSessionTimelines } = await import('./renderers/session-timeline.js');
|
|
96
121
|
const { renderTypingProfiles } = await import('./renderers/typing-profile.js');
|
|
97
122
|
|
|
98
123
|
// Create images/ subdirectory for visual outputs
|
|
99
124
|
mkdirSync(join(config.outputDir, 'images'), { recursive: true });
|
|
100
125
|
|
|
101
126
|
await renderTrajectories(participants, triage, config);
|
|
102
|
-
await
|
|
127
|
+
await renderSessionTimelines(participants, config);
|
|
103
128
|
await renderTypingProfiles(participants, config);
|
|
104
129
|
visualsRendered = true;
|
|
105
130
|
}
|
|
106
131
|
}
|
|
107
132
|
|
|
133
|
+
// Replay assets — per-participant JSONP models under replay/, lazy-loaded
|
|
134
|
+
// by the HTML report. Size is printed because dom-tier models dominate
|
|
135
|
+
// the report's disk footprint.
|
|
136
|
+
const { renderReplayAssets } = await import('./renderers/replay-assets.js');
|
|
137
|
+
const replayAssets = renderReplayAssets(participants, config.outputDir);
|
|
138
|
+
if (replayAssets.count > 0) {
|
|
139
|
+
console.log(` replay/ — ${replayAssets.count} session replays (${(replayAssets.totalBytes / 1024 / 1024).toFixed(1)} MB)`);
|
|
140
|
+
}
|
|
141
|
+
|
|
108
142
|
// HTML index page — references images/ folder (not base64-embedded)
|
|
109
143
|
const { renderHtmlIndex } = await import('./renderers/html-index.js');
|
|
110
144
|
await renderHtmlIndex(summaries, triage, participants, config, visualsRendered);
|
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,
|
|
@@ -370,9 +415,17 @@ export function init(userConfig) {
|
|
|
370
415
|
return sessionData.hardScore[k].triggered;
|
|
371
416
|
});
|
|
372
417
|
report.trialsCompleted = sessionData.trialsCompleted;
|
|
418
|
+
// Carry the effective runtime settings the CLI report needs to interpret
|
|
419
|
+
// this participant's data with the SAME thresholds the library screened
|
|
420
|
+
// them against (e.g. a strict-preset participant's tab-away cutoff), rather
|
|
421
|
+
// than analyst-side CLI defaults.
|
|
373
422
|
report.config = {
|
|
374
423
|
preset: config.preset,
|
|
375
|
-
participantId: config.participantId
|
|
424
|
+
participantId: config.participantId,
|
|
425
|
+
thresholds: {
|
|
426
|
+
tabAwayDurationMs: config.thresholds.tabAwayDurationMs,
|
|
427
|
+
typingSpeedCps: config.thresholds.typingSpeedCps
|
|
428
|
+
}
|
|
376
429
|
};
|
|
377
430
|
report.libraryVersion = VERSION;
|
|
378
431
|
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
|
}
|