cyborg-hunter 0.3.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/README.md +145 -0
- package/bin/cyborg-hunter.js +20 -0
- package/dist/cyborg-hunter.esm.js +1534 -0
- package/dist/cyborg-hunter.min.js +6 -0
- package/dist/jspsych-cyborg-hunter.js +1 -0
- package/package.json +56 -0
- package/src/cli/analyzers/edge-exit.js +56 -0
- package/src/cli/analyzers/summary.js +99 -0
- package/src/cli/analyzers/triage.js +104 -0
- package/src/cli/config.js +99 -0
- package/src/cli/ingest.js +343 -0
- package/src/cli/init.js +31 -0
- package/src/cli/renderers/event-log.js +66 -0
- package/src/cli/renderers/extensions.js +43 -0
- package/src/cli/renderers/html-index.js +329 -0
- package/src/cli/renderers/summary-csv.js +70 -0
- package/src/cli/renderers/tab-timeline.js +149 -0
- package/src/cli/renderers/trajectories.js +607 -0
- package/src/cli/renderers/triage-md.js +29 -0
- package/src/cli/renderers/typing-profile.js +200 -0
- package/src/cli/report.js +100 -0
- package/src/core/index.js +13 -0
- package/src/core/monitor.js +404 -0
- package/src/core/scoring.js +153 -0
- package/src/core/signals/browser.js +303 -0
- package/src/core/signals/clipboard.js +91 -0
- package/src/core/signals/dom-protection.js +285 -0
- package/src/core/signals/focus.js +101 -0
- package/src/core/signals/mouse.js +117 -0
- package/src/core/signals/typing.js +110 -0
- package/src/core/state-machine.js +88 -0
- package/src/jspsych/extension.js +199 -0
- package/src/shared/constants.js +162 -0
- package/src/shared/schema.js +56 -0
- package/src/shared/validation.js +73 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// src/cli/ingest.js
|
|
2
|
+
// Loads JSON or CSV data files, extracts integrity data, validates schema.
|
|
3
|
+
//
|
|
4
|
+
// Handles three input pathways:
|
|
5
|
+
// 1. JSON, Shape 1 — one file per participant with a trials array containing
|
|
6
|
+
// an integrity sub-object: { participantId: 'P1', trials: [{ integrity: {...} }] }
|
|
7
|
+
// 2. JSON, Shape 2 — rule-gallery format: { metadata: { subjectId: 'P1' },
|
|
8
|
+
// responses: [{ mouseTrack: [...], tabAwayEvents: [...] }] }
|
|
9
|
+
// 3. CSV — jsPsych default save format. Each row is a trial; nested objects
|
|
10
|
+
// (integrity, integritySession, integrityScore) are JSON-stringified into
|
|
11
|
+
// single cells by Papa Parse's unparse. We unwrap them back into objects,
|
|
12
|
+
// then route through Shape 1.
|
|
13
|
+
//
|
|
14
|
+
// Legacy field names (mouseTrack) are mapped to CyborgHunter schema names (mouseEvents).
|
|
15
|
+
|
|
16
|
+
import { readFileSync, readdirSync } from 'fs';
|
|
17
|
+
import { join, extname } from 'path';
|
|
18
|
+
import Papa from 'papaparse';
|
|
19
|
+
import { TRIAL_REPORT_FIELDS } from '../shared/schema.js';
|
|
20
|
+
|
|
21
|
+
export async function ingest(config) {
|
|
22
|
+
const files = findFiles(config.dataDir, config.filePattern);
|
|
23
|
+
const participants = [];
|
|
24
|
+
const warnings = [];
|
|
25
|
+
|
|
26
|
+
for (const file of files) {
|
|
27
|
+
try {
|
|
28
|
+
const text = readFileSync(file, 'utf8');
|
|
29
|
+
// Branch by extension. CSV is jsPsych's default save format; JSON is what
|
|
30
|
+
// rule-gallery and other server-side-saving experiments use.
|
|
31
|
+
const raw = extname(file).toLowerCase() === '.csv'
|
|
32
|
+
? parseCsvToRaw(text, config)
|
|
33
|
+
: JSON.parse(text);
|
|
34
|
+
const result = extractIntegrityData(raw, config);
|
|
35
|
+
|
|
36
|
+
// --participant flag filters to a single participant
|
|
37
|
+
if (config.singleParticipant && result.participantId !== config.singleParticipant) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (result.warnings.length > 0) {
|
|
42
|
+
warnings.push({ file, warnings: result.warnings });
|
|
43
|
+
}
|
|
44
|
+
if (result.trials.length > 0) {
|
|
45
|
+
participants.push(result);
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
warnings.push({ file, warnings: [`Failed to parse: ${e.message}`] });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { participants, warnings };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Extracts integrity trial data from a single participant's raw JSON.
|
|
56
|
+
// Returns { participantId, trials, warnings, metadata }.
|
|
57
|
+
export function extractIntegrityData(raw, config) {
|
|
58
|
+
const warnings = [];
|
|
59
|
+
const pidField = config.participantIdField || 'participantId';
|
|
60
|
+
const intField = config.integrityField || 'integrity';
|
|
61
|
+
|
|
62
|
+
// Determine participant ID — check top level, then metadata sub-object
|
|
63
|
+
const participantId = raw[pidField] || raw.metadata?.[pidField] || 'unknown';
|
|
64
|
+
|
|
65
|
+
let trials = [];
|
|
66
|
+
|
|
67
|
+
// Shape 1: { trials: [{ integrity: {...} }, ...] }
|
|
68
|
+
// Each trial has an `integrity` sub-object added by CyborgHunter's endTrial()
|
|
69
|
+
if (Array.isArray(raw.trials)) {
|
|
70
|
+
trials = raw.trials
|
|
71
|
+
.filter(t => t[intField])
|
|
72
|
+
.map(t => t[intField]);
|
|
73
|
+
// P3: jsPsych extension data carries trialStart_perfNow on every trial
|
|
74
|
+
// (set by the wrapper's on_load), so the fast path subtracts directly
|
|
75
|
+
// and gives exact trial-relative startRel_ms. Without this, renderers
|
|
76
|
+
// see only session-absolute `start` and plot tab-away markers off-canvas.
|
|
77
|
+
normalizeTabAwayTimestamps(trials, raw);
|
|
78
|
+
}
|
|
79
|
+
// Shape 2: { responses: [{ mouseTrack, tabAwayEvents, ... }] } (rule-gallery legacy format)
|
|
80
|
+
// Signal data lives directly on the response — no integrity wrapper.
|
|
81
|
+
// We apply field name mapping (mouseTrack → mouseEvents).
|
|
82
|
+
else if (Array.isArray(raw.responses)) {
|
|
83
|
+
trials = raw.responses.map((r, i) => mapLegacyFields({
|
|
84
|
+
...r,
|
|
85
|
+
_sourceIndex: i
|
|
86
|
+
}));
|
|
87
|
+
// Normalize tab-away timestamps from session-relative performance.now()
|
|
88
|
+
// to trial-relative milliseconds so renderers can plot them on the
|
|
89
|
+
// same x-axis as mouseEvents[].t (which is already trial-relative).
|
|
90
|
+
normalizeTabAwayTimestamps(trials, raw);
|
|
91
|
+
}
|
|
92
|
+
// Shape 3: Top-level array of trials
|
|
93
|
+
else if (Array.isArray(raw)) {
|
|
94
|
+
trials = raw.filter(t => t[intField]).map(t => t[intField]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (trials.length === 0) {
|
|
98
|
+
warnings.push(`No integrity data found (looked for "${intField}" field)`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Validate each trial has the minimum required fields from the schema.
|
|
102
|
+
// Missing fields get a warning but don't prevent analysis.
|
|
103
|
+
for (const trial of trials) {
|
|
104
|
+
const missing = [];
|
|
105
|
+
for (const [field, spec] of Object.entries(TRIAL_REPORT_FIELDS)) {
|
|
106
|
+
if (spec.required && trial[field] === undefined) {
|
|
107
|
+
missing.push(field);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (missing.length > 0) {
|
|
111
|
+
warnings.push(`Trial ${trial.trialId || '?'}: missing fields: ${missing.join(', ')}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { session, score } = findSessionData(raw);
|
|
116
|
+
if (session === null && trials.length > 0) {
|
|
117
|
+
warnings.push('No session-level integrity data — some signals unavailable (did the experiment call getSessionReport()?)');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { participantId, trials, warnings, metadata: raw.metadata || {}, session, score };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Locates session-level integrity data in one of three locations, in priority order:
|
|
124
|
+
// 1. raw.metadata.integritySession / integrityScore — the card-games convention.
|
|
125
|
+
// 2. Last trial's integritySession / integrityScore — jsPsych addDataToLastTrial (Option A).
|
|
126
|
+
// 3. Any trial's integritySession — fallback (Option B).
|
|
127
|
+
// Returns { session, score }, both null if not found.
|
|
128
|
+
function findSessionData(raw) {
|
|
129
|
+
// 1. card-games metadata convention
|
|
130
|
+
if (raw.metadata?.integritySession) {
|
|
131
|
+
return { session: raw.metadata.integritySession, score: raw.metadata.integrityScore || null };
|
|
132
|
+
}
|
|
133
|
+
// 2. jsPsych addDataToLastTrial convention (Option A)
|
|
134
|
+
if (Array.isArray(raw.trials) && raw.trials.length > 0) {
|
|
135
|
+
const last = raw.trials[raw.trials.length - 1];
|
|
136
|
+
if (last?.integritySession) {
|
|
137
|
+
return { session: last.integritySession, score: last.integrityScore || null };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// 3. Any-trial fallback
|
|
141
|
+
if (Array.isArray(raw.trials)) {
|
|
142
|
+
const t = raw.trials.find(x => x?.integritySession);
|
|
143
|
+
if (t) return { session: t.integritySession, score: t.integrityScore || null };
|
|
144
|
+
}
|
|
145
|
+
return { session: null, score: null };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Maps rule-gallery legacy field names to CyborgHunter schema names.
|
|
149
|
+
// The CLI can then use a single set of field names downstream.
|
|
150
|
+
const LEGACY_FIELD_MAP = {
|
|
151
|
+
mouseTrack: 'mouseEvents',
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Normalizes tabAwayEvents[*].start from session-relative performance.now()
|
|
155
|
+
// (milliseconds since browser navigation) to trial-relative milliseconds
|
|
156
|
+
// (milliseconds since the trial began), stored as `startRel_ms`.
|
|
157
|
+
//
|
|
158
|
+
// The rule-gallery data schema stores mouseTrack entries with a per-trial
|
|
159
|
+
// relative `t` field (zero-based per trial), but tabAwayEvents are stored
|
|
160
|
+
// with session-relative `start` values. Without normalization, renderers
|
|
161
|
+
// using the same x-scale plot tab-aways hundreds of thousands of ms off-canvas.
|
|
162
|
+
//
|
|
163
|
+
// Algorithm: trial boundaries exist in wall-clock (response.timestamp is the
|
|
164
|
+
// trial END wall-clock, responseTime_ms is trial duration). We infer a
|
|
165
|
+
// participant-level `sessionOffset_ms` = (performance.now() value at session
|
|
166
|
+
// start) by using the relationship:
|
|
167
|
+
// tab.start == sessionOffset + trialStart_wallclockRel + positionInTrial
|
|
168
|
+
// where positionInTrial ∈ [0, responseTime_ms]. We estimate sessionOffset as
|
|
169
|
+
// the median of (firstTab.start - trialStartRel) across trials, clipped to
|
|
170
|
+
// the valid range implied by each (trial, firstTab) pair.
|
|
171
|
+
function normalizeTabAwayTimestamps(trials, raw) {
|
|
172
|
+
// P3 fast path: if every trial carries a monitor-supplied trialStart_perfNow
|
|
173
|
+
// (same clock as tabAwayEvents[*].start), compute startRel_ms by direct
|
|
174
|
+
// subtraction. This skips the heuristic estimator entirely and is exact.
|
|
175
|
+
const everyTrialHasPerfNow = trials.length > 0 && trials.every(t =>
|
|
176
|
+
typeof (t.trialStart_perfNow ?? t.startTime) === 'number'
|
|
177
|
+
);
|
|
178
|
+
if (everyTrialHasPerfNow) {
|
|
179
|
+
for (const trial of trials) {
|
|
180
|
+
const tabs = trial.tabAwayEvents;
|
|
181
|
+
if (!Array.isArray(tabs) || tabs.length === 0) continue;
|
|
182
|
+
const trialStart = trial.trialStart_perfNow ?? trial.startTime;
|
|
183
|
+
trial.tabAwayEvents = tabs.map(ta => ({
|
|
184
|
+
...ta,
|
|
185
|
+
startRel_ms: typeof ta.start === 'number' ? ta.start - trialStart : null
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Legacy estimator path — used when the detector didn't record a
|
|
192
|
+
// per-trial performance.now() anchor (e.g., rule-gallery pre-P3 data).
|
|
193
|
+
const sessStartIso = raw.metadata?.startTime;
|
|
194
|
+
if (!sessStartIso) return;
|
|
195
|
+
const sessStartMs = Date.parse(sessStartIso);
|
|
196
|
+
if (!Number.isFinite(sessStartMs)) return;
|
|
197
|
+
|
|
198
|
+
// Precompute wall-clock trial start (ms since session start) for each trial.
|
|
199
|
+
const trialStartRels = trials.map(t => {
|
|
200
|
+
if (!t.timestamp || t.responseTime_ms == null) return null;
|
|
201
|
+
const endMs = Date.parse(t.timestamp);
|
|
202
|
+
if (!Number.isFinite(endMs)) return null;
|
|
203
|
+
return (endMs - sessStartMs) - t.responseTime_ms;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Collect offset candidates from each trial that has ≥1 tab-away.
|
|
207
|
+
// For trial i with first tab at T and trialStartRel S, responseTime R:
|
|
208
|
+
// offset ∈ [T - (S + R), T - S] (tab must lie within [S, S+R])
|
|
209
|
+
// Median of midpoints is a robust estimator.
|
|
210
|
+
const candidates = [];
|
|
211
|
+
for (let i = 0; i < trials.length; i++) {
|
|
212
|
+
const tabs = trials[i].tabAwayEvents;
|
|
213
|
+
if (!Array.isArray(tabs) || tabs.length === 0) continue;
|
|
214
|
+
const S = trialStartRels[i];
|
|
215
|
+
const R = trials[i].responseTime_ms;
|
|
216
|
+
if (S == null || R == null) continue;
|
|
217
|
+
const T = tabs[0].start;
|
|
218
|
+
if (typeof T !== 'number') continue;
|
|
219
|
+
// Midpoint of the valid offset interval for this trial's first tab.
|
|
220
|
+
candidates.push((T - S - R / 2));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (candidates.length === 0) return;
|
|
224
|
+
|
|
225
|
+
candidates.sort((a, b) => a - b);
|
|
226
|
+
const sessionOffset = candidates[Math.floor(candidates.length / 2)];
|
|
227
|
+
|
|
228
|
+
// Apply normalization — add startRel_ms, keep original start.
|
|
229
|
+
for (let i = 0; i < trials.length; i++) {
|
|
230
|
+
const tabs = trials[i].tabAwayEvents;
|
|
231
|
+
if (!Array.isArray(tabs) || tabs.length === 0) continue;
|
|
232
|
+
const S = trialStartRels[i];
|
|
233
|
+
if (S == null) continue;
|
|
234
|
+
trials[i].tabAwayEvents = tabs.map(ta => ({
|
|
235
|
+
...ta,
|
|
236
|
+
startRel_ms: (typeof ta.start === 'number')
|
|
237
|
+
? ta.start - sessionOffset - S
|
|
238
|
+
: null
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function mapLegacyFields(trial) {
|
|
244
|
+
const mapped = { ...trial };
|
|
245
|
+
for (const [oldName, newName] of Object.entries(LEGACY_FIELD_MAP)) {
|
|
246
|
+
if (mapped[oldName] !== undefined && mapped[newName] === undefined) {
|
|
247
|
+
mapped[newName] = mapped[oldName];
|
|
248
|
+
delete mapped[oldName];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Flag distinguishes "no tracking hardware" from "tracked, zero events"
|
|
252
|
+
mapped.mouseDataAvailable = Array.isArray(mapped.mouseEvents) && mapped.mouseEvents.length > 0;
|
|
253
|
+
return mapped;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Finds files matching a glob pattern in the given directory.
|
|
257
|
+
// Supports:
|
|
258
|
+
// - `*.json` (default, broadened to also match `*.csv`)
|
|
259
|
+
// - `*.csv` (CSV-only)
|
|
260
|
+
// - `*.{json,csv}` (explicit brace expansion)
|
|
261
|
+
// - `gallery_*.json` (other simple wildcard patterns)
|
|
262
|
+
//
|
|
263
|
+
// The default `*.json` is broadened to JSON-or-CSV because jsPsych's `.csv()`
|
|
264
|
+
// save is the most common shape we'll see in the wild; users on JSON pipelines
|
|
265
|
+
// are unaffected.
|
|
266
|
+
function findFiles(dir, pattern) {
|
|
267
|
+
const matchers = expandPatternToMatchers(pattern);
|
|
268
|
+
const files = readdirSync(dir).filter(f => matchers.some(m => m.test(f)));
|
|
269
|
+
return files.map(f => join(dir, f)).sort();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Turns a single user-facing pattern into one or more anchored RegExp matchers.
|
|
273
|
+
// Brace expansion is the only "fancy" feature supported; everything else is the
|
|
274
|
+
// classic wildcard-to-regex conversion.
|
|
275
|
+
function expandPatternToMatchers(pattern) {
|
|
276
|
+
// Default broadens to JSON + CSV.
|
|
277
|
+
if (pattern === '*.json') return [/\.json$/i, /\.csv$/i];
|
|
278
|
+
// Brace pattern: *.{json,csv} → expand to ['*.json', '*.csv'].
|
|
279
|
+
const brace = pattern.match(/^(.*)\{([^}]+)\}(.*)$/);
|
|
280
|
+
if (brace) {
|
|
281
|
+
const [, prefix, alts, suffix] = brace;
|
|
282
|
+
return alts.split(',').map(a => globToRegex(prefix + a.trim() + suffix));
|
|
283
|
+
}
|
|
284
|
+
return [globToRegex(pattern)];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function globToRegex(pat) {
|
|
288
|
+
// Convert glob pattern to anchored regex: * → .*, escape dots.
|
|
289
|
+
return new RegExp('^' + pat.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Parses jsPsych CSV output into a Shape-1 raw object that extractIntegrityData
|
|
293
|
+
// already understands: `{ [pidField]: ..., trials: [...rows...] }`.
|
|
294
|
+
//
|
|
295
|
+
// jsPsych saves CSV via Papa Parse's `unparse()`. Nested objects/arrays in trial
|
|
296
|
+
// data (e.g., the `integrity` field returned by the extension's on_finish, or
|
|
297
|
+
// the `integritySession` and `integrityScore` blobs attached to the last trial
|
|
298
|
+
// via addDataToLastTrial) get JSON-stringified into single cells. We reverse
|
|
299
|
+
// that here: any cell whose string value starts with `{` or `[` is run through
|
|
300
|
+
// JSON.parse, restoring the nested structure for downstream analyzers.
|
|
301
|
+
//
|
|
302
|
+
// Participant ID is hoisted from the first row to the top level so the Shape-1
|
|
303
|
+
// branch in extractIntegrityData picks it up via raw[pidField].
|
|
304
|
+
function parseCsvToRaw(text, config) {
|
|
305
|
+
const pidField = config.participantIdField || 'participantId';
|
|
306
|
+
// Papa Parse mis-handles a trailing newline on the final cell of the last
|
|
307
|
+
// row (treats it as an unterminated quoted field). POSIX convention is to
|
|
308
|
+
// end text files with a newline, so almost every CSV from the wild has one.
|
|
309
|
+
// Trim trailing whitespace defensively.
|
|
310
|
+
const result = Papa.parse(text.replace(/\s+$/, ''), {
|
|
311
|
+
header: true,
|
|
312
|
+
skipEmptyLines: true,
|
|
313
|
+
dynamicTyping: true, // numbers and booleans parsed natively, strings stay strings
|
|
314
|
+
});
|
|
315
|
+
const rows = result.data || [];
|
|
316
|
+
|
|
317
|
+
// Walk every cell; if it looks like JSON, parse it. Leave non-JSON strings alone.
|
|
318
|
+
for (const row of rows) {
|
|
319
|
+
for (const [key, val] of Object.entries(row)) {
|
|
320
|
+
if (typeof val !== 'string') continue;
|
|
321
|
+
const trimmed = val.trim();
|
|
322
|
+
if (trimmed.length === 0) continue;
|
|
323
|
+
const first = trimmed[0];
|
|
324
|
+
if (first !== '{' && first !== '[') continue;
|
|
325
|
+
try {
|
|
326
|
+
row[key] = JSON.parse(trimmed);
|
|
327
|
+
} catch {
|
|
328
|
+
// Cell looked like JSON but didn't parse — leave the raw string.
|
|
329
|
+
// This is rare (e.g., a free-text response that happens to start with `{`)
|
|
330
|
+
// and downstream code can handle string values gracefully.
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Hoist participant ID from the first row to the top level so Shape-1 ingest
|
|
336
|
+
// finds it via raw[pidField]. Falls back to 'unknown' if the column isn't there.
|
|
337
|
+
const participantId = rows[0]?.[pidField] ?? 'unknown';
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
[pidField]: participantId,
|
|
341
|
+
trials: rows,
|
|
342
|
+
};
|
|
343
|
+
}
|
package/src/cli/init.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// src/cli/init.js
|
|
2
|
+
// Generates a minimal starter config file in the current directory.
|
|
3
|
+
// The user edits dataDir and filePattern to point at their data.
|
|
4
|
+
|
|
5
|
+
import { writeFileSync, existsSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
|
|
8
|
+
export async function runInit() {
|
|
9
|
+
const configPath = join(process.cwd(), 'cyborg-hunter.config.json');
|
|
10
|
+
|
|
11
|
+
if (existsSync(configPath)) {
|
|
12
|
+
console.log(`Config already exists: ${configPath}`);
|
|
13
|
+
console.log('Delete it first if you want to regenerate.');
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Minimal config — just the fields every project needs to set.
|
|
18
|
+
// filePattern defaults to JSON-or-CSV because jsPsych's `.csv()` save is
|
|
19
|
+
// the most common format we see in the wild (rule-gallery uses JSON; most
|
|
20
|
+
// jsPsych setups use CSV).
|
|
21
|
+
const config = {
|
|
22
|
+
dataDir: './data',
|
|
23
|
+
filePattern: '*.{json,csv}',
|
|
24
|
+
outputDir: './cyborg-hunter-report'
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
28
|
+
console.log(`Created ${configPath}`);
|
|
29
|
+
console.log('Edit dataDir and filePattern to match your data location.');
|
|
30
|
+
console.log('See docs/configuration.md for all available options.');
|
|
31
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// src/cli/renderers/event-log.js
|
|
2
|
+
// Writes event-log.csv — chronological clipboard/paste events across all participants.
|
|
3
|
+
// Each row is one event with participant, trial, type, timestamp, and text (if paste).
|
|
4
|
+
|
|
5
|
+
import { writeFileSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
|
|
8
|
+
export async function renderEventLog(participants, config) {
|
|
9
|
+
// duration_ms column is populated for tab-aways (which have intrinsic
|
|
10
|
+
// duration); empty for instantaneous events (copy, paste, drop, synthetic).
|
|
11
|
+
const header = 'participantId,trialId,eventType,timestamp,duration_ms,text';
|
|
12
|
+
const rows = [];
|
|
13
|
+
|
|
14
|
+
for (const p of participants) {
|
|
15
|
+
for (const trial of p.trials) {
|
|
16
|
+
const trialId = trial.trialId || trial.ruleId || '?';
|
|
17
|
+
|
|
18
|
+
// Paste events
|
|
19
|
+
for (const e of (trial.pasteEvents || [])) {
|
|
20
|
+
rows.push(formatRow(p.participantId, trialId, 'paste', e.t, '', e.text));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Copy events
|
|
24
|
+
for (const e of (trial.copyEvents || [])) {
|
|
25
|
+
rows.push(formatRow(p.participantId, trialId, 'copy', e.t, '', ''));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Drop events
|
|
29
|
+
for (const e of (trial.dropEvents || [])) {
|
|
30
|
+
rows.push(formatRow(p.participantId, trialId, 'drop', e.t, '', e.text));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Synthetic insertions (text appeared without keystrokes)
|
|
34
|
+
for (const e of (trial.syntheticInsertions || [])) {
|
|
35
|
+
rows.push(formatRow(p.participantId, trialId, 'synthetic', e.t, '', e.text));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Tab-away events. timestamp uses the session-absolute `start` (same
|
|
39
|
+
// performance.now() clock the copy/paste `t` field uses), so all rows
|
|
40
|
+
// in this CSV share one chronological scale. The `text` column carries
|
|
41
|
+
// the tab-away type (windowBlur / visibilityChange / etc.) so analysts
|
|
42
|
+
// can filter by trigger.
|
|
43
|
+
for (const e of (trial.tabAwayEvents || [])) {
|
|
44
|
+
rows.push(formatRow(p.participantId, trialId, 'tabAway', e.start, e.duration_ms, e.type || ''));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Sort chronologically within each participant
|
|
50
|
+
const csv = [header, ...rows].join('\n') + '\n';
|
|
51
|
+
const outPath = join(config.outputDir, 'event-log.csv');
|
|
52
|
+
writeFileSync(outPath, csv);
|
|
53
|
+
console.log(` event-log.csv — ${rows.length} events`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatRow(pid, trialId, type, timestamp, duration, text) {
|
|
57
|
+
return [pid, trialId, type, timestamp ?? '', duration ?? '', escapeCSV(text ?? '')].join(',');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function escapeCSV(val) {
|
|
61
|
+
const str = String(val);
|
|
62
|
+
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
|
63
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
64
|
+
}
|
|
65
|
+
return str;
|
|
66
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// src/cli/renderers/extensions.js
|
|
2
|
+
// Writes extensions.csv — lists which AI extensions/tools were detected
|
|
3
|
+
// for which participants. One row per participant × extension.
|
|
4
|
+
// Also includes sidebar detection as a separate row.
|
|
5
|
+
|
|
6
|
+
import { writeFileSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
|
|
9
|
+
export async function renderExtensions(participants, config) {
|
|
10
|
+
const header = 'participantId,detectionType,name,details';
|
|
11
|
+
const rows = [];
|
|
12
|
+
|
|
13
|
+
for (const p of participants) {
|
|
14
|
+
const pid = p.participantId;
|
|
15
|
+
|
|
16
|
+
// Extensions detected (from browser scan)
|
|
17
|
+
const extensions = p.session?.aiExtensionsFound || p.trials[0]?.extensionsDetected || [];
|
|
18
|
+
for (const ext of extensions) {
|
|
19
|
+
const name = typeof ext === 'string' ? ext : ext.name || 'unknown';
|
|
20
|
+
rows.push(`${pid},extension,${escapeCSV(name)},`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Sidebar detection
|
|
24
|
+
const hasSidebar = p.trials.some(t => (t.sidebarGapPx || 0) > 0);
|
|
25
|
+
if (hasSidebar) {
|
|
26
|
+
const maxGap = Math.max(...p.trials.map(t => t.sidebarGapPx || 0));
|
|
27
|
+
rows.push(`${pid},sidebar,browser_sidebar,${maxGap}px gap`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const csv = [header, ...rows].join('\n') + '\n';
|
|
32
|
+
const outPath = join(config.outputDir, 'extensions.csv');
|
|
33
|
+
writeFileSync(outPath, csv);
|
|
34
|
+
console.log(` extensions.csv — ${rows.length} detections`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function escapeCSV(val) {
|
|
38
|
+
const str = String(val);
|
|
39
|
+
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
|
40
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
41
|
+
}
|
|
42
|
+
return str;
|
|
43
|
+
}
|