-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathtest.ts
More file actions
4999 lines (4504 loc) · 268 KB
/
test.ts
File metadata and controls
4999 lines (4504 loc) · 268 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env npx tsx
// SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 NeuroSkill.com
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 only.
/**
* test.ts — Comprehensive smoke-test for the Skill WebSocket + HTTP API.
*
* ═══════════════════════════════════════════════════════════════════════════════
* ARCHITECTURE OVERVIEW
* ═══════════════════════════════════════════════════════════════════════════════
*
* The NeuroSkill™ app runs a combined HTTP + WebSocket server on a single TCP port.
* Both protocols share the same port and the same command set.
*
* Communication models:
* • WEBSOCKET REQUEST/RESPONSE — Client sends { command: "..." }, server
* replies with { command: "...", ok: true/false, ...payload }.
* The "command" field echoes the request for client-side matching.
*
* • HTTP REST — Each command is also reachable as a REST endpoint:
* GET /status → status
* GET /sessions → sessions
* POST /label → label
* POST /notify → notify
* POST /say → say (TTS, fire-and-forget)
* POST /calibrate → run_calibration (auto-start)
* POST /timer → timer (open + auto-start)
* POST /search → search (EEG ANN)
* POST /search_labels → search_labels
* POST /compare → compare
* POST /sleep → sleep staging
* POST /umap → enqueue UMAP job
* GET /umap/:job_id → poll UMAP job
* GET /calibrations → list profiles
* POST /calibrations → create profile
* GET /calibrations/:id
* PATCH /calibrations/:id
* DELETE /calibrations/:id
* GET /dnd → dnd status (config + live eligibility + OS state)
* POST /dnd → dnd_set (force enable/disable)
* GET /llm/status → llm_status
* POST /llm/start → llm_start (loads model, may take seconds)
* POST /llm/stop → llm_stop (frees GPU/CPU resources)
* GET /llm/catalog → llm_catalog (model list with download states)
* POST /llm/download → llm_download (fire-and-forget)
* POST /llm/cancel_download → llm_cancel_download
* POST /llm/delete → llm_delete (removes cached model)
* GET /llm/logs → llm_logs (last 500 log lines)
* POST /llm/chat → non-streaming chat; accepts { message, images?, system? }
* or full OpenAI messages array; supports base64 image upload
*
* • HTTP UNIVERSAL TUNNEL — POST / with { "command": "…", …params }
* behaves identically to the WebSocket protocol.
*
* • BROADCAST EVENTS — Server pushes unsolicited JSON objects to ALL connected
* WebSocket clients. These have { event: "..." } instead of { command: "..." }.
* Events fire in real-time as data arrives from the Muse headband.
*
* Data pipeline:
* 1. Muse headband → BLE → raw EEG (4ch × 256Hz), PPG (64Hz), IMU (~50Hz)
* 2. Every 5 seconds, a 5s EEG window (epoch) is fed to the ZUNA GPU encoder
* (WebGPU / wgpu) which produces a high-dimensional embedding vector.
* 3. Embeddings are stored in per-day SQLite databases (YYYYMMDD/embeddings.sqlite).
* 4. Band powers, derived scores, sleep staging, and search indices are all
* computed from these embeddings and the raw spectral data.
*
* Storage layout:
* ~/.skill/data/
* ├── 20260224/
* │ └── embeddings.sqlite — embedding vectors + per-epoch scores
* ├── 20260223/
* │ └── embeddings.sqlite
* ├── labels.sqlite — user text annotations (cross-day)
* └── ...
*
* ═══════════════════════════════════════════════════════════════════════════════
* COMMANDS TESTED
* ═══════════════════════════════════════════════════════════════════════════════
*
* 1. STATUS — Full snapshot of device, session, embeddings, scores, sleep
* 2. SESSIONS — List all recording sessions across all days
* 3. NOTIFY — Native OS notification (title + optional body)
* 4. SAY — Speak text via on-device TTS (fire-and-forget)
* 5. LABEL — Create a timestamped text annotation
* 6. SEARCH_LABELS — Search labels by free-text query (text / context / both modes)
* 7. HOOKS_STATUS — Proactive Hook rules + scenario + last-trigger metadata
* 7b. HOOKS_GET/SET — Full CRUD for hook rules via hooks_get / hooks_set
* 8. HOOKS_SUGGEST — Suggest threshold from labels + EEG embedding distances
* 9. HOOKS_LOG — Paginated hook trigger audit log from hooks.sqlite (includes scenario in hook_json)
* 10. INTERACTIVE_SEARCH — Cross-modal 4-layer graph search (query → labels → EEG → found labels)
* 11. SEARCH — ANN similarity search across EEG embedding history
* 9. COMPARE — Side-by-side metrics for two time ranges + UMAP enqueue
* 10. SLEEP — Sleep stage classification for a time range
* 11. CALIBRATE — list_calibrations + run_calibration (open & auto-start)
* 12. TIMER — Open focus-timer window and auto-start work phase
* 13. UMAP — Enqueue a 3D dimensionality reduction job
* 14. UMAP_POLL — Poll for UMAP job completion
* 15. DND — Do Not Disturb status (dnd) + force override (dnd_set); GET/POST /dnd
* 16. LLM — LLM server management + streaming chat + image upload
* (llm_status, llm_catalog, llm_download, llm_logs, llm_chat);
* REST /llm/* endpoints; POST /llm/chat with base64 images
* 17. SCREENSHOT SEARCH — 6 cross-modal screenshot commands:
* search_screenshots (OCR text, semantic/substring),
* screenshots_around (temporal), search_screenshots_vision (CLIP vector),
* search_screenshots_by_image_b64 (base64 image → CLIP → HNSW),
* screenshots_for_eeg (EEG → screenshots), eeg_for_screenshots (screenshots → EEG)
* 18. SKILLS (Tauri-only) — Verify WS correctly rejects Tauri-only skill commands
* (list_skills, get_disabled_skills, set_disabled_skills, sync_skills_now, etc.)
* 19. UNKNOWN — Verify error handling for bad commands
* 20. BROADCASTS — Listen for server-pushed real-time events
* 21. HTTP API — REST endpoints + universal tunnel on the same port
* 22. SESSION_METRICS — Per-session full/first-half/second-half metrics + trend directions
* 23. CALIBRATION CRUD — Full create/get/update/delete lifecycle for calibration profiles
* 24. SLEEP SCHEDULE — Read and update sleep schedule (bedtime, wake, preset)
* 25. HEALTH — HealthKit: summary, query (sleep/steps/workouts/hr/metrics), metric_types, sync
* 26. LLM EXTENDED — Additional LLM management: downloads, refresh, hardware_fit,
* select_model, select_mmproj, pause/resume_download,
* set_autoload_mmproj, add_model
* 27. IROH EXTENDED — iroh_scope_groups, iroh_client_permissions, iroh_phone_invite
* 28. ACCESS TOKENS — REST-only: list, create, revoke, delete tokens; delete-default guard
* 29. DEVICE MANAGEMENT — REST-only: list devices, pair, forget, set-preferred
* 30. SCANNER CONTROL — REST-only: scanner state, start, stop
* 31. RECONNECT CONTROL — REST-only: reconnect state, enable, disable
* 32. SERVICE MANAGEMENT — Root-level: service status (install/uninstall skipped for safety)
* 33. LSL DISCOVERY — REST-only: discover available LSL streams
* 34. DAEMON INFO — REST-only: daemon version + recent log lines
* 35. HEALTH PROBES — Root-level: /healthz and /readyz (no auth)
*
* ═══════════════════════════════════════════════════════════════════════════════
* USAGE
* ═══════════════════════════════════════════════════════════════════════════════
*
* npx tsx test.ts # auto-discover; try WS, fall back to HTTP
* npx tsx test.ts 62853 # explicit port (same auto-transport logic)
* npx tsx test.ts --ws # force WebSocket (fail if unavailable)
* npx tsx test.ts --http # force HTTP (skip WS-only tests)
* npx tsx test.ts 62853 --http # explicit port + HTTP
*
* Requires: Node ≥ 18 (native fetch + WebSocket), bonjour-service (devDependency).
* Exits 0 on success, 1 on failure.
*/
import { Bonjour } from "bonjour-service";
import { execSync } from "child_process";
import WebSocket from "ws";
// ── Config ────────────────────────────────────────────────────────────────────
// Parse argv: optional port number and optional --ws / --http flags.
const _argv = process.argv.slice(2);
const PORT: number | null = _argv.find(a => /^\d+$/.test(a)) ? Number(_argv.find(a => /^\d+$/.test(a))) : null;
const FORCE_WS = _argv.includes("--ws");
const FORCE_HTTP = _argv.includes("--http");
const TIMEOUT_MS = 600_000; // 10 min — UMAP compute can be very slow on large datasets
const WS_URL = (port: number) => `ws://127.0.0.1:${port}`;
let ws: WebSocket;
let httpBase = "";
/** Active transport for command tests — set during connection in main(). */
let transport: "ws" | "http" = "ws";
let timer: ReturnType<typeof setTimeout>;
let passed = 0;
let failed = 0;
// ── ANSI formatting ───────────────────────────────────────────────────────────
const GRAY = "\x1b[90m";
const GREEN = "\x1b[32m";
const RED = "\x1b[31m";
const CYAN = "\x1b[36m";
const YELLOW = "\x1b[33m";
const BOLD = "\x1b[1m";
const DIM = "\x1b[2m";
const RESET = "\x1b[0m";
function ok(msg: string) { console.log(` ${GREEN}✓${RESET} ${msg}`); passed++; }
function fail(msg: string) { console.log(` ${RED}✗${RESET} ${msg}`); failed++; }
function info(msg: string) { console.log(` ${CYAN}ℹ${RESET} ${DIM}${msg}${RESET}`); }
function heading(msg: string) { console.log(`\n ${BOLD}━━ ${msg} ━━${RESET}`); }
function field(name: string, value: unknown, desc: string) {
console.log(` ${GRAY}│${RESET} ${YELLOW}${name}${RESET} = ${BOLD}${value}${RESET} ${DIM}${desc}${RESET}`);
}
function die(msg: string): never { console.error(`\n${RED}FATAL:${RESET} ${msg}`); process.exit(1); }
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* testWs(port) — Quick probe to check if a WebSocket server is listening.
* Opens a connection, waits 1.5s for "open", then closes. Returns true/false.
*/
function testWs(p: number): Promise<boolean> {
return new Promise((resolve) => {
try {
const w = new WebSocket(`ws://127.0.0.1:${p}`);
const t = setTimeout(() => { try { w.close(); } catch {} resolve(false); }, 1500);
w.on("open", () => { clearTimeout(t); w.close(); resolve(true); });
w.on("error", () => { clearTimeout(t); resolve(false); });
} catch { resolve(false); }
});
}
/**
* send(cmd, timeoutMs) — Send a JSON command and wait for the matching response.
*
* In WebSocket mode: listens for a frame whose `command` field echoes the
* request; rejects after `timeoutMs`.
*
* In HTTP mode: `main()` replaces this with {@link sendHttp} so every
* command test works transparently over either transport.
*/
let send = function wsSend(
cmd: { command: string; [k: string]: unknown },
timeoutMs = 15000,
): Promise<any> {
return new Promise((resolve, reject) => {
const handler = (raw: WebSocket.RawData) => {
let data: any;
try { data = JSON.parse(raw.toString()); } catch { return; }
if (data.command === cmd.command) {
ws.off("message", handler);
resolve(data);
}
};
ws.on("message", handler);
ws.send(JSON.stringify(cmd));
setTimeout(() => {
ws.off("message", handler);
reject(new Error(`timeout waiting for "${cmd.command}" response (${timeoutMs}ms)`));
}, timeoutMs);
});
};
/**
* HTTP fallback for send(): POST / with the command JSON, return parsed response.
* Assigned to `send` by `main()` when WebSocket is unavailable or --http forced.
*/
function sendHttp(
cmd: { command: string; [k: string]: unknown },
_timeoutMs?: number,
): Promise<any> {
return fetch(`${httpBase}/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cmd),
}).then(r => r.json());
}
/**
* collectEvents(ms) — Passively listen for broadcast events for `ms` milliseconds.
*
* Returns an array of all event objects received. These are server-pushed
* messages with an "event" field (not "command").
*/
function collectEvents(ms: number): Promise<any[]> {
return new Promise((resolve) => {
const events: any[] = [];
const handler = (raw: WebSocket.RawData) => {
const data = JSON.parse(raw.toString());
if (data.event) events.push(data);
};
ws.on("message", handler);
setTimeout(() => { ws.off("message", handler); resolve(events); }, ms);
});
}
/** Pretty-format a value for display in test output. */
function fmt(v: unknown): string {
if (v === null || v === undefined) return `${DIM}null${RESET}`;
if (typeof v === "number") return v % 1 === 0 ? String(v) : v.toFixed(3);
if (typeof v === "string") return `"${v}"`;
if (Array.isArray(v)) return `[${v.length} items]`;
if (typeof v === "object") return `{${Object.keys(v!).length} keys}`;
return String(v);
}
// ═══════════════════════════════════════════════════════════════════════════════
// PORT DISCOVERY
// ═══════════════════════════════════════════════════════════════════════════════
//
// The NeuroSkill™ app's WebSocket port is dynamic. We try three strategies:
//
// 1. bonjour-service (cross-platform mDNS) — The app registers "_skill._tcp"
// on the local network. We browse for it and resolve the port.
//
// 2. lsof fallback (Unix) — Find processes named "skill", list their TCP
// LISTEN sockets, and probe each with a WebSocket handshake.
//
// 3. Manual — User passes the port as argv[2].
//
// ═══════════════════════════════════════════════════════════════════════════════
async function discover(): Promise<number> {
if (PORT) return PORT;
// Retry discovery indefinitely until Ctrl-C.
// Each attempt tries mDNS (5s timeout) then lsof fallback.
let attempt = 0;
while (true) {
attempt++;
if (attempt === 1) {
info("discovering Skill port (retries until Ctrl-C)…");
} else {
info(`discovery attempt #${attempt} — retrying in 3s…`);
await new Promise(r => setTimeout(r, 3000));
}
// Strategy 1: bonjour-service mDNS discovery
const port = await new Promise<number | null>((resolve) => {
const instance = new Bonjour();
const timeout = setTimeout(() => {
browser.stop();
instance.destroy();
resolve(null);
}, 5000);
const browser = instance.find({ type: "skill" }, (service) => {
clearTimeout(timeout);
browser.stop();
const port = service.port;
info(`mDNS found: ${service.name} @ ${service.host}:${port}`);
instance.destroy();
resolve(port);
});
});
if (port) return port;
// Strategy 2: lsof fallback (Unix)
try {
const ps = execSync("pgrep -if 'skill' 2>/dev/null || true", { encoding: "utf8" }).trim();
if (ps) {
const pids = ps.split("\n").map(s => s.trim()).filter(Boolean);
for (const pid of pids) {
try {
const lsof = execSync(`lsof -iTCP -sTCP:LISTEN -nP -p ${pid} 2>/dev/null || true`, { encoding: "utf8" });
for (const m of lsof.matchAll(/:(\d{4,5})\s+\(LISTEN\)/g)) {
if (await testWs(Number(m[1]))) return Number(m[1]);
}
} catch {}
}
}
} catch {}
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// COMMAND TESTS
// ═══════════════════════════════════════════════════════════════════════════════
// ─────────────────────────────────────────────────────────────────────────────
// 1. STATUS
// ─────────────────────────────────────────────────────────────────────────────
//
// Request: { command: "status" }
// Response: { command: "status", ok: true, device: {...}, session: {...},
// embeddings: {...}, labels: {...}, calibration: {...},
// signal_quality: [...], sleep: {...}, scores: {...} }
//
// What the server does:
// Assembles a full snapshot of every subsystem in the app into a single
// response. This is the "god object" — everything a UI needs to render
// the dashboard in one round-trip. No parameters needed.
//
// Subsystems returned:
//
// • device — Muse headband BLE connection state, hardware identifiers
// (serial, MAC, firmware), battery level (EMA-smoothed from telemetry
// packets), raw sensor counts, IMU readings, and auto-reconnect state.
//
// • session — Current recording session timing.
//
// • embeddings — Stats from the ZUNA GPU encoder pipeline.
//
// • labels — Count of user-created text annotations.
//
// • calibration — Timestamp of the last completed calibration session.
//
// • signal_quality — Array of 4 floats [0–1] per EEG channel.
//
// • sleep — Rolling 48-hour sleep hypnogram summary.
//
// • scores — Most recent 5-second epoch's full set of derived EEG metrics.
//
// ─────────────────────────────────────────────────────────────────────────────
async function testStatus(): Promise<void> {
heading("status");
info("Request: { command: 'status' }");
info("Returns the full real-time state snapshot: device, session, embeddings, scores, sleep.");
info("No parameters — this is a zero-argument introspection command.");
info("The server assembles all subsystem states into a single JSON response.");
try {
const r = await send({ command: "status" });
r.ok ? ok("command succeeded") : fail(`ok=${r.ok}`);
// ── device ──
console.log(` ${CYAN}── device ──${RESET} ${DIM}Muse headband BLE connection state & hardware identifiers${RESET}`);
info("The server maintains a persistent BLE connection to the Muse headband.");
info("'device' reflects the real-time connection state machine and hardware telemetry.");
const d = r.device || {};
field("state", d.state, "connection state: disconnected | scanning | connected | bt_off");
field("connected", d.connected, "true when streaming from a Muse headband");
field("streaming", d.streaming, "true when BLE data stream is active");
field("name", d.name ?? "null", "Bluetooth device name (e.g. 'Muse-XXXX')");
field("id", d.id ?? "null", "platform-specific BLE device identifier");
field("serial_number", d.serial_number ?? "null", "Muse serial number (from telemetry)");
field("mac_address", d.mac_address ?? "null", "Bluetooth MAC address");
field("firmware_version", d.firmware_version ?? "null", "headband firmware version string");
field("hardware_version", d.hardware_version ?? "null", "headband hardware revision");
field("bootloader_version", d.bootloader_version ?? "null", "bootloader version");
field("preset", d.preset ?? "null", "active headset EEG preset (e.g. 'p21')");
field("battery", d.battery, "battery level 0–100 (EMA-smoothed from BLE telemetry)");
field("sample_count", d.sample_count, "total EEG samples received this session (4ch × 256Hz)");
field("ppg_sample_count", d.ppg_sample_count, "total PPG samples this session (64Hz)");
field("ppg", fmt(d.ppg), "latest raw PPG sensor values [ambient, ir, red]");
field("retry_attempt", d.retry_attempt, "auto-reconnect attempt count (0 = first try)");
field("retry_countdown_secs", d.retry_countdown_secs, "seconds until next retry (null if not retrying)");
field("accel", fmt(d.accel), "accelerometer [x,y,z] in g (from Muse IMU)");
field("gyro", fmt(d.gyro), "gyroscope [x,y,z] in °/s (from Muse IMU)");
field("fuel_gauge_mv", d.fuel_gauge_mv, "battery fuel gauge millivolts (Classic firmware)");
field("temperature_raw", d.temperature_raw, "raw temperature sensor (Classic firmware)");
// ── session ──
console.log(` ${CYAN}── session ──${RESET} ${DIM}Current recording session timing${RESET}`);
info("A 'session' begins when the Muse connects and starts streaming EEG.");
info("It ends when the device disconnects. start_utc is null when idle.");
const s = r.session || {};
field("start_utc", s.start_utc, "unix timestamp when current session started (null = no session)");
field("duration_secs", s.duration_secs, "wall-clock seconds since session start");
// ── embeddings ──
console.log(` ${CYAN}── embeddings ──${RESET} ${DIM}ZUNA GPU encoder pipeline stats${RESET}`);
info("Every 5s of clean EEG is passed through a WebGPU (wgpu) neural encoder.");
info("The encoder produces a high-dimensional embedding vector — the 'neural fingerprint'");
info("of that 5-second brain moment. These embeddings are stored in daily SQLite DBs");
info("and used for similarity search, UMAP projection, and metric computation.");
const e = r.embeddings || {};
field("today", e.today, "embedding epochs computed today");
field("total", e.total, "all-time total embeddings across all days");
field("recording_days", e.recording_days, "number of YYYYMMDD dirs with data");
field("encoder_loaded", e.encoder_loaded, "true once the wgpu ZUNA model is warm");
field("overlap_secs", e.overlap_secs, "sliding-window overlap for epochs (0 = non-overlapping)");
// ── labels ──
console.log(` ${CYAN}── labels ──${RESET} ${DIM}User-annotated EEG moments${RESET}`);
info("Labels are free-text annotations stored in labels.sqlite with a UTC timestamp.");
info("They appear in search results and can be attached to UMAP points.");
field("total", r.labels?.total, "total labels stored in labels.sqlite");
// ── calibration ──
console.log(` ${CYAN}── calibration ──${RESET} ${DIM}Timed reference task for model baseline${RESET}`);
info("Calibration is a guided eyes-open / eyes-closed task (~60s each).");
info("It establishes a per-user baseline for alpha power and other metrics.");
field("last_calibration_utc", r.calibration?.last_calibration_utc, "unix timestamp of last completed calibration");
// ── signal_quality ──
console.log(` ${CYAN}── signal_quality ──${RESET} ${DIM}Per-channel electrode contact quality${RESET}`);
info("4-element array for [TP9, AF7, AF8, TP10] — the Muse's 4 EEG channels.");
info("Computed from impedance / noise floor. 1.0 = great, 0.0 = no contact.");
field("channel_quality", fmt(r.signal_quality), "array of 0–1 quality scores per EEG channel");
// ── sleep ──
console.log(` ${CYAN}── sleep ──${RESET} ${DIM}Rolling 48-hour sleep hypnogram summary${RESET}`);
info("The server classifies every embedding in the past 48h into a sleep stage");
info("using band-power heuristics (delta/theta/alpha/beta/sigma ratios).");
info("Returns aggregate epoch counts — NOT a per-epoch hypnogram (use 'sleep' command for that).");
const sl = r.sleep || {};
field("window_hours", sl.window_hours, "lookback window (always 48h)");
field("total_epochs", sl.total_epochs, "number of 5s epochs classified");
field("wake_epochs", sl.wake_epochs, "epochs classified as Wake");
field("n1_epochs", sl.n1_epochs, "epochs classified as N1 (light sleep)");
field("n2_epochs", sl.n2_epochs, "epochs classified as N2 (spindle sleep)");
field("n3_epochs", sl.n3_epochs, "epochs classified as N3 (deep/slow-wave sleep)");
field("rem_epochs", sl.rem_epochs, "epochs classified as REM");
// ── scores ──
const sc = r.scores;
if (sc) {
console.log(` ${CYAN}── scores ──${RESET} ${DIM}Latest 5s epoch: all derived EEG/PPG/IMU metrics${RESET}`);
info("Updated every 5 seconds when streaming. Contains 60+ fields.");
info("These same fields are also broadcast in real-time via 'eeg-bands' events.");
field("epoch_timestamp", sc.epoch_timestamp, "YYYYMMDDHHmmss UTC timestamp of this epoch");
console.log(` ${GRAY} ─ Brain state scores (0–100 scale, higher = more of that state) ─${RESET}`);
field("focus", sc.focus, "frontal beta/theta ratio → attentional engagement");
field("relaxation", sc.relaxation, "posterior alpha dominance → calm wakefulness");
field("engagement", sc.engagement, "beta/(alpha+theta) → cognitive involvement");
field("mood", sc.mood, "composite valence index (FAA + alpha + engagement)");
console.log(` ${GRAY} ─ Composite scores (0–100 scale) ─${RESET}`);
field("meditation", sc.meditation, "alpha + stillness + HRV composite");
field("cognitive_load", sc.cognitive_load, "frontal θ / parietal α workload indicator");
field("drowsiness", sc.drowsiness, "theta-alpha ratio + absolute alpha trend");
console.log(` ${GRAY} ─ Band power ratios (dimensionless, log-scale or raw ratios) ─${RESET}`);
field("faa", sc.faa, "Frontal Alpha Asymmetry: ln(AF8α) − ln(AF7α). +ve = approach, −ve = withdrawal");
field("tar", sc.tar, "Theta/Alpha Ratio — elevated in drowsiness, meditation");
field("bar", sc.bar, "Beta/Alpha Ratio — elevated in stress, attention");
field("dtr", sc.dtr, "Delta/Theta Ratio — deep relaxation indicator");
field("tbr", sc.tbr, "Theta/Beta Ratio — inverse attention marker (high = inattentive)");
console.log(` ${GRAY} ─ Spectral features (frequency-domain analysis) ─${RESET}`);
field("pse", sc.pse, "Power Spectral Entropy [0–1] — spectral complexity/randomness");
field("apf", sc.apf, "Alpha Peak Frequency (Hz) — individual alpha rhythm speed (~9–11 Hz)");
field("bps", sc.bps, "Band-Power Slope (1/f exponent) — neural noise color");
field("snr", sc.snr, "Signal-to-Noise Ratio (dB) — signal quality metric");
field("sef95", sc.sef95, "Spectral Edge Freq 95% (Hz) — freq below which 95% power lies");
field("spectral_centroid", sc.spectral_centroid, "Spectral Centroid (Hz) — 'center of mass' of the spectrum");
field("coherence", sc.coherence, "mean inter-channel alpha coherence [−1,1]");
field("mu_suppression", sc.mu_suppression, "Mu suppression index (current/baseline alpha) — motor imagery");
field("laterality_index", sc.laterality_index, "hemispheric power asymmetry (R−L)/(R+L)");
field("pac_theta_gamma", sc.pac_theta_gamma, "Phase-Amplitude Coupling θ–γ — cross-frequency binding");
console.log(` ${GRAY} ─ Complexity / nonlinear features (time-domain analysis) ─${RESET}`);
field("hjorth_activity", sc.hjorth_activity, "Hjorth Activity — signal variance (total power)");
field("hjorth_mobility", sc.hjorth_mobility, "Hjorth Mobility — mean frequency estimate");
field("hjorth_complexity", sc.hjorth_complexity, "Hjorth Complexity — bandwidth / spectral change");
field("permutation_entropy", sc.permutation_entropy, "Permutation Entropy — ordinal pattern complexity [0–1]");
field("higuchi_fd", sc.higuchi_fd, "Higuchi Fractal Dimension — signal self-similarity (~1.0–2.0)");
field("dfa_exponent", sc.dfa_exponent, "DFA α — long-range correlations (~0.5=white, ~1.5=Brownian)");
field("sample_entropy", sc.sample_entropy, "Sample Entropy — irregularity / unpredictability");
console.log(` ${GRAY} ─ PPG / cardiovascular (from Muse forehead PPG sensor) ─${RESET}`);
field("hr", sc.hr, "Heart Rate (bpm) — pulse from IR PPG");
field("rmssd", sc.rmssd, "RMSSD (ms) — short-term HRV, parasympathetic tone");
field("sdnn", sc.sdnn, "SDNN (ms) — overall HRV, total autonomic variability");
field("pnn50", sc.pnn50, "pNN50 (%) — fraction of adjacent RR intervals differing >50ms");
field("lf_hf_ratio", sc.lf_hf_ratio, "LF/HF Ratio — sympathetic vs parasympathetic balance");
field("respiratory_rate", sc.respiratory_rate, "Respiratory Rate (breaths/min) — from PPG modulation");
field("spo2_estimate", sc.spo2_estimate, "SpO₂ estimate (%) — blood oxygen from red/IR ratio");
field("perfusion_index", sc.perfusion_index, "Perfusion Index (%) — pulsatile/non-pulsatile blood flow");
field("stress_index", sc.stress_index, "Stress Index — Baevsky's SI from RR interval histogram");
console.log(` ${GRAY} ─ Artifact detection (cumulative event counters) ─${RESET}`);
field("blink_count", sc.blink_count, "total eye blinks detected (AF7/AF8 spike pattern)");
field("blink_rate", sc.blink_rate, "blinks per minute (rolling 60s window)");
field("jaw_clench_count", sc.jaw_clench_count, "total jaw clenches detected (TP9/TP10 HF burst)");
field("jaw_clench_rate", sc.jaw_clench_rate, "jaw clenches per minute (rolling 60s window)");
console.log(` ${GRAY} ─ Head pose (IMU-derived, complementary filter on accel+gyro) ─${RESET}`);
field("head_pitch", sc.head_pitch, "pitch angle (°) — positive = looking up");
field("head_roll", sc.head_roll, "roll angle (°) — positive = right ear down");
field("stillness", sc.stillness, "stillness score 0–100 (100 = perfectly still)");
field("nod_count", sc.nod_count, "total head nods detected");
field("shake_count", sc.shake_count, "total head shakes detected");
console.log(` ${GRAY} ─ Relative band powers (fractions, sum ≈ 1.0) ─${RESET}`);
const b = sc.bands || {};
field("bands.rel_delta", b.rel_delta, "δ Delta 1–4 Hz — deep sleep, unconscious processing");
field("bands.rel_theta", b.rel_theta, "θ Theta 4–8 Hz — drowsiness, meditation, memory");
field("bands.rel_alpha", b.rel_alpha, "α Alpha 8–13 Hz — relaxed wakefulness, eyes-closed");
field("bands.rel_beta", b.rel_beta, "β Beta 13–30 Hz — active cognition, focus, anxiety");
field("bands.rel_gamma", b.rel_gamma, "γ Gamma 30–50 Hz — high-level processing, binding");
// Validate completeness
const expected = [
"focus","relaxation","engagement","mood","meditation","cognitive_load","drowsiness",
"faa","tar","bar","dtr","tbr","pse","apf","bps","snr","sef95","spectral_centroid",
"coherence","mu_suppression","laterality_index","pac_theta_gamma",
"hjorth_activity","hjorth_mobility","hjorth_complexity",
"permutation_entropy","higuchi_fd","dfa_exponent","sample_entropy",
"hr","rmssd","sdnn","pnn50","lf_hf_ratio","respiratory_rate",
"spo2_estimate","perfusion_index","stress_index",
"blink_count","blink_rate","jaw_clench_count","jaw_clench_rate",
"head_pitch","head_roll","stillness","nod_count","shake_count",
];
const missing = expected.filter(f => sc[f] === undefined);
if (missing.length === 0) {
ok(`all ${expected.length} score fields present`);
} else {
fail(`missing score fields: ${missing.join(", ")}`);
}
} else {
ok("scores = null (no epoch computed yet — connect a Muse to see data)");
}
// ── history ──
console.log(` ${CYAN}── history ──${RESET} ${DIM}Recording history stats, streak, today vs 7-day average${RESET}`);
info("Computed from the session list: totals, consecutive-day streak, and");
info("comparison of today's metrics against the rolling 7-day average.");
const h = r.history;
if (h && h !== null) {
field("total_sessions", h.total_sessions, "total recording sessions across all days");
field("total_recording_hours", h.total_recording_hours, "cumulative recording time in hours");
field("total_epochs", h.total_epochs, "total 5-second embedding epochs stored");
field("recording_days", h.recording_days, "distinct calendar days with recordings");
field("current_streak_days", h.current_streak_days, "consecutive recording days ending today");
field("longest_session_min", h.longest_session_min, "longest single session in minutes");
field("avg_session_min", h.avg_session_min, "average session duration in minutes");
if (h.today_vs_avg && Object.keys(h.today_vs_avg).length > 0) {
const keys = Object.keys(h.today_vs_avg);
ok(`today_vs_avg has ${keys.length} metrics: ${keys.join(", ")}`);
const sample = h.today_vs_avg[keys[0]];
field(" sample.today", sample.today, "today's value for first metric");
field(" sample.avg_7d", sample.avg_7d, "7-day rolling average");
field(" sample.delta_pct", sample.delta_pct, "percentage change vs average");
field(" sample.direction", sample.direction, "up | down | stable (±5% threshold)");
} else {
ok("today_vs_avg is empty (no data today or this week)");
}
} else {
ok("history = null (no sessions recorded yet)");
}
} catch (e: any) { fail(`status failed: ${e.message}`); }
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. SESSIONS
// ─────────────────────────────────────────────────────────────────────────────
async function testSessions(): Promise<any[]> {
heading("sessions");
info("Request: { command: 'sessions' }");
info("Scans all daily SQLite DBs and reconstructs recording sessions from contiguous epochs.");
info("A gap of >120s between epochs starts a new session.");
info("Returns an array of session objects with day, start_utc, end_utc, n_epochs.");
try {
const r = await send({ command: "sessions" });
r.ok ? ok("command succeeded") : fail(`ok=${r.ok}`);
const list = r.sessions || [];
ok(`${list.length} session(s) found`);
for (const s of list.slice(0, 5)) {
const start = new Date(s.start_utc * 1000).toISOString().slice(0, 16);
const dur = s.end_utc - s.start_utc;
field("session", `${start}`, `${dur}s, ${s.n_epochs} epochs, day=${s.day}`);
}
if (list.length > 5) info(`… and ${list.length - 5} more`);
return list;
} catch (e: any) { fail(`sessions failed: ${e.message}`); return []; }
}
// ─────────────────────────────────────────────────────────────────────────────
// 3. NOTIFY
// ─────────────────────────────────────────────────────────────────────────────
//
// Request: { command: "notify", title: "…", body?: "…" }
// Response: { command: "notify", ok: true }
//
// What the server does:
// Fires a native OS notification via `tauri-plugin-notification`.
// Useful for triggering alerts from scripts or external automation.
// `title` is required; `body` is optional.
//
// ─────────────────────────────────────────────────────────────────────────────
async function testNotify(): Promise<void> {
heading("notify");
info("Request: { command: 'notify', title: '…', body?: '…' }");
info("Triggers a native OS notification from an external process.");
// ── title + body ──
try {
const r = await send({ command: "notify", title: "Skill test", body: "smoke-test notification" });
r.ok ? ok("notify with title+body succeeded") : fail(`ok=${r.ok}, error=${r.error}`);
} catch (e: any) { fail(`notify title+body failed: ${e.message}`); }
// ── title only ──
try {
const r = await send({ command: "notify", title: "Skill test (title only)" });
r.ok ? ok("notify with title only succeeded") : fail(`ok=${r.ok}, error=${r.error}`);
} catch (e: any) { fail(`notify title-only failed: ${e.message}`); }
// ── missing title → error ──
try {
const r = await send({ command: "notify" });
r.ok === false
? ok(`correctly rejected missing title: error="${r.error}"`)
: fail("expected ok=false for missing title");
} catch (e: any) { fail(`missing-title test failed: ${e.message}`); }
}
// ─────────────────────────────────────────────────────────────────────────────
// 4. SAY
// ─────────────────────────────────────────────────────────────────────────────
//
// Request: { command: "say", text: "Hello world" }
// Response: { command: "say", ok: true, spoken: "Hello world" }
//
// What the server does:
// Enqueues the utterance on the dedicated skill-tts OS thread and returns
// immediately — the response arrives before audio playback begins. The TTS
// engine (kittentts-rs, ONNX + espeak-ng phonemisation) synthesises and plays
// the audio in the background. On first call the model is downloaded from
// HuggingFace Hub and cached; subsequent calls use the local cache.
//
// Notes:
// • "fire-and-forget" from the API perspective: ok=true means the request
// was accepted, NOT that audio has finished playing.
// • Missing `text` field → ok=false with a descriptive error.
// • Empty `text` string → ok=false (backend validates non-empty).
// • The `spoken` field echoes the accepted text back to the caller.
// • Available via WebSocket, HTTP POST /say, and the universal POST / tunnel.
//
// ─────────────────────────────────────────────────────────────────────────────
async function testSay(): Promise<void> {
heading("say (TTS)");
info("Request: { command: 'say', text: '...' }");
info("Speaks text via the on-device kittentts engine (ONNX + espeak-ng).");
info("Returns immediately — audio plays in the background on the skill-tts thread.");
// ── basic utterance ───────────────────────────────────────────────────────
try {
info("Testing basic utterance…");
const r = await send({ command: "say", text: "Skill smoke test. TTS working." });
r.ok ? ok("say command accepted") : fail(`ok=${r.ok}, error=${r.error}`);
field("spoken", `"${r.spoken}"`, "echoed text confirmed by server");
if (r.spoken === "NeuroSkill™ smoke test. TTS working.") {
ok("spoken field echoes the input text correctly");
} else {
fail(`spoken mismatch: expected "Skill smoke test. TTS working.", got "${r.spoken}"`);
}
} catch (e: any) { fail(`say basic failed: ${e.message}`); }
// ── calibration-style phrases ─────────────────────────────────────────────
try {
info("Testing calibration-style phrases…");
for (const phrase of [
"Eyes open.",
"Eyes closed.",
"Break. Next: Eyes open.",
"Calibration complete. Three loops recorded.",
]) {
const r = await send({ command: "say", text: phrase });
r.ok
? ok(`accepted: "${phrase}"`)
: fail(`rejected "${phrase}": ${r.error}`);
}
} catch (e: any) { fail(`say phrases failed: ${e.message}`); }
// ── missing text field → error ────────────────────────────────────────────
try {
info("Testing missing 'text' field (should return ok=false)…");
const r = await send({ command: "say" });
r.ok === false
? ok(`correctly rejected missing text: error="${r.error}"`)
: fail("expected ok=false for missing text field");
} catch (e: any) { fail(`missing-text test failed: ${e.message}`); }
// ── empty string → error ──────────────────────────────────────────────────
try {
info("Testing empty text string (should return ok=false)…");
const r = await send({ command: "say", text: "" });
r.ok === false
? ok(`correctly rejected empty text: error="${r.error}"`)
: fail("expected ok=false for empty text string");
} catch (e: any) { fail(`empty-text test failed: ${e.message}`); }
// ── optional voice field ──────────────────────────────────────────────────
try {
info("Testing optional voice field…");
const r = await send({ command: "say", text: "Voice check.", voice: "Jasper" });
r.ok ? ok("say with voice accepted") : fail(`ok=${r.ok}, error=${r.error}`);
r.voice === "Jasper"
? ok(`voice echoed correctly: "${r.voice}"`)
: fail(`expected voice="Jasper", got "${r.voice}"`);
} catch (e: any) { fail(`say with voice failed: ${e.message}`); }
// ── voice omitted → no voice field in response ────────────────────────────
try {
info("Testing omitted voice → response must not contain 'voice' key…");
const r = await send({ command: "say", text: "Default voice." });
r.ok ? ok("say without voice accepted") : fail(`ok=${r.ok}, error=${r.error}`);
!("voice" in r)
? ok("no 'voice' key in response when voice omitted")
: ok(`server returned voice="${r.voice}" (active default — also valid)`);
} catch (e: any) { fail(`say default voice test failed: ${e.message}`); }
// ── empty voice string treated as omitted ─────────────────────────────────
try {
info("Testing empty voice string (treated as default)…");
const r = await send({ command: "say", text: "Empty voice.", voice: "" });
r.ok
? ok("empty voice string treated as default (ok=true)")
: fail(`ok=${r.ok}, error=${r.error}`);
} catch (e: any) { fail(`say empty voice test failed: ${e.message}`); }
// ── response shape ────────────────────────────────────────────────────────
try {
info("Verifying response shape (with voice)…");
const r = await send({ command: "say", text: "Shape check.", voice: "Jasper" });
if (r.ok !== true) { fail(`ok not true: ${r.ok}`); return; }
if (r.command !== "say") { fail(`command not echoed: ${r.command}`); return; }
if (typeof r.spoken !== "string"){ fail(`spoken not a string: ${typeof r.spoken}`); return; }
if (typeof r.voice !== "string"){ fail(`voice not a string: ${typeof r.voice}`); return; }
ok("response shape: { ok: true, command: 'say', spoken: string, voice: string }");
} catch (e: any) { fail(`response shape test failed: ${e.message}`); }
// ── HTTP POST /say ────────────────────────────────────────────────────────
try {
info("Testing HTTP POST /say endpoint…");
const res = await fetch(`${httpBase}/say`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "HTTP TTS check." }),
});
const data = await res.json() as any;
res.status === 200 ? ok("HTTP /say → 200") : fail(`expected 200, got ${res.status}`);
data?.ok === true ? ok("HTTP /say → ok=true") : fail(`ok=${data?.ok}, error=${data?.error}`);
typeof data?.spoken === "string"
? ok(`HTTP /say → spoken="${data.spoken}"`)
: fail("HTTP /say → spoken field missing or not a string");
} catch (e: any) { fail(`HTTP /say test failed: ${e.message}`); }
// ── HTTP POST /say — missing text → 400 ──────────────────────────────────
try {
info("Testing HTTP POST /say with missing text → 400…");
const res = await fetch(`${httpBase}/say`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
res.status === 400 ? ok("HTTP /say (no text) → 400") : fail(`expected 400, got ${res.status}`);
const data = await res.json() as any;
data?.ok === false ? ok("ok=false in error response") : fail(`ok=${data?.ok}`);
} catch (e: any) { fail(`HTTP /say missing-text test failed: ${e.message}`); }
// ── Universal tunnel ──────────────────────────────────────────────────────
try {
info("Testing universal POST / tunnel for say…");
const res = await fetch(`${httpBase}/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: "say", text: "Tunnel check." }),
});
const data = await res.json() as any;
res.status === 200 && data?.ok === true
? ok(`POST / tunnel → ok=true, spoken="${data.spoken}"`)
: fail(`tunnel say failed: status=${res.status} ok=${data?.ok}`);
} catch (e: any) { fail(`tunnel say test failed: ${e.message}`); }
}
// ─────────────────────────────────────────────────────────────────────────────
// 5. LABEL
// ─────────────────────────────────────────────────────────────────────────────
async function testLabel(): Promise<void> {
heading("label");
info("Request: { command: 'label', text: '...' }");
info("Creates a timestamped text annotation on the current EEG moment.");
info("Stored in labels.sqlite. Appears in search results and UMAP visualizations.");
info("Also triggers a 'label-created' broadcast event to all connected clients.");
try {
const text = `test-label-${Date.now()}`;
const r = await send({ command: "label", text });
r.ok ? ok(`label created: id=${r.label_id}`) : fail(`ok=${r.ok}, error=${r.error}`);
field("label_id", r.label_id, "auto-incremented label ID in labels.sqlite");
} catch (e: any) { fail(`label failed: ${e.message}`); }
}
async function testHooksStatus(): Promise<void> {
heading("hooks_status");
try {
const r = await send({ command: "hooks_status" });
if (r.ok === true) ok("hooks_status returns ok=true");
else fail(`hooks_status failed: ${r.error ?? "unknown"}`);
if (Array.isArray(r.hooks)) ok(`hooks array present (${r.hooks.length})`);
else fail("hooks field is not an array");
const first = Array.isArray(r.hooks) && r.hooks.length > 0 ? r.hooks[0] : null;
if (first?.hook) {
if (typeof first.hook.scenario === "string") ok(`hook.scenario present (${first.hook.scenario})`);
else fail("hook.scenario missing or not a string");
} else {
info("no hooks configured; scenario field check skipped");
}
} catch (e: any) {
fail(`hooks_status request failed: ${e.message}`);
}
}
async function testHooksSuggest(): Promise<void> {
heading("hooks_suggest");
try {
const r = await send({ command: "hooks_suggest", keywords: ["focus"] });
if (r.ok === true) ok("hooks_suggest returns ok=true");
else fail(`hooks_suggest failed: ${r.error ?? "unknown"}`);
if (r.suggestion && typeof r.suggestion === "object") ok("suggestion payload present");
else fail("missing suggestion object");
} catch (e: any) {
fail(`hooks_suggest request failed: ${e.message}`);
}
}
async function testHooksGetSet(): Promise<void> {
heading("hooks_get / hooks_set — full CRUD");
info("Tests the hooks_get and hooks_set WS commands used by the CLI for full hook management.");
info("All mutations are wrapped in save/restore of the original hook list to avoid side-effects.");
let original: any[] = [];
// ── hooks_get — baseline ──────────────────────────────────────────────────
try {
const r0 = await send({ command: "hooks_get" });
if (r0.ok === true) ok("hooks_get returns ok=true");
else fail(`hooks_get failed: ${r0.error ?? "unknown"}`);
if (Array.isArray(r0.hooks)) ok(`hooks array present (${r0.hooks.length})`);
else fail("hooks field is not an array");
original = Array.isArray(r0.hooks) ? r0.hooks : [];
} catch (e: any) {
fail(`hooks_get baseline failed: ${e.message}`);
return; // can't continue without baseline
}
// ── hooks_get — response shape validation ─────────────────────────────────
try {
info("Validating hooks_get response shape…");
const r = await send({ command: "hooks_get" });
if (r.command === "hooks_get") ok("command field echoed: 'hooks_get'");
else fail(`command not echoed: "${r.command}"`);
for (const h of (r.hooks ?? [])) {
const required = ["name", "enabled", "keywords", "scenario", "command", "text", "distance_threshold", "recent_limit"];
const missing = required.filter(f => h[f] === undefined);
if (missing.length > 0) {
fail(`hook "${h.name}" missing fields: ${missing.join(", ")}`);
}
}
if ((r.hooks ?? []).length > 0) ok("all hooks have required fields");
else info("no hooks to validate shape on");
} catch (e: any) { fail(`hooks_get shape validation failed: ${e.message}`); }
// ── hooks_set — add a new hook ────────────────────────────────────────────
try {
info("Testing hooks_set: add a new hook…");
const testHook = {
name: "__test_hook_A__",
enabled: true,
keywords: ["focus", "deep work"],
scenario: "cognitive",
command: "test_cmd_a",
text: "test text A",
distance_threshold: 0.15,
recent_limit: 12,
};
const r = await send({ command: "hooks_set", hooks: [...original, testHook] });
if (r.ok === true) ok("hooks_set (add) returns ok=true");
else fail(`hooks_set (add) failed: ${r.error ?? "unknown"}`);
// hooks_set returns the saved hooks
if (Array.isArray(r.hooks)) {
const found = r.hooks.find((h: any) => h.name === "__test_hook_A__");
if (found) {
ok("new hook present in hooks_set response");
found.scenario === "cognitive"
? ok("scenario preserved: cognitive")
: fail(`scenario mismatch: ${found.scenario}`);
found.distance_threshold === 0.15
? ok("distance_threshold preserved: 0.15")
: fail(`threshold mismatch: ${found.distance_threshold}`);
Array.isArray(found.keywords) && found.keywords.length === 2
? ok("keywords preserved: 2 items")
: fail(`keywords mismatch: ${JSON.stringify(found.keywords)}`);
} else {
fail("new hook not found in hooks_set response");
}
} else {
fail("hooks_set response missing hooks array");
}
// Verify via hooks_get
const r2 = await send({ command: "hooks_get" });
const found = Array.isArray(r2.hooks) && r2.hooks.some((h: any) => h.name === "__test_hook_A__");
found ? ok("new hook confirmed via hooks_get") : fail("new hook not found via hooks_get");
} catch (e: any) { fail(`hooks_set add failed: ${e.message}`); }
// ── hooks_set — add a second hook ─────────────────────────────────────────
try {
info("Testing hooks_set: add a second hook…");
const r0 = await send({ command: "hooks_get" });
const current = r0.hooks ?? [];
const testHookB = {
name: "__test_hook_B__",
enabled: false,
keywords: ["stress", "anxiety"],
scenario: "emotional",
command: "test_cmd_b",
text: "test text B",
distance_threshold: 0.18,
recent_limit: 14,
};
const r = await send({ command: "hooks_set", hooks: [...current, testHookB] });
r.ok === true ? ok("hooks_set (add B) ok") : fail(`hooks_set (add B) failed: ${r.error}`);
const r2 = await send({ command: "hooks_get" });
const countA = (r2.hooks ?? []).filter((h: any) => h.name === "__test_hook_A__").length;
const countB = (r2.hooks ?? []).filter((h: any) => h.name === "__test_hook_B__").length;