cyborg-hunter 0.7.5 → 0.9.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 +74 -0
- package/CITATION.cff +2 -2
- package/README.md +22 -7
- package/dist/cyborg-hunter-replay.js +3 -3
- package/dist/cyborg-hunter.esm.js +5 -2
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/package.json +10 -2
- package/src/cli/analyzers/score-weights.js +145 -0
- package/src/cli/analyzers/triage.js +55 -39
- package/src/cli/config.js +15 -0
- package/src/cli/ingest.js +311 -57
- package/src/cli/renderers/html-index-core.js +88 -22
- package/src/cli/renderers/html-index.js +7 -5
- package/src/cli/renderers/replay-assets.js +61 -7
- package/src/cli/renderers/replay-client-source.js +102 -0
- package/src/cli/renderers/replay-viewer.client.js +1750 -595
- package/src/cli/renderers/score-weights.js +16 -0
- package/src/cli/renderers/summary-csv.js +2 -0
- package/src/cli/renderers/trajectories-core.js +2 -1
- package/src/cli/renderers/triage-md.js +9 -2
- package/src/cli/report.js +16 -3
- package/src/core/monitor.js +12 -13
- package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
- package/src/jspsych/extension-cyborg-hunter.js +1 -1
- package/src/replay/capture-dom.js +470 -458
- package/src/replay/capture-trace.js +688 -271
- package/src/replay/delivery.js +82 -0
- package/src/replay/dom-instantiate.js +779 -0
- package/src/replay/index.js +88 -5
- package/src/replay/initial-state.js +295 -0
- package/src/replay/mutations.js +668 -0
- package/src/replay/node-registry.js +116 -0
- package/src/replay/persistence.js +19 -6
- package/src/replay/recorder.js +342 -73
- package/src/replay/redaction.js +165 -0
- package/src/replay/serializer.js +148 -43
- package/src/replay/snapshot.js +409 -0
- package/src/replay/span.js +55 -0
- package/src/replay/viewer-model.js +293 -102
- package/src/shared/constants.js +1 -1
- package/src/shared/inline-safe.js +80 -0
- package/src/shared/schema-v2-validator.js +595 -0
- package/src/shared/schema.js +1 -0
- package/src/shared/validation.js +1 -1
- package/tools/convert/jspsych-v1-to-v2.mjs +432 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// src/cli/renderers/score-weights.js
|
|
2
|
+
// Writes score-weights.json — the weights this report's triage score was built
|
|
3
|
+
// with (config.scoreWeights merged onto the defaults). Always written, so two
|
|
4
|
+
// reports can be checked for comparability without their config files.
|
|
5
|
+
// Warnings stay on the console (config.js); this file holds weights only.
|
|
6
|
+
|
|
7
|
+
import { writeFileSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { resolveScoreWeights } from '../analyzers/score-weights.js';
|
|
10
|
+
|
|
11
|
+
export function renderScoreWeights(config) {
|
|
12
|
+
const { weights, isDefault } = resolveScoreWeights(config?.scoreWeights);
|
|
13
|
+
writeFileSync(join(config.outputDir, 'score-weights.json'),
|
|
14
|
+
JSON.stringify({ isDefault, weights }, null, 2) + '\n');
|
|
15
|
+
console.log(` score-weights.json — ${isDefault ? 'default' : 'custom'} weights`);
|
|
16
|
+
}
|
|
@@ -9,6 +9,8 @@ import { join } from 'path';
|
|
|
9
9
|
const COLUMNS = [
|
|
10
10
|
['participantId', (s, t) => s.participantId],
|
|
11
11
|
['trialCount', (s, t) => s.trialCount],
|
|
12
|
+
// The exact score rankTriage sorted on (not the one-decimal display form),
|
|
13
|
+
// so the analysis file never disagrees with the ranking.
|
|
12
14
|
['triageScore', (s, t) => t.score],
|
|
13
15
|
['hardTriggered', (s, t) => t.hardTriggered ? 'YES' : 'no'],
|
|
14
16
|
['triageReason', (s, t) => t.reason],
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
// - Title with trial ID, RT, mouse count, tab-away count + total duration
|
|
27
27
|
|
|
28
28
|
import { ruleChronologicalCompare } from '../extract-core.js';
|
|
29
|
+
import { formatScore } from '../analyzers/score-weights.js';
|
|
29
30
|
|
|
30
31
|
// Panel dimensions (pixels).
|
|
31
32
|
const PANEL_W = 400;
|
|
@@ -121,7 +122,7 @@ export function drawTrajectoryGrid(p, triageEntry, config, createCanvas) {
|
|
|
121
122
|
ctx.fillRect(0, 0, canvasW, canvasH);
|
|
122
123
|
|
|
123
124
|
// Header
|
|
124
|
-
const headerText = `${p.participantId} — Score: ${triageEntry?.score ?? '?'} — ${triageEntry?.reason ?? ''}`;
|
|
125
|
+
const headerText = `${p.participantId} — Score: ${formatScore(triageEntry?.score ?? '?')} — ${triageEntry?.reason ?? ''}`;
|
|
125
126
|
ctx.fillStyle = COLORS.headerText;
|
|
126
127
|
ctx.font = 'bold 16px sans-serif';
|
|
127
128
|
ctx.fillText(headerText, PANEL_PAD, 30);
|
|
@@ -4,8 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
import { writeFileSync } from 'fs';
|
|
6
6
|
import { join } from 'path';
|
|
7
|
+
import { resolveScoreWeights, formulaText, formatScore } from '../analyzers/score-weights.js';
|
|
7
8
|
|
|
8
9
|
export async function renderTriage(triage, config) {
|
|
10
|
+
// Default weights keep the 0.8.0 sentence verbatim; custom ones state the
|
|
11
|
+
// formula that was actually applied (config.scoreWeights).
|
|
12
|
+
const { weights, isDefault } = resolveScoreWeights(config?.scoreWeights);
|
|
13
|
+
const formula = isDefault
|
|
14
|
+
? 'heuristic (5×paste + 5×copy + 3×sidebar + 1×tab-away) — it orders rows'
|
|
15
|
+
: `heuristic (${formulaText(weights)}, set by scoreWeights) — it orders rows`;
|
|
9
16
|
const lines = [
|
|
10
17
|
'# Participant Triage — Ranked by Suspiciousness',
|
|
11
18
|
'',
|
|
@@ -14,7 +21,7 @@ export async function renderTriage(triage, config) {
|
|
|
14
21
|
'**Tier** is the library\'s two-tier screening verdict: `HARD` = a hard signal',
|
|
15
22
|
'(paste/drop/copy) crossed its count threshold; `soft` = library soft score ≥',
|
|
16
23
|
'its threshold; `clean` = neither. **Score** is the CLI\'s separate ranking',
|
|
17
|
-
|
|
24
|
+
formula,
|
|
18
25
|
'*within* a tier and is not the library soft score.',
|
|
19
26
|
'',
|
|
20
27
|
'| Rank | Participant | Tier | Score | Reason |',
|
|
@@ -25,7 +32,7 @@ export async function renderTriage(triage, config) {
|
|
|
25
32
|
const tier = t.hardTriggered ? '**HARD**' : t.softFlagged ? 'soft' : 'clean';
|
|
26
33
|
// Escape pipe characters in reason text to avoid breaking the table
|
|
27
34
|
const reason = t.reason.replace(/\|/g, '\\|');
|
|
28
|
-
lines.push(`| ${i + 1} | ${t.participantId} | ${tier} | ${t.score} | ${reason} |`);
|
|
35
|
+
lines.push(`| ${i + 1} | ${t.participantId} | ${tier} | ${formatScore(t.score)} | ${reason} |`);
|
|
29
36
|
});
|
|
30
37
|
|
|
31
38
|
lines.push('');
|
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 { resolveScoreWeights, formulaText } from './analyzers/score-weights.js';
|
|
15
16
|
import { applyPhaseScope, describePhaseScope, findUnmatchedPhaseScopePhases } from './analyzers/phase-scope.js';
|
|
16
17
|
import { VERSION } from '../shared/constants.js';
|
|
17
18
|
import { checkForUpdate, formatUpdateNotice, formatCollectedVersionNotice } from './update-check.js';
|
|
@@ -86,14 +87,18 @@ export async function run(args) {
|
|
|
86
87
|
// "flagged": the tier counts below come from the LIBRARY's two-tier
|
|
87
88
|
// screening (hard count thresholds / soft score vs its threshold), while
|
|
88
89
|
// triage.md is ORDERED by the CLI's separate composite triage score
|
|
89
|
-
// (5×paste + 5×copy + 3×sidebar + 1×tab-away
|
|
90
|
-
//
|
|
90
|
+
// (by default 5×paste + 5×copy + 3×sidebar + 1×tab-away; config.scoreWeights
|
|
91
|
+
// can change it) within each tier. Label both explicitly so the console
|
|
92
|
+
// summary can't be read as "top N of triage.md".
|
|
93
|
+
const scoreWeights = resolveScoreWeights(config.scoreWeights);
|
|
91
94
|
console.log(`\nAnalyzing...`);
|
|
92
95
|
console.log(` Hard-flagged (hard signal crossed its count threshold): ${flaggedHard}`);
|
|
93
96
|
console.log(` Soft-flagged (library soft score >= its threshold): ${flaggedSoft}`);
|
|
94
97
|
console.log(` Clean: ${clean}`);
|
|
95
98
|
console.log(` Triage.md orders tier-first (hard > soft > clean), then by the CLI`);
|
|
96
|
-
console.log(
|
|
99
|
+
console.log(scoreWeights.isDefault
|
|
100
|
+
? ` triage score (5xpaste + 5xcopy + 3xsidebar + 1xtab-away) within a tier.`
|
|
101
|
+
: ` triage score (${formulaText(scoreWeights.weights, 'x')}, from scoreWeights) within a tier.`);
|
|
97
102
|
|
|
98
103
|
// 4. Render outputs
|
|
99
104
|
console.log(`\nRendering...`);
|
|
@@ -107,6 +112,8 @@ export async function run(args) {
|
|
|
107
112
|
const { renderExtensions } = await import('./renderers/extensions.js');
|
|
108
113
|
|
|
109
114
|
await renderSummaryCSV(summaries, triage, config);
|
|
115
|
+
const { renderScoreWeights } = await import('./renderers/score-weights.js');
|
|
116
|
+
renderScoreWeights(config);
|
|
110
117
|
await renderTriage(triage, config);
|
|
111
118
|
await renderEventLog(participants, config);
|
|
112
119
|
await renderExtensions(participants, config);
|
|
@@ -150,6 +157,12 @@ export async function run(args) {
|
|
|
150
157
|
if (replayAssets.count > 0) {
|
|
151
158
|
console.log(` replay/ — ${replayAssets.count} session replays (${(replayAssets.totalBytes / 1024 / 1024).toFixed(1)} MB)`);
|
|
152
159
|
}
|
|
160
|
+
// A skipped artifact is already visible in the report (the participant's
|
|
161
|
+
// replay section says why), but an analyst watching the CLI must not have
|
|
162
|
+
// to open the HTML to learn a recording did not make it.
|
|
163
|
+
for (const s of replayAssets.skipped) {
|
|
164
|
+
console.log(` replay/ — skipped ${s.participantId}: ${s.reason}`);
|
|
165
|
+
}
|
|
153
166
|
|
|
154
167
|
// HTML index page — references images/ folder (not base64-embedded)
|
|
155
168
|
const { renderHtmlIndex } = await import('./renderers/html-index.js');
|
package/src/core/monitor.js
CHANGED
|
@@ -64,7 +64,7 @@ export function init(userConfig) {
|
|
|
64
64
|
}, userConfig.domProtection || {}),
|
|
65
65
|
collectForPostHoc: Object.assign({
|
|
66
66
|
fullKeystrokeTimestamps: false,
|
|
67
|
-
rawMouseTrack:
|
|
67
|
+
rawMouseTrack: true, // see the privacy note at endTrial (flipped on 2026-09-02)
|
|
68
68
|
responseText: false,
|
|
69
69
|
windowPositionLog: true,
|
|
70
70
|
elementTrace: false,
|
|
@@ -378,18 +378,17 @@ export function init(userConfig) {
|
|
|
378
378
|
report.mouseMetrics = mouseMetrics;
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
// toggle.
|
|
381
|
+
// Raw per-sample mouse coordinates persist under `mouseTrack` (the
|
|
382
|
+
// name extract-core's field map expects: mouseTrack → mouseEvents, so a
|
|
383
|
+
// saved report round-trips through extractIntegrityData() without new
|
|
384
|
+
// glue) unless collectForPostHoc.rawMouseTrack is set false, in which
|
|
385
|
+
// case only the derived mouseMetrics above ship and the {x,y,t,type}
|
|
386
|
+
// samples never leave the browser. ON by default since 2026-09-02: a
|
|
387
|
+
// researcher who adds CH to an experiment has already adjudicated
|
|
388
|
+
// collecting behavioural traces, and an off-by-default gate made the
|
|
389
|
+
// report's trajectory panels read "no mouse data" for everyone who
|
|
390
|
+
// never found the toggle (found by CK on the bench harness). The
|
|
391
|
+
// toggle stays for protocols that need less.
|
|
393
392
|
if (config.collectForPostHoc.rawMouseTrack) {
|
|
394
393
|
report.mouseTrack = report.mouseEvents;
|
|
395
394
|
}
|
|
@@ -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.9.0',
|
|
39
39
|
data: {} // per-trial return is {}; session meta goes via addProperties
|
|
40
40
|
};
|
|
41
41
|
|
|
@@ -47,9 +47,37 @@ class CyborgHunterReplayExtension {
|
|
|
47
47
|
this._monitoring = false;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// Keyframe cadence on a jsPsych host: every TRIAL segment, always.
|
|
51
|
+
//
|
|
52
|
+
// The standalone recorder keyframes adaptively (spec §3's size trigger plus a
|
|
53
|
+
// segment fallback) because a persistent DOM makes a continuation cheap: the
|
|
54
|
+
// player carries the previous segment's tree forward and applies deltas. A
|
|
55
|
+
// jsPsych trial wipes the display and builds a new one, so there is nothing
|
|
56
|
+
// to carry forward — a continuation would make the player reconstruct each
|
|
57
|
+
// trial by replaying the wipe as a removal of everything followed by an
|
|
58
|
+
// insertion of everything, and would leave a seek into any trial dependent on
|
|
59
|
+
// the trials before it. §3 says as much: wiping hosts SHOULD keyframe every
|
|
60
|
+
// segment, which is also jsPsych-v1's own behaviour.
|
|
61
|
+
//
|
|
62
|
+
// Forced over researcher config rather than merged under it: the reason is a
|
|
63
|
+
// property of the host, not a tuning preference, so a value passed here is a
|
|
64
|
+
// misunderstanding rather than a choice. It is not discarded silently.
|
|
65
|
+
//
|
|
66
|
+
// "Every TRIAL segment" is exact, not loose. A jsPsych recording can also
|
|
67
|
+
// contain UNBRACKETED segments — the inter-trial interval, where the display
|
|
68
|
+
// wipe fires mutations between one trial's `on_finish` and the next trial's
|
|
69
|
+
// `on_load` — and those are recorded as CONTINUATIONS whatever this setting
|
|
70
|
+
// says (capture-dom's implicit-segment rule). That is correct: an ITI segment
|
|
71
|
+
// genuinely continues the outgoing trial's DOM as it is torn down, and its
|
|
72
|
+
// patches were numbered against that span before the segment existed.
|
|
50
73
|
initialize(params) {
|
|
51
74
|
this.params = params || {};
|
|
52
|
-
this.
|
|
75
|
+
if (this.params.keyframeEvery != null && this.params.keyframeEvery !== 1) {
|
|
76
|
+
console.warn('[cyborg-hunter-replay] ignoring keyframeEvery: ' +
|
|
77
|
+
this.params.keyframeEvery + ' — jsPsych wipes the display between ' +
|
|
78
|
+
'trials, so every trial segment is recorded as a keyframe.');
|
|
79
|
+
}
|
|
80
|
+
this.api = CHReplay.attach(Object.assign({}, this.params, { keyframeEvery: 1 }));
|
|
53
81
|
this.api.startSession();
|
|
54
82
|
this._stashChMonitor();
|
|
55
83
|
}
|
|
@@ -64,6 +92,36 @@ class CyborgHunterReplayExtension {
|
|
|
64
92
|
} catch (e) { /* standalone replay is fine */ }
|
|
65
93
|
}
|
|
66
94
|
|
|
95
|
+
// Spec §2's `host`: the runtime the recorder was embedded in, as opposed to
|
|
96
|
+
// `recorder`, which is this library. The recorder core is host-agnostic by
|
|
97
|
+
// construction and never sniffs for globals, so the adapter — the one piece
|
|
98
|
+
// that exists because jsPsych does — is where the answer comes from.
|
|
99
|
+
//
|
|
100
|
+
// §2 types host as `{name, version} | null` with version a required STRING,
|
|
101
|
+
// so a runtime that reports no version gets no host record rather than a
|
|
102
|
+
// half-record a conforming consumer would trip over.
|
|
103
|
+
//
|
|
104
|
+
// `version()` is the only shape checked for, because it is the only shape
|
|
105
|
+
// jsPsych has: 7.3.1 defines `version() { return version; }` on the JsPsych
|
|
106
|
+
// class and 8.x keeps the same core-API accessor. An earlier draft also
|
|
107
|
+
// accepted a plain string field; nothing produces one, so it was speculative
|
|
108
|
+
// dead code and went.
|
|
109
|
+
//
|
|
110
|
+
// Never throws: finalize()'s outer catch turns any throw into
|
|
111
|
+
// replayFinalizeError with NO autosave, and the least important field in
|
|
112
|
+
// the file must not be able to cost the recording.
|
|
113
|
+
_detectHost() {
|
|
114
|
+
try {
|
|
115
|
+
var jp = this.jsPsych;
|
|
116
|
+
if (!jp || typeof jp.version !== 'function') return null;
|
|
117
|
+
var v = jp.version();
|
|
118
|
+
if (typeof v !== 'string' || !v) return null;
|
|
119
|
+
return { name: 'jspsych', version: v };
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
67
125
|
// jsPsych 7 calls on_start unconditionally for every trial that lists the
|
|
68
126
|
// extension — must exist even as a no-op (see extension-cyborg-hunter.js).
|
|
69
127
|
on_start(_params) {}
|
|
@@ -105,7 +163,10 @@ class CyborgHunterReplayExtension {
|
|
|
105
163
|
} catch (e) {
|
|
106
164
|
console.warn('[cyborg-hunter-replay] could not pull CH session report:', e);
|
|
107
165
|
}
|
|
108
|
-
var result = await this.api.autoSaveNow({
|
|
166
|
+
var result = await this.api.autoSaveNow({
|
|
167
|
+
chSessionReport: chReport,
|
|
168
|
+
host: this._detectHost()
|
|
169
|
+
});
|
|
109
170
|
this._lastRecording = result.recording;
|
|
110
171
|
this.jsPsych.data.addProperties({ integrityReplayMeta: result.meta });
|
|
111
172
|
} catch (e) {
|
|
@@ -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.9.0',
|
|
14
14
|
data: { integrity: { type: 'object' } }
|
|
15
15
|
};
|
|
16
16
|
|