Compare commits

...
Author SHA1 Message Date
github-actions[bot] b22c37b998 chore: update vendor submodules to latest upstream 2026-10-02 18:25:16 +00:00
rUv 03a897938f Merge pull request #2062 from ruvnet/claude/ruview-npm-package-9uc5x3
feat(npm): RuView npm toolkit @ruvnet/ruview 0.9.1 — kernel, SDK, flashing, device access, MCP Apps, live Claude Code views (ADR-368..378)
2026-10-01 20:53:26 -04:00
ruv 15d48d4481 style(homecore-hap): rustfmt the new verifier test
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 19:55:28 -04:00
ruv c764c8916b fix(homecore-hap): pad the SRP verifier to 384 bytes (flaky provisioning)
compute_verifier returns the minimal big-endian encoding, so about one
provisioning in 256 produced a verifier with a zero top byte, encoded to
383 bytes, and failed the store's own 384-byte check
("SRP verifier must contain exactly 384 bytes"). Seen in CI on
first_provisioning_returns_code_but_restart_does_not. Left-pad to the
3072-bit group width at creation: the same number, the fixed length.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 19:55:05 -04:00
rUv 5efc7e2c2c Merge pull request #2073 from ruvnet/feat/ruview-mcp-apps-ui
feat(npm): live sensor views in Claude Code, MCP Apps console, ESPHome radar, ruview umbrella — @ruvnet/ruview 0.9.1 (ADR-375..378)
2026-10-01 19:23:17 -04:00
ruv 39b4ae9ae1 release: @ruvnet/ruview 0.9.1 and ruview umbrella 0.9.1
Bump the harness and the umbrella (which pins it), point README and the
user guide at 0.9.1, sync the stale lockfile header (0.5.1, old bin name)
with package.json, and add a plain-language changelog entry for the
live sensing views in Claude Code (ADR-377, ADR-378).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:57:49 -04:00
ruv 4a472926e1 fix(ruview-live): code-review findings on the waterfall (ADR-378)
- Stale frames shown as live: spectrumWith now keeps only nodes in the
  latest capture, so a node that stops streaming (or a failed capture)
  leaves the waterfall and the "no CSI frames" card returns.
- Multi-antenna ESP32 nodes never reached the waterfall: with --spectrum
  the parser adds chain-0 amplitudes (ADR-018 antenna-major) in a separate
  field the SpectrumCollector reads; the kernel path is unchanged.
- Frames from different CSI layouts could be joined: spectrum results
  carry their shape, and frames merge only when shape and bins match.
- Animation ran on assumed time: frames read the real clock (the one the
  full render uses), so pulses keep the reported rates and the replay
  keeps up when a frame fires late; replay lag is pruned with the nodes.

Tests: regression tests for each; npm test 193 pass, test:security 91
pass, engine tests 4 pass, validate passes.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:47:18 -04:00
ruv c4f9d95e11 fix(ruview-live): open as a dialog that takes the keys, sized per view
Hotkeys reach a pane only while it holds the keyboard, and `focus` alone
was not taking it. The engine's documented dialog form does: open with
focus + closeOnEscape + holdToasts (Escape closes, as originally). Each
open also requests `rows` for its view (overview 16, waterfall 30,
radar 26) so the inline pane opens tall enough for the bordered design
instead of a third of the screen; switching views re-requests the size,
and picture heights leave room for the chrome (rows - 15).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:35:19 -04:00
ruv bf21c8c7a9 Revert "fix(ruview-live): fit the inline pane; actions on top; show focus state"
The compact inline layout read as broken next to the bordered cards the
user prefers; restore the previous design (78a147405). This reverts
commit 629e1b9c6.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:33:28 -04:00
ruv 629e1b9c6c fix(ruview-live): fit the inline pane; actions on top; show focus state
Outside fullscreen the pane sits inline above the prompt with a few rows,
so the bordered cards, rule and footer were cut off (Refresh/Close never
visible). Inline (or under 18 rows) the pane is now compact: views and
actions share the top row, no borders/rule/gaps, pictures sized to the
rows left, radar readings beside a small fan. The top row always says
whether the pane holds the keyboard ("keys active · Esc returns them" or
"click the pane or press ctrl+x tab to use the keys").

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:32:12 -04:00
ruv 78a1474057 fix(ruview-live): pane hotkeys need the keyboard — ask for focus, say how
Button hotkeys reach a Pane only while it holds the keyboard; the pane
opened without it, so 1/2/3/r/c went to the prompt. Open (and
/ruview waterfall|radar) now request `focus: true`; closeOnEscape is
dropped so Escape hands the keys back without closing; an unfocused pane
shows "click the pane or press ctrl+x tab to use the keys".

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:27:52 -04:00
ruv 751b7ae17b feat(ruview-live): showcase views — CSI waterfall, radar fan, animated cells (ADR-378)
The /ruview pane gains three views (keys 1/2/3) drawn in terminal Rasters
and animated in place with $.ui.blit (~12 fps, no redraws):

- CSI waterfall: per-subcarrier amplitude over time from the new
  `esp32 --spectrum` capture, two frames per row via the half block,
  5-95th percentile colour limits, replayed at the frames' arrival rate,
  labelled MEASURED or SYNTHETIC from the packets' own flag; n cycles nodes.
- Radar: a 120-degree range fan with rings, a fading distance trail and a
  sonar ping to the measured range; braille heart/breathing/distance charts
  scaled to the data; a heart and breathing gauge that pulse at the
  device-reported rates ("a metronome, not a waveform").
- A shimmer rule under the title; surfaces without Raster get a note.

Harness: captureEsp32 / ruview_esp32_capture take spectrum,
spectrum_bins (8-128) and spectrum_frames (8-256): a bounded ring of
binned amplitude frames per node, dominant shape, rate and synthetic flag.

The mod is split into model/raster/anim/views/register (own-file imports
only). Tests: 11 showcase + 11 mod Node tests, a --spectrum loopback test,
4 engine tests (waterfall tab sends --spectrum, draws the Raster, blits);
npm test 189 pass, test:security 90 pass, manifest verified, validate passes.

Evidence: MEASURED on an ESP32-C6 (COM11, release c6-adr110 v0.7.0, node 6)
streaming 7-18 Hz CSI to this host; a 1x256 capture renders the occupied
band against guard/null tones. Unpacked budget 384 -> 448 KiB (+~37 KB).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:22:40 -04:00
ruv c4d7ef8086 feat(ruview-live): redesigned pane — LIVE badge, cards, trends, plausibility flags
- Header: RuView + LIVE/STALE badge (fresh within 2 refresh periods),
  "updated <time> (Ns ago)", a 5 s redraw tick keeps the age current.
- Bordered cards for the radar and the CSI nodes, side by side from 100
  columns, stacked below.
- Radar: PRESENCE DETECTED (+ target count) in green; distance, heart and
  breathing with per-refresh sparklines; device-reported vitals outside
  resting ranges (heart 40-180, breathing 4-40 bpm) are flagged in yellow
  instead of shown as fact (the live kit reported breathing 1.0 bpm).
- Nodes: per-node rate sparkline; "no packets" is a short hint in the card
  rather than a long top alert (status no longer counts it as an alert);
  an unconfigured radar says how to configure it.
- Buttons show their hotkeys.

Tests: 11 Node unit tests, 3 engine tests (mounted pane validates on the
terminal surface), validate passes.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 17:50:09 -04:00
ruv bd7c506076 fix(ruview-live): a reloaded pane waited forever for its first capture
A hot reload (or a resumed session) re-runs register with fresh variables
while the engine keeps the pane open, so no timer ran and the pane stayed
on "waiting for the first capture". Drawing the pane now resumes polling
when no timer is running. Engine test mounts the pane with no /ruview in
the module instance: fails without the fix, passes with it.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 17:44:00 -04:00
ruv 8e12606c6a fix(ruview-live): pane showed "updated Invalid Date"
$.clock.now() resolves a promise of epoch milliseconds; refresh passed the
promise into the model. Await it, and have the pane print "—" for any
non-finite timestamp. Seen live in Claude Code 2.1.287 (/ruview pane).
Regression tests fail without the fix (2/2) and pass with it.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 17:40:59 -04:00
ruv 8b857e41cd docs: README and user guide for @ruvnet/ruview 0.9.0 (devices, radar, MCP/ChatGPT, Claude Code mod)
- README MetaHarness section pinned to 0.9.0 with the hardware, MCP-over-HTTP,
  mod, Homecore and kernel commands; plugin section lists the ruview-live mod.
- User guide: new "RuView npm toolkit" section (hardware CLI, alert meanings,
  ESPHome kits, MCP/ChatGPT connection, the /ruview mod, Homecore + kernel),
  TOC entry, 0.5.0 pins updated.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 17:26:40 -04:00
ruv 093e3bbeae feat(ruview): ruview-live Claude Code mod shipped in the npm package (ADR-377)
- mod/: a Claude Code mod (function hooks). /ruview opens a pane beside
  the transcript with CSI nodes (rate, loss, RSSI, shape, SYNTHETIC) and an
  optional ESPHome radar (presence, distance, device-reported vitals),
  alerts (heartbeat-only boards + buffer-starvation hint), Refresh/Close
  buttons and a one-line status; refreshed on $.clock.every.
- Reads only through the harness CLI beside it (fixed read-only argv,
  --json) located via $.plugin.root (mods may import nothing else).
- Packaged in @ruvnet/ruview (mod/.claude-plugin, mod/hooks), listed in the
  repo marketplace as ruview-live@ruview; `ruview mod` prints how to load it.
- @ruvnet/ruview 0.9.0; umbrella ruview 0.9.0 (still unpublished).

Verified: claude plugin validate (mod + marketplace); claude plugin test
2/2 x3 runs; harness 172 tests + 89 security; real Claude Code headless
`/ruview refresh` from an installed tarball and with the live radar kit.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 17:12:53 -04:00
ruv 4128661789 docs(adr-376): record release outcome — components published, unscoped ruview refused by npm (403, similar to iview)
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 16:45:20 -04:00
ruv b6f3abd35e feat(npm): ruview umbrella package + ESPHome radar source (ADR-376, ADR-373 A3)
- harness/ruview-umbrella -> npm `ruview` 0.8.0: one CLI and one MCP server
  for the operator harness, Homecore and the vitals kernel. `ruview homecore
  ...`, `ruview kernel <doctor|info|selftest|parity|synth|analyze|bench>`,
  `ruview capabilities`; everything else delegates to @ruvnet/ruview. MCP
  serves ruview_* + homecore_* tools and the ui:// console (stdio + HTTP).
- @ruvnet/ruview 0.8.0: bin renamed `ruview-harness` (the umbrella owns
  `ruview`; a clean tarball install showed the bin collision). New exports
  (cli, cli-ui, mcp, mcp-http, ui, esphome, kernel); injectable MCP handler;
  kernel as optional peer + setKernelImporter so the kernel resolves from
  the umbrella's tree under linked/pnpm installs.
- mmwave --source esphome: dependency-free read-only ESPHome native API
  client (private hosts only; encrypted/password APIs reported). Live Seeed
  MR60BHA2 kit: presence, distance, device-reported HR/BR (MEASURED).
- monitor detects Ameba CSI buffer starvation; radar views in terminal UI
  and console widget.
- homecore 0.1.0 and @ruvnet/ruview-kernel 0.1.0 gain ./cli exports.

Gates: harness 166 + 89 security, homecore 27 + 23, kernel 21 + 12,
umbrella 4; clean-dir tarball install smoke incl. live radar over HTTP MCP.
Local publish is a one-off exception to ADR-265 D2, recorded in ADR-376.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 16:30:23 -04:00
ruv 45796e4e3e feat(ruview): MCP Apps console widget, HTTP transport, terminal UI (ADR-375)
- ui://ruview/console-v1.html: one self-contained widget for MCP Apps hosts
  and ChatGPT (ui.resourceUri + openai/outputTemplate). Renders node
  streams, device scans and doctor results; Refresh re-runs the tool via
  window.openai.callTool or an MCP Apps tools/call. Empty CSP allowlist, no
  network, textContent only, parent-frame messages only.
- MCP: protocol negotiation (2025-06-18/2025-03-26/2024-11-05),
  structuredContent + compact JSON text (17-33% smaller, MEASURED),
  resources/list|read, tool titles, canonical tool name in result _meta.
  Transport-neutral handleRpc + shared FIFO dispatcher.
- `mcp start --http`: Streamable HTTP for ChatGPT/remote clients. Bearer or
  secret-path token (constant-time), loopback default, Origin allowlist,
  256 KiB cap, no batches, write grants never honoured over HTTP.
- CLI: formatted TTY output (tables, rate bars, alerts first, NO_COLOR),
  JSON for pipes/--json, capture countdown, `esp32 --watch` with per-node
  sparklines, lazy module loading (--help ~20% faster, MEASURED).
- monitor detects Ameba CSI report-buffer starvation ("lack of csi buf"),
  the root cause of the Realtek heartbeat-only stall seen live.
- Size budget 320 -> 384 KiB (package ~339 kB).

Verified: 160 tests + 83 security tests; headless-Chrome e2e of the MCP
Apps flow against live boards (refresh round trip 108 -> 105 packets);
HTTP security probes; clean tarball smoke (stdio + HTTP).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 15:52:26 -04:00
ruv 3cd360f9e2 fix(ruview): CSI loss survives stray and reordered sequence numbers
Live RTL8721Dx capture (node 3, COM10) reported 97% loss: one stray
sequence value (+52,835, then straight back) was counted as 52,834 lost
frames, and every late UDP packet re-counted its gap. The tracker now
treats a single out-of-window value as a stray, needs two agreeing frames
to accept a counter reset, and lets a late packet fill its own gap
(seqReordered / seqStrays / seqResyncs in the summary). Against ground
truth on the live board (unique sequence numbers over the span): 4,783 of
4,783 present, loss 0, 9 strays; the tracker matched both.

ADR-373 Amendment 2 records the live Realtek run: devices -> realtek,
7,404/7,404 packets decoded, monitor at 1500000 baud without a reset, and
12,549 live frames analyzed by the WASM kernel.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 15:20:21 -04:00
Claudeandclaude-flow d31d9daba2 fix(nightly-sota): ignore stdin EPIPE from children that never read input
runProcess() ends the child's stdin with no error handler. A child that
never reads stdin (git check-ignore) can exit and close the pipe before the
write flushes; the unhandled EPIPE then crashes `agent.mjs validate`. This
failed harness/ruview (node 22) on PR #2062 and reproduces 12/12 locally
under CPU load (0/12 after this change). The command's real outcome is
still reported by the 'close' handler; this mirrors the existing handler in
harness/ruview/src/process-runner.js.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015H55jmprfz4oHWXSFbX5rf
2026-10-01 19:07:40 +00:00
ruv b25729acd6 fix(ruview): boot-log capture aborted on non-ASCII firmware logs on Windows
The flash flow's boot-log capture decoded serial lines and wrote text to a
stdout that the scrubbed child env leaves on cp1252. The ESP32-C6 release
firmware logs U+2192, so the capture died mid-boot (bootLog.captured=false);
an earlier abort would have hidden the CSI evidence and reported a good
flash as unvalidated. Write raw bytes instead. Regression test forces
PYTHONIOENCODING=cp1252 through a stand-in serial module (fails without
the fix). ADR-373 Amendment 2 records the real C6 flash evidence.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 15:02:09 -04:00
ruv 8d2be98ed7 feat(ruview): Realtek RAC1 nodes, honest capture, live CSI analysis (ADR-373 Amendment 2)
Findings from a hardware run of this branch against an RTL8721Dx board
(PL2303GC) and an ESP32-C6 (CP210x) on Windows 11:

- esp32 capture decodes Realtek RAC1 (ADR-323, CRC-verified, 8/16-bit
  tones) and RHB1 heartbeats, ADR-110 sync and ADR-081 mesh packets, and
  reports every CSI shape a node sends.
- Capture fails (no_decodable_packets / heartbeat_only) instead of
  returning ok:true + MEASURED when nothing decodes; names
  heartbeat-only senders.
- --analyze runs live single-antenna CSI through @ruvnet/ruview-kernel at
  the measured frame rate with a bounded buffer.
- monitor opens the port with DTR/RTS deasserted (no node reboot), takes
  --baud, and explains the C6/S3 USB-Serial/JTAG console.
- devices classifies Prolific 067B:23A3 as a Realtek board.
- Windows: owner-only ACL on the hosts file (fails closed); symlink test
  skips only where Windows refuses symlinks.
- kernel build names uninitialized submodules when cargo fails.
- Parser ~3.2x faster (MEASURED, 0.42-0.52M -> 1.33-1.67M packets/s).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 14:40:32 -04:00
Claudeandclaude-flow 43f16e5638 perf(npm): binary kernel transport, faster device e2e, and CI fixes (ADR-368/373 amendments)
CI fixes (both failures on PR #2062 were in this branch):
- harness/ruview: remove `2.11.0` literals from training remedies; the
  ADR-265 D3 version-literal gate rejects them.
- ruview-kernel workflow: install wasm32 for the toolchain pinned by
  v2/rust-toolchain (the action only added it to `stable`); build.mjs now
  fails fast with the exact `rustup target add` remedy.

Kernel optimization (ADR-368 amendment):
- New `analyze_flat` / `session_push_flat` ops via `call_f64`: WASM
  `rvk_call_f64` (alignment-agnostic bytes) and napi `callF64(Float64Array)`.
  `analyze()`/`session.push()` pack uniform frames automatically; irregular
  input keeps the JSON path and its per-frame errors. `analyzeFlat()` is the
  zero-copy entry. Shared `Session::step` makes both paths byte-identical on
  identical f64 inputs (Rust test), and the binary path avoids serde_json's
  non-correctly-rounded float parsing.
- MEASURED (`ruview-kernel bench --seconds 120`, one Linux x64 host):
  2.82x (wasm) and 2.45x (napi) faster than the JSON transport.
- Profiled remaining cost (examples/profile_stages.rs): heart-rate ACF in
  wifi-densepose-vitals dominates; left for a separate, output-reviewed
  change. Rejected WASM +simd128 and opt-level=s after three measured runs
  (bench/wasm-variants.mjs).

Device e2e (ADR-373 amendment):
- test/e2e/devices.e2e.mjs + `npm run test:e2e:devices` + new workflow
  ruview-device-e2e.yml: pty-emulated MR60BHA2 and RPLIDAR through real
  pyserial, a UDP ESP32 node, and the real iPhone relay, via the CLI.
- mmWave auto-detect keeps probe frames and reads only the remainder;
  iPhone LiDAR gains max_frames and returns immediately when refused.
  Suite wall time 19.6 s -> 5.6 s (MEASURED, dev container).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015H55jmprfz4oHWXSFbX5rf
2026-10-01 14:45:55 +00:00
Claudeandclaude-flow 6112a185df feat(npm): host device + SSH remote access — ESP32, 60/24 GHz mmWave, LiDAR (ADR-373/374)
@ruvnet/ruview 0.7.0 gives CLI, MCP, and SDK access to RuView sensing
hardware on the machine it is attached to, and to other machines over SSH.

Devices (ADR-373, src/devices/):
- ruview_devices_scan: pyserial port list classified by USB VID:PID
  (Espressif native USB, CP210x, CH34x, FTDI) into ESP32 / mmWave / RPLIDAR
  roles with the command that confirms each.
- ruview_esp32_capture: receive-only UDP listener parsing ADR-018 CSI
  frames, vitals, and the other 0xC511000x packets per node: rate,
  sequence loss, RSSI, subcarriers, frequency, device vitals.
- ruview_mmwave_read: MR60BHA2 (60 GHz) and LD2410 (24 GHz) parsers ported
  byte-for-byte from firmware mmwave_sensor.c; baud/frame auto-detect.
- ruview_lidar_read: RPLIDAR SCAN over serial (start/stop, DTR best-effort)
  and the iPhone LiDAR relay as a WebSocket client (token only via
  RUVIEW_LIDAR_TOKEN; statistics only, never raw depth).
- Dependency-free serial via a fixed pyserial pump (argv-only, bounded,
  hex output immune to secret redaction). Schema-level port pattern
  validation added to the policy validator.
- All device tools require the new `device-access` MCP grant.

Remote hosts (ADR-374, src/remote.js):
- ~/.config/ruview/hosts.json (0600), CLI-only `hosts add`; strict
  name/target validation against ssh option and shell injection.
- `ssh -o BatchMode=yes -o StrictHostKeyChecking=yes -- <target>` running a
  version-pinned `ruview call <tool> --read-only`, every token single-quoted;
  read-only enforced locally and remotely; no mutations, credentialed reads,
  or host hopping. MCP needs `remote-host` plus the remote tool's grant.

Also: device-access skill, doctor `devices`/`remote` groups, SDK
`devices.*`/`hosts.*` with types, generic `ruview call` verb, guidance
capability, 320 KiB package budget, CI smoke expects 22 tools.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015H55jmprfz4oHWXSFbX5rf
2026-09-30 17:01:15 +00:00
Claudeandclaude-flow 888222277e feat(npm): RuView operator surface — SDK, cross-platform flashing, training, doctor (ADR-369..372)
@ruvnet/ruview 0.6.0 turns the harness into a complete npm operator
surface: one policy-checked tool registry behind the CLI, the MCP server,
and a new typed SDK.

- SDK (ADR-369): `@ruvnet/ruview/sdk` createRuView() with doctor,
  firmware.{ports,plan,flash,monitor}, training.{plan,run,gate,calibrate},
  kernel.{selfTest,load}, guidance/claimCheck/verify/spaces; strict mode
  throws RuViewError; TypeScript declarations in src/sdk.d.ts.
- Firmware (ADR-370): cross-platform `python -m esptool` flashing replaces
  the Windows-only stub. SHA256SUMS verification (mismatch always fatal),
  bundle containment, variant/partition checks, argv-only command, chip_id
  preflight that refuses a mismatched chip, boot-log capture;
  hardwareValidated only when CSI callbacks appear without a panic.
  Read-only ruview_firmware_plan / ruview_firmware_ports.
- Training (ADR-371): ruview_train / ruview_train_plan run pose-smoke,
  pose (MM-Fi), or room training from the trusted checkout with in-repo
  paths only, logs/checkpoints under v2/target/ruview-train, and libtorch
  remedies. ruview_train_gate enforces mean-pose baseline, leakage-free
  splits, subject/time disjointness, and evidence tagging.
- Doctor (ADR-372): structured pass/warn/fail/skip checks with remedies
  across runtime, integrity, hosts, repo, Rust, Python/esptool, firmware
  bundles, serial, sensing server, and the compute kernel; --json/--group;
  hardware probe and network checks are CLI/SDK-only.
- Plan tools cannot take `confirm`; acting tools keep grant+confirm over
  MCP. Policy files and TOOL_POLICY are drift-tested.
- Unpacked budget 160 KiB → 224 KiB in both npm workflows; CI tarball smoke
  now checks the SDK and the training gate.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015H55jmprfz4oHWXSFbX5rf
2026-09-30 16:47:30 +00:00
Claudeandclaude-flow 1938f0251f feat(npm): @ruvnet/ruview-kernel — WASM + napi-rs compute kernel with metaharness e2e (ADR-368)
Adds a portable RuView compute kernel for JavaScript, built from the Rust
source of truth and wired into the @ruvnet/ruview metaharness.

Rust
- v2/crates/ruview-kernel: bounded single-function JSON ABI
  (call(op, json) -> envelope) over the ADR-021 wifi-densepose-vitals
  pipeline: streaming sessions, batch analyze, deterministic SplitMix64
  synthetic CSI (SYNTHETIC), bounded session registry, strict input
  validation (deny_unknown_fields, width/finite/magnitude checks, size and
  count limits). wasm32-only zero-import C ABI (rvk_alloc/free/call).
- v2/crates/ruview-kernel-napi: napi-rs transport exposing only
  abiVersion()/call(); standalone workspace (excluded from v2) so napi never
  enters `cargo test --workspace`; panic=unwind + catch_unwind.

npm (harness/ruview-kernel, zero runtime deps)
- loadKernel({backend: wasm|napi|auto}); WASM default (no host authority),
  napi never silently falls back, auto reports fallbackReason.
- SHA256SUMS integrity verification; mismatch is always fatal; loader
  rejects any WASM module that imports host functions.
- Browser-safe ./wasm entry; CLI doctor/info/selftest/parity/synth/
  analyze/bench; build.mjs builds artifacts from source (no shell).
- Unit, security, and e2e tests (CLI round trip, cross-backend parity).

Metaharness
- New read-only tool ruview_kernel_selftest / `ruview kernel`: imports the
  fixed optional package, fails closed with kernel_not_installed; policy,
  claims, MCP policy, guidance capability, and manifest updated.

CI: .github/workflows/ruview-kernel-npm.yml builds both transports on
Linux/macOS/Windows, requires napi in tests, runs parity, verifies
artifacts, packs, and runs the cross-package e2e. No publish step.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015H55jmprfz4oHWXSFbX5rf
2026-09-30 16:23:29 +00:00
rUv b90b592d4f fix(vitals): reject respiratory peaks and cap filtered confidence (#2061) 2026-09-30 10:20:41 -04:00
rUv e1fbbede61 fix(sensing): exclude stale nodes from room occupancy (#2060) 2026-09-30 09:25:20 -04:00
rUv aac4155584 feat(bfi): add validated capture and bounded research tooling (#2055)
* feat(bfi): add validated capture and bounded research tooling

* fix(deps): patch Wasmtime for RUSTSEC-2026-0316
2026-09-29 23:53:48 -04:00
Dragan Spiridonov 5ef001b4fe Merge pull request #1904 from ruvnet/codex/firmware-delivery-20260911
fix(firmware): restore native C6 onboarding delivery
2026-09-27 11:43:55 +02:00
rUv 6524712900 Merge pull request #1890: storage partition for 8MB, compile node_log out on 4MB
Verified on a physical ESP32-C6 (4 MB, esp32c6 defaults, ESP-IDF v5.4):
- App image: 1,085,424 B on main, 1,042,368 B with this change (43,056 B smaller).
- The 'Failed to find FATFS partition storage' error seen on every 4 MB C6 boot is gone.
- Wi-Fi join, CSI init and CSI UDP delivery to the host were confirmed.
CI: 35/35 green.
2026-09-26 12:36:57 -05:00
rUv dd02efe2fe Merge pull request #1903 from ruvnet/codex/calibration-process-identity
fix(calibration): bind server receipts to room identity
2026-09-16 16:30:28 +00:00
Dragan Spiridonov 0c1efda425 chore(firmware): pin exact IDF 5.4 C6 artifact 2026-09-16 09:58:16 +02:00
Dragan Spiridonov b1be91478c fix(firmware): restore native C6 onboarding delivery 2026-09-16 09:58:16 +02:00
Joe 6eb79cad26 chore: empty commit to re-trigger stale CI (row 35 sweep, main@45fc41ac) 2026-09-15 22:28:35 -04:00
Joeandclaude-flow 36e4df95b7 feat(firmware): give node_log an 8MB home, compile it out on 4MB
node_log currently only has a FAT volume on the 16MB table
(partitions_16mb.csv), so it silently does nothing on the two chips
most of the fleet's OTA/CI surface actually exercises.

partitions_display.csv (8MB) ends at 0x600000 with 2.00 MB of flash
never allocated. Add a storage/fat partition filling exactly that
tail (0x600000 + 0x200000 = 0x800000, the full 8 MB with nothing left
over), so node_log has somewhere to mount on the s3/8mb profile.

partitions_4mb.csv has no equivalent room -- two 1.81 MB app slots
leave only 256 KB free, not enough for a FAT volume of any useful
size. Guard node_log.c's whole implementation on
CONFIG_ESPTOOLPY_FLASHSIZE_4MB and provide no-op stubs for every
entry point, so main.c's call sites need no #ifdef of their own.
Deliberately did not touch main/CMakeLists.txt: fatfs/wear_levelling
stay in REQUIRES, and with nothing left calling into them on 4MB,
--gc-sections drops both archives the same way it already drops
libespcoredump.a when coredump-to-flash is unused (measured at 0
bytes contribution).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ
2026-09-10 13:21:59 -04:00
202 changed files with 27017 additions and 678 deletions

No files matched your search

+5
View File
@@ -10,6 +10,11 @@
"name": "ruview",
"source": "./plugins/ruview",
"description": "End-to-end RuView toolkit: getting started, ESP32 hardware setup, configuration, sensing applications (presence / vitals / pose / sleep / MAT), camera-free + camera-supervised model training, advanced multistatic sensing, CLI / API / WASM, mmWave radar, and witness verification"
},
{
"name": "ruview-live",
"source": "./harness/ruview/mod",
"description": "Claude Code mod: /ruview opens a live pane with your CSI nodes (ESP32, Realtek) and ESPHome radar (presence, distance, device-reported vitals), refreshed on a timer, plus a one-line status. Read-only; ships inside @ruvnet/ruview. Mods are early access (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1)."
}
]
}
+4
View File
@@ -135,6 +135,10 @@ async function runProcess(command, args, {
resolve(result);
});
});
// A child that never reads stdin (e.g. `git check-ignore`) may exit and
// close the pipe before this write flushes; the resulting EPIPE is not a
// failure of the command, whose real outcome the 'close' handler reports.
child.stdin.on('error', () => {});
child.stdin.end(input);
});
}
+43
View File
@@ -0,0 +1,43 @@
name: BFI Research Tools
on:
pull_request:
paths:
- 'tools/bfi/**'
- 'tools/bfi_learning/**'
- '.github/workflows/bfi-research.yml'
push:
branches: [main]
paths:
- 'tools/bfi/**'
- 'tools/bfi_learning/**'
- '.github/workflows/bfi-research.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: bfi-research-${{ github.ref }}
cancel-in-progress: true
jobs:
offline-tests:
name: BFI offline tests (Linux, CPU)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: '3.13'
- name: Install numeric test dependencies
run: python -m pip install numpy==2.4.2 scipy==1.17.1
- name: Install CPU PyTorch
run: python -m pip install torch==2.11.0 --index-url https://download.pytorch.org/whl/cpu
- name: Test capture parsing and bounded runners
run: python -B -m unittest discover -s tools/bfi -v
- name: Test offline learning and data admission
run: python -B -m unittest discover -s tools/bfi_learning -v
+32
View File
@@ -204,3 +204,35 @@ jobs:
idf.py build
test "$(stat -c%s build/esp32-csi-node.bin)" -le $((1152 * 1024))
strings build/esp32-csi-node.bin | grep -F "external U.FL"
publish-release-assets:
name: Publish firmware release assets
if: github.ref_type == 'tag'
needs: [version-guard, build, build-c6-idf6]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download the exact-run firmware artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
pattern: esp32-csi-node-firmware-*
path: release-downloads
- name: Package variants without filename collisions
run: |
mkdir -p release-assets
for directory in release-downloads/esp32-csi-node-firmware-*; do
variant="${directory##*/}"
tar -C "$directory" -czf "release-assets/${variant}.tar.gz" .
done
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS.txt)
- name: Publish the downloadable firmware bundles
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65
with:
name: ESP32 firmware ${{ github.ref_name }}
body: |
Exact-run ESP32-S3 8 MB, ESP32-S3 4 MB, and ESP32-C6 4 MB firmware bundles.
Verify each archive against `SHA256SUMS.txt` before flashing.
files: release-assets/*
+15 -5
View File
@@ -41,8 +41,12 @@ jobs:
build: false
publishable: true
# ADR-283/325: brain + local hosts + replay assets + guarded Spaces OAuth adapter;
# still runtime-dependency-free. 160 KiB is the reviewed hard ceiling.
unpacked_budget: 163840
# still runtime-dependency-free. ADR-369..374 add the SDK, firmware
# flashing, training runner/gate, doctor, device access, and SSH
# hosts. ADR-375 adds the MCP Apps console widget (~12 KB), the
# Streamable HTTP transport and the terminal UI (384 KiB); ADR-378 the
# ruview-live showcase views (~37 KB): 448 KiB reviewed ceiling.
unpacked_budget: 458752
- dir: harness/homecore
build: false
publishable: true
@@ -131,13 +135,19 @@ jobs:
npm i --no-fund --no-audit "$TGZ"
case "${{ matrix.package.dir }}" in
harness/ruview)
./node_modules/.bin/ruview --version
./node_modules/.bin/ruview doctor
./node_modules/.bin/ruview-harness --version
./node_modules/.bin/ruview-harness doctor
# the honesty gate must fail closed on empty input (ADR-263 F1)
if ./node_modules/.bin/ruview claim-check; then
if ./node_modules/.bin/ruview-harness claim-check; then
echo 'claim-check passed with no input — fail-open regression'; exit 1
fi
node --input-type=module -e "const m = await import('@ruvnet/ruview'); if (!m.TOOLS) process.exit(1);"
# ADR-369: the typed SDK resolves and the evidence gate refuses a baseline-free score
node --input-type=module -e "const { createRuView } = await import('@ruvnet/ruview/sdk'); const r = createRuView(); if (r.tools().length < 22) process.exit(1); const g = await r.training.gate({ model_score: 0.93 }); if (g.verdict !== 'FAIL') process.exit(1);"
./node_modules/.bin/ruview-harness doctor --json --group runtime,harness,firmware > doctor.json
if ./node_modules/.bin/ruview-harness train-gate --model-score 0.93 > /dev/null; then
echo 'train-gate passed without a baseline'; exit 1
fi
;;
harness/homecore)
./node_modules/.bin/homecore --version
+68
View File
@@ -0,0 +1,68 @@
# ADR-373/374 — device-access end-to-end through the real @ruvnet/ruview CLI.
# MR60BHA2 (60 GHz) and RPLIDAR are emulated on pseudo-terminals and read via
# real pyserial; an ESP32 node is emulated over UDP; the iPhone LiDAR path
# uses the repository's real relay. All results are SYNTHETIC: this proves
# the transports and parsers, not hardware behaviour.
name: ruview device e2e
on:
push:
branches: [main]
paths:
- 'harness/ruview/src/**'
- 'harness/ruview/bin/**'
- 'harness/ruview/test/e2e/**'
- 'integrations/iphone-lidar/web/**'
- '.github/workflows/ruview-device-e2e.yml'
pull_request:
paths:
- 'harness/ruview/src/**'
- 'harness/ruview/bin/**'
- 'harness/ruview/test/e2e/**'
- 'integrations/iphone-lidar/web/**'
- '.github/workflows/ruview-device-e2e.yml'
permissions:
contents: read
concurrency:
group: ruview-device-e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
devices:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: '3.12'
- name: Install pyserial
run: python -m pip install pyserial==3.5
- name: Install the iPhone LiDAR relay
working-directory: integrations/iphone-lidar/web
run: npm ci --ignore-scripts
- name: Install harness
working-directory: harness/ruview
run: npm ci --ignore-scripts
# Missing prerequisites fail instead of skipping (RUVIEW_E2E_REQUIRE).
# The suite links each emulator pty to /dev/ttyRUVIEWE2E<n> with
# `sudo -n`, because the harness refuses /dev/pts/* port names.
- name: Device e2e (SYNTHETIC devices)
working-directory: harness/ruview
env:
RUVIEW_E2E_REQUIRE: '1'
run: npm run test:e2e:devices
+132
View File
@@ -0,0 +1,132 @@
# ADR-368 — @ruvnet/ruview-kernel: build the WASM and napi-rs transports from
# source on every OS, run the Rust ABI tests, the package unit/security/e2e
# tests with the native addon REQUIRED, the wasm↔napi parity check, artifact
# verification, and the cross-package e2e through the @ruvnet/ruview
# metaharness. No publish step: publication stays CI-only via the release
# workflow and requires explicit maintainer approval (ADR-265).
name: ruview-kernel npm
on:
push:
branches: [main]
paths:
- 'v2/crates/ruview-kernel/**'
- 'v2/crates/ruview-kernel-napi/**'
- 'v2/crates/wifi-densepose-vitals/**'
- 'harness/ruview-kernel/**'
- 'harness/ruview/src/kernel.js'
- 'harness/ruview/test/kernel.test.mjs'
- '.github/workflows/ruview-kernel-npm.yml'
pull_request:
paths:
- 'v2/crates/ruview-kernel/**'
- 'v2/crates/ruview-kernel-napi/**'
- 'v2/crates/wifi-densepose-vitals/**'
- 'harness/ruview-kernel/**'
- 'harness/ruview/src/kernel.js'
- 'harness/ruview/test/kernel.test.mjs'
- '.github/workflows/ruview-kernel-npm.yml'
permissions:
contents: read
concurrency:
group: ruview-kernel-${{ github.ref }}
cancel-in-progress: true
jobs:
kernel:
name: kernel (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The v2 workspace lists submodule crates as members; cargo must be
# able to load every member manifest.
submodules: recursive
- name: Install Rust + wasm32 target
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
targets: wasm32-unknown-unknown
# v2/rust-toolchain pins its own channel; the target must be installed
# for that toolchain, not only for the action's default `stable`.
- name: Add wasm32 target to the pinned v2 toolchain
working-directory: v2
run: rustup target add wasm32-unknown-unknown
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
with:
workspaces: |
v2 -> target
v2/crates/ruview-kernel-napi -> target
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- name: Rust ABI tests
run: cargo test --manifest-path v2/Cargo.toml -p ruview-kernel --locked
- name: Build WASM + napi-rs artifacts from source
working-directory: harness/ruview-kernel
run: npm run build
- name: Verify artifacts (checksums, zero WASM imports, ABI)
working-directory: harness/ruview-kernel
run: node scripts/verify-artifacts.mjs --require-wasm
- name: Package tests (napi required)
working-directory: harness/ruview-kernel
env:
RUVIEW_KERNEL_REQUIRE_NAPI: '1'
run: npm test
- name: Doctor, self-test, and wasm↔napi parity (SYNTHETIC)
working-directory: harness/ruview-kernel
run: |
node bin/cli.js doctor --strict-napi
node bin/cli.js selftest --backend wasm
node bin/cli.js selftest --backend napi
node bin/cli.js parity
- name: Pack
working-directory: harness/ruview-kernel
run: |
npm pack --dry-run
# Relative destination: Windows runner temp paths contain
# backslashes that bash globbing would mangle.
mkdir -p dist-pack
npm pack --pack-destination dist-pack
- name: Cross-package e2e through the @ruvnet/ruview metaharness
working-directory: harness/ruview
run: |
npm ci --ignore-scripts
npm install --no-save --ignore-scripts ../ruview-kernel/dist-pack/ruvnet-ruview-kernel-*.tgz
node --test test/kernel.test.mjs
node bin/cli.js kernel --backend auto
- name: Upload built artifacts
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ruview-kernel-linux-x64
path: |
harness/ruview-kernel/wasm/ruview_kernel.wasm
harness/ruview-kernel/wasm/SHA256SUMS
harness/ruview-kernel/native/*.node
harness/ruview-kernel/native/SHA256SUMS
retention-days: 14
+8 -6
View File
@@ -104,8 +104,10 @@ jobs:
run: |
set -euo pipefail
case "${{ inputs.package }}" in
# ADR-283/325: brain + hosts + replay + guarded Spaces OAuth; no runtime deps.
harness/ruview) export UNPACKED_BUDGET=163840 ;;
# ADR-283/325/369-374: brain + hosts + replay + Spaces OAuth + SDK,
# flashing, training, doctor, device access, SSH hosts; ADR-375 console
# widget, HTTP transport, terminal UI; ADR-378 mod showcase; no runtime deps.
harness/ruview) export UNPACKED_BUDGET=458752 ;;
# ADR-285: CLI + MCP + reviewed brain + WASM-kernel adapter.
harness/homecore) export UNPACKED_BUDGET=180000 ;;
# ADR-264 O2: map-free tarball (was 188 kB with maps).
@@ -140,12 +142,12 @@ jobs:
npm i --no-fund --no-audit "$TGZ"
case "${{ inputs.package }}" in
harness/ruview)
./node_modules/.bin/ruview --version
./node_modules/.bin/ruview doctor
./node_modules/.bin/ruview guidance --topic homecore --query restore --limit 1 \
./node_modules/.bin/ruview-harness --version
./node_modules/.bin/ruview-harness doctor
./node_modules/.bin/ruview-harness guidance --topic homecore --query restore --limit 1 \
| grep -q '"homecore-runtime-restore"'
# the honesty gate must fail closed on empty input (ADR-263 F1)
if ./node_modules/.bin/ruview claim-check; then
if ./node_modules/.bin/ruview-harness claim-check; then
echo 'claim-check passed with no input — fail-open regression'; exit 1
fi
node --input-type=module -e "const m = await import('@ruvnet/ruview'); if (!m.TOOLS) process.exit(1);"
+8
View File
@@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **`@ruvnet/ruview` 0.9.1: live sensing views inside Claude Code (ADR-377, ADR-378).** The package now ships `ruview-live`, a Claude Code mod. Type `/ruview` and a pane opens next to the conversation showing what your sensors see right now, refreshed automatically. It has three views, switched with keys `1` `2` `3` or with `/ruview overview|waterfall|radar`:
- **Overview**: the CSI nodes streaming to your machine (rate, packet loss, signal strength) and an optional 60 GHz radar kit (presence, distance, heart and breathing rates).
- **CSI waterfall**: the Wi-Fi signal itself, the strength of every subcarrier over time, drawn in colour and scrolling as frames arrive. The new `ruview esp32 --spectrum` option supplies the data.
- **Radar**: a top-down range fan with an animated ping out to the measured distance, plus heart, breathing and distance charts.
Every reading is labelled honestly: MEASURED or SYNTHETIC from the packets themselves; radar vitals as "device-reported, not validated"; range-only radar as "distance, not direction". The pane only reads; it never flashes or configures a device.
Verified on real hardware: an ESP32-C6 streaming CSI and a Seeed MR60BHA2 radar kit (MEASURED; reproducer `npx @ruvnet/ruview esp32 --seconds 4 --spectrum --json`). The package's size ceiling rises from 384 KiB to 448 KiB; it still has no runtime dependencies.
- **`wifi-densepose-sar` — coherent wideband RF tomography research crate (ADR-287).** New standalone leaf crate (the `nvsim` pattern; zero coupling to `wifi-densepose-hardware` or any real ingestion path) implementing the synthetic-aperture-radar reconstruction primitive a handheld through-wall RF imaging device would need — motivated by comparison against Applied Electrodynamics' "WaveSight" launch, and explicitly scoped below ADR-278's RISE/DiffRadar/GeRaF reproduction gates. Ships: (1) a stepped-frequency, multi-position complex forward measurement simulator (`y_{m,k} = Σ σ_j/R² · exp(-i·4π·f·R/c) + noise`, deterministic ChaCha20 seeding); (2) delay-and-sum backprojection reconstruction onto a 3D voxel grid, rayon-parallelized over voxels; (3) threshold + local-maximum point-cloud extraction; (4) closed-form range/cross-range resolution and antenna-pose coherence-budget formulas (`ΔR=c/2B`, `δ_CR≈λR/2L`, `Δp≤λ/8`) checked against the reconstruction's *actual* behavior in `tests/physics_validation.rs` rather than merely documented — forward-simulating two targets at controlled separations and proving they resolve or merge exactly where the formulas predict, and that reconstructed focus at a known target degrades as injected antenna-pose error grows. Every number is SYNTHETIC/L0 (ADR-282) — no real wideband RF hardware backs this crate; see the crate README and `docs/tutorials/coherent-rf-tomography-backprojection.md` for the full honesty boundary and a worked walkthrough. `focus_at_point` exploits the evenly-spaced-by-construction frequency sweep (an arithmetic progression in per-term phase) to evaluate each pose's phasor once and advance it by a fixed complex-multiply step per frequency instead of one `sin`/`cos` pair per frequency — **MEASURED ~4.4-4.5x faster** (criterion regression detection, p < 0.001) than the first-shipped direct-computation version, proven equivalent (not just faster) to an independently reimplemented reference across four sweep sizes and on-/off-target points. 25 tests (22 unit + 3 integration), 0 failed, clippy-clean; MEASURED backprojection throughput ~1.7-2.3M voxels/sec (criterion, 21 poses × 32 freq steps).
- **HOMECORE platform runtime completion — secure native/Wasmtime plugins, authenticated HAP IP, expanded Home Assistant APIs, durable restoration/migration, and voice protocols.** `homecore-server` now owns deterministic compiled-in native plugin registration plus explicitly configured, path-bounded, Ed25519 publisher-verified Wasm packages executed through Wasmtime with setup/state-change/teardown lifecycle; arbitrary native dynamic libraries remain intentionally unsupported. The optional HAP server implements persisted accessory identity and controller records, SRP-6a Pair-Setup M1–M6, X25519/Ed25519 Pair-Verify M1–M4, HKDF-SHA512/ChaCha20-Poly1305 record framing, authenticated/admin endpoint gates, replay/tamper closure, live entity synchronization, and paired-state `_hap._tcp` mDNS updates (45 focused tests; external Apple certification is not claimed). Startup restores device/entity registries and deterministic latest recorder states before plugins, and migration now atomically preserves forward-compatible device/config-entry fields. The HA-compatible surface adds events, templates, config checks, components, registries, history/logbook with SQL-enforced global response bounds, calendar/camera provider routes, and modern WebSocket negotiation while retaining a machine-readable limitations matrix for integration-specific behavior. Assist adds bounded PCM16, async STT/TTS contracts, an end-to-end speech pipeline, and an authenticated satellite session protocol; real deployments still provide the speech engines.
- **`ruview-unified` increment 3 — Gaussian update-loop completion, separable delay-Doppler, and property-tested boundary hardening.** (1) `GaussianMap::merge_overlapping` (ADR-275 step 5: mutual-Mahalanobis + semantic-compatibility dedup catching drift the insert-time gate misses) and lifetime-aware decay (`τ_eff = τ·(1+ln(1+lifetime/τ))` — confirmed structures outlive transients at equal nominal τ). (2) `delay_doppler_map` reimplemented separably (`O(B²S+S²B)`), proven equivalent to the direct reference to <1e-10 and **measured 8.3× faster** (520 µs vs 4.34 ms at 56×8). (3) `tests/security_boundaries.rs` — 8 `proptest` properties over the boundary surfaces (arbitrary values incl. NaN/±inf via `f64::from_bits`) that found and fixed three input-controlled defects: a BLE-CS phase-unwrap infinite loop on non-finite phases and an ~1e299-iteration loop on finite-huge phases (now O(1) modular unwrap + plausibility bound), and a subnormal Gaussian scale overflowing `1/σ²` to NaN density (now physical σ/occupancy bounds). (4) New criterion benches for all increment-2 hot paths (`to_canonical` 38 µs, `ble_cs_range` 481 ns, AoI planner 647 ns/200 regions, coherent fusion 1.5 µs/32 members, factorized pose 521 ns). ruview-unified now 98 tests (87 lib + 3 acceptance + 8 security), 0 failed, clippy-clean.
+48 -15
View File
@@ -40,29 +40,56 @@ Every WiFi router already fills your space with radio waves. When people move, b
<details>
<summary><strong>RuView MetaHarness</strong> — guided operation for humans and AI agents</summary>
The RuView-specific metaharness we created is published as [`@ruvnet/ruview`](harness/ruview/README.md). It provides source-cited guidance, guarded Claude Code/Codex agents, deterministic verification, an honesty check for accuracy claims, and an explicitly granted OAuth-only Cognitum Spaces read.
The RuView-specific metaharness we created is published as [`@ruvnet/ruview`](harness/ruview/README.md). It provides:
- source-cited guidance;
- device access for CSI nodes, radar and LiDAR;
- firmware flashing with boot evidence;
- one MCP server, usable over stdio or HTTP for ChatGPT, with a live console widget;
- a Claude Code mod;
- guarded Claude Code/Codex agents;
- deterministic verification;
- an honesty check for accuracy claims.
Companion packages: [`homecore`](harness/homecore/README.md) (Homecore developer metaharness) and [`@ruvnet/ruview-kernel`](harness/ruview-kernel/README.md) (the vitals pipeline as WASM).
```bash
# Check the local setup and get source-cited guidance
npx @ruvnet/ruview@0.4.0 doctor
npx @ruvnet/ruview@0.4.0 guidance --topic sensing --query "model loading"
npx @ruvnet/ruview@0.9.1 doctor
npx @ruvnet/ruview@0.9.1 guidance --topic sensing --query "model loading"
# Hardware: what is plugged in, live CSI from your nodes, a 60 GHz radar kit
npx @ruvnet/ruview@0.9.1 devices
npx @ruvnet/ruview@0.9.1 esp32 --watch # ESP32 + Realtek RAC1 nodes, live view
npx @ruvnet/ruview@0.9.1 esp32 --seconds 45 --analyze # live CSI through the vitals kernel
npx @ruvnet/ruview@0.9.1 mmwave --source esphome --host <kit-ip>
# Agents: MCP over stdio, or over HTTP for ChatGPT (token-protected, read tools only)
npx @ruvnet/ruview@0.9.1 mcp start
RUVIEW_MCP_GRANTS=device-access npx @ruvnet/ruview@0.9.1 mcp start --http
# Claude Code: a live sensing pane (/ruview), shipped in the package
npx @ruvnet/ruview@0.9.1 mod
# Run a read-only RuView agent through Codex
npx @ruvnet/ruview@0.4.0 agent run --host codex --repo . \
npx @ruvnet/ruview@0.9.1 agent run --host codex --repo . \
--prompt "Find the nearest tests and cite the source files"
# Search or verify the reviewed contributor brain
npx @ruvnet/ruview@0.4.0 brain search --query "calibration"
npx @ruvnet/ruview@0.4.0 brain verify --repo .
# Check claims, replay the deterministic proof, or expose the MCP server
npx @ruvnet/ruview@0.4.0 claim-check --file REPORT.md
npx @ruvnet/ruview@0.4.0 verify
npx @ruvnet/ruview@0.4.0 spaces
npx @ruvnet/ruview@0.4.0 mcp start
# Check claims, replay the deterministic proof, search the reviewed brain
npx @ruvnet/ruview@0.9.1 claim-check --file REPORT.md
npx @ruvnet/ruview@0.9.1 verify
npx @ruvnet/ruview@0.9.1 brain search --query "calibration"
```
Agent runs are read-only by default. Workspace writes require both `--allow-write` and `--confirm`; retrieved brain content is evidence, not authority.
**Safety:**
- Agent runs are read-only by default. Workspace writes require both `--allow-write` and `--confirm`.
- Hardware reads need the `device-access` grant.
- Flashing and calibration never run over the HTTP transport.
- Device-reported vitals are labelled as unvalidated.
- Retrieved brain content is evidence, not authority.
A single `npx ruview` package bundling all of this is ready (ADR-376). It is waiting on npm to release the unscoped name.
Full walkthrough: [user guide → RuView npm toolkit](docs/user-guide.md#ruview-npm-toolkit-ruvnetruview).
</details>
@@ -694,7 +721,13 @@ claude --plugin-dir ./plugins/ruview
Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full details: [`plugins/ruview/README.md`](plugins/ruview/README.md).
For the portable RuView MetaHarness, use `npx @ruvnet/ruview@0.4.0`; the quick commands and fuller explanation are in the collapsed MetaHarness section near the top of this README and in [`harness/ruview/`](harness/ruview/README.md).
**Live sensing pane (Claude Code mod):** the same marketplace also lists `ruview-live`, a Claude Code mod.
- Install it with `/plugin install ruview-live@ruview`, or run `npx @ruvnet/ruview@0.9.1 mod` for its path inside the npm package.
- `/ruview` opens a pane beside the transcript with your CSI nodes and radar, refreshed on a timer, plus a one-line status.
- It is read-only.
- Mods are early access; if `/ruview` is missing, start Claude Code with `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`. See [ADR-377](docs/adr/ADR-377-ruview-live-claude-code-mod.md).
For the portable RuView MetaHarness, use `npx @ruvnet/ruview@0.9.1`. The quick commands and a fuller explanation are in the collapsed MetaHarness section near the top of this README, in [`harness/ruview/`](harness/ruview/README.md), and in the [user guide](docs/user-guide.md#ruview-npm-toolkit-ruvnetruview).
</details>
@@ -0,0 +1,96 @@
# ADR-364: BFI capture admission and continuity
- **Status**: proposed
- **Date**: 2026-09-29
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: bfi, capture, evidence, continuity, hardware
## Context
The Archer BE550, associated iPhone and MT7927 capture host have produced
readable beamforming feedback information (BFI). Monitor-mode support alone
does not establish this capability, and readable reports do not establish
continuous sensing. Router channel width also does not guarantee an identical
feedback grid in every report.
**MEASURED:** two saved trials contain 66 and 96 unique reports. During their
approximately 30-second sender intervals, rates were 2.2 and 3.2 reports/s,
with maximum silence of 8.392 and 6.437 seconds. No reports followed in the
approximately 15-second post-traffic intervals. All 162 reports received an
independent tshark angle/control/SNR comparison. Reproducer commands, capture
hashes and limitations are in the [capture results](../../tools/bfi/OPTIMIZATION.md)
and [continuity report](../../tools/bfi_learning/CAPTURE-CONTINUITY.md).
## Decision
Admit a hardware/driver/profile combination only from its actual packet
evidence. Apply these boundaries before using its output for sensing:
1. Keep originals and receipts outside Git, with raw own-network captures on
the capture host. Record hardware, driver, channel, source hashes, capture
interval and restoration outcome. Select the authorized AP/client direction
explicitly; missing provenance cannot be inferred from an SSID.
2. Enforce container, radiotap, MAC, length, fragmentation and supported-format
checks in [bfi_decode.py](../../tools/bfi/bfi_decode.py). Validate FCS when
present and reject flagged corruption; absent FCS is a recorded limitation.
Unsupported tuples remain counted rejections. Compare every admitted angle
against an independent dissector before extending a format's support claim.
3. Deduplicate retries over the whole capture before slicing time intervals.
Preserve integer timestamps and separate feedback grids. Sequence numbers
and sounding tokens do not measure lost packets. BFI steering matrices are
not full CSI; the identity product of a square unitary matrix with its
conjugate transpose cannot serve as a changing channel feature.
4. Preserve full-observation quality metrics and add separate before-traffic,
sender-interval and after-traffic metrics. Include leading/trailing silence,
report rate, maximum gap and duration-weighted occupied one-second bins.
Sender timestamps bound attempted UDP sends, not verified over-air sounding.
5. Use an initial continuity gate of reports in at least 27 of the first 30
complete one-second sender bins and no silence longer than two seconds
anywhere in the actual sender interval, repeated in three comparable trials.
Report any partial final bin separately; never count it as a complete second.
This is a project engineering gate, not a published sensing accuracy result.
The current traffic receipt lacks a PCAP hash and AP/client binding. The
analyzer checks caller-selected file associations and hashes all inputs in its
output; this establishes content integrity, not authenticated session binding.
Stronger binding is required before automated capture admission can treat
receipt association as independently verified.
## Alternatives
Monitor-mode declarations, average report rate alone and interpolating missing
reports would each hide a different failure. Retain explicit packet validation
and interval coverage instead. ESP32/Realtek CSI remains a separate modality;
multiple unsynchronized boards do not establish a coherent BFI array.
## Consequences
### Positive
Packet validity, capture continuity and sensing accuracy have separate,
reproducible acceptance criteria. Changes in denominators cannot appear as RF
performance gains.
### Negative
Strict format and continuity gates can exclude usable but sparse observations.
New PHY formats and hardware require additional captures and independent checks.
### Neutral
The current trials fail continuity. Further motion modeling requires a new
capture result; this ADR does not authorize hardware changes or establish
identity recognition.
## Validation and links
Run `python -B -m unittest discover -s tools/bfi -v` and the focused
`test_traffic_coverage.py` suite under `tools/bfi_learning`. Synthetic tests
check parsing and interval rules; acceptance additionally requires retained
real captures and the three successful trials above.
- Extends proposed [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md)
and [ADR-123](ADR-123-bfld-capture-path-nexmon-and-esp32.md); neither is superseded.
- Preserves [ADR-120](ADR-120-bfld-privacy-class-and-hash-rotation.md)'s privacy boundary.
- Public-data admission has a separate contract in
[ADR-365](ADR-365-public-bfi-dataset-and-decoder-contract.md).
@@ -0,0 +1,121 @@
# ADR-365: Public BFI dataset and decoder contract
- **Status**: proposed
- **Date**: 2026-09-29
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: bfi, datasets, decoding, provenance, benchmarks
## Context
Public activity recordings offer a separate route to BFI model development
while local capture continuity is insufficient. They introduce different frame
formats, collection assumptions and licenses. Public HAR activity labels do
not reproduce KIT's person-identification experiment.
**MEASURED:** the pinned CSI-BFI-HAR train/validation download passed exact
size/hash checks for 120 captures plus its license card. A separate body decoder
matched 13,104 quantized angles across 14 sample bodies against tshark 4.2.2 in
two independent executions. Whole-packet admission and activity training remain
incomplete. See [results](../../tools/bfi_learning/RESULTS.md) and the
[decoder audit and reproducer](../../tools/bfi_learning/PUBLIC-BFI-DECODE.md).
## Decision
### Pin provenance and partitions
Use the [author dataset](https://huggingface.co/datasets/foysalhaque/CSI-BFI-HAR-Dataset/tree/c3013b3edc5a563a9cf6759f5d464a9d9b6f456a)
at revision `c3013b3edc5a563a9cf6759f5d464a9d9b6f456a`. The
[manifest](../../tools/bfi_learning/manifests/csi-bfi-har-c3013b3-subset.json)
pins URLs, lengths, SHA-256, labels and split membership. Preserve the pinned
dataset card's GPL-3.0 declaration and attribution separately from code licenses.
| Partition | Author directory | Domain |
| --- | --- | --- |
| Training | HAR-1/BFI/M1 | Day 1, kitchen |
| Validation | HAR-3/BFI/M1 | Day 3, classroom |
| Held out | HAR-5/BFI/M1 and M2 | Day 5, living room; two views |
Keep all views of one event in the same partition. Day and room are confounded;
M1/M2 labels do not establish heterogeneous-chipset transfer. The selected
filenames cover A–T activities and P1–P3 participants.
Default downloads exclude held-out views. The bounded
[downloader](../../tools/bfi_learning/download_public_bfi.py) verifies exact
bytes before atomic completion, rehashes resumed files, retains failed partials
and enforces transfer deadlines and byte limits. Data stays outside Git;
downloaded code is never executed by the downloader.
### Keep body decoding separate from packet admission
[public_bfi_decode.py](../../tools/bfi_learning/public_bfi_decode.py) accepts
only the audited VHT MU 3x1, 80 MHz, Ng=1, codebook-1 complete action body:
1,003 bytes containing one SNR byte, 936 angle bytes and 61 opaque MU-exclusive
bytes. It exposes quantized/radian angles and tone ordinals. Frequency indices,
exclusive-field interpretation and validated matrix reconstruction remain
separate acceptance work.
Twelve sample frames have nonzero fragment-number bits. Successful tshark
decoding and valid CRCs do not resolve their semantics. Retain live MAC guards;
quarantine these frames until an explicit, independently reviewed dataset
policy can justify admission. Require lossless pcapng conversion evidence for
integer timestamps, packet lengths, link types and all original packet bytes.
Do not silently strip headers or rewrite fragment fields to gain admission.
The natural circular representation needs 1,404 features, exceeding the current
trainer's 1,024-feature limit. Approve a bounded representation or revised limit
with allocation tests before integration; do not truncate tones implicitly.
### Admit recording quality before scoring models
Inventory every manifest file, including tiny or unusable traces. Freeze a
five-second observed-interval rule: at least 20 valid unique reports, with no
inter-report gap above one second and no interpolation. Require at least two
independent recordings per activity in each partition/view group that supply
an eligible interval. Missing A–T coverage fails benchmark admission.
Before implementing the census, also freeze interval anchoring, stride and
leading/trailing gap rules: an inter-report gap bound alone does not bound
silence at interval edges. These details remain required acceptance work.
Public files lack trusted recorder start/end manifests. Observed packet spans
cannot establish unobserved leading/trailing coverage. Report rejection reasons,
eligible recording/time coverage and accuracy together. Freeze event/file groups
before generating windows; inspect held-out quality without model selection
against held-out outcomes.
## Alternatives
Reusing the HE/SU parser without format admission, weakening live fragment
guards, or silently dropping sparse classes would change the task while hiding
the change. Keep this adapter explicit and fail incomplete benchmark coverage.
## Consequences
### Positive
Data provenance, protocol support and score coverage remain independently
auditable. New public formats cannot silently weaken live capture validation.
### Negative
Sparse files or unresolved framing may prevent a benchmark despite a successful
download. Format and representation work must precede model optimization.
### Neutral
This is offline research/reference tooling. Production integration must reuse
the Rust protocols in [ADR-291](ADR-291-public-benchmark-evaluation-harness.md)
and scorecards in [ADR-317](ADR-317-benchmark-multi-domain-scorecard.md); this ADR
does not establish a second production trainer or an integrated benchmark score.
## Validation and links
Use `test_download_public_bfi.py` and `test_public_bfi_decode.py` under
`tools/bfi_learning`, then the full packet oracle and quality census specified
in the [decode audit](../../tools/bfi_learning/PUBLIC-BFI-DECODE.md) and
[benchmark protocol](../../tools/bfi_learning/RESEARCH-NEXT.md).
- Depends on [ADR-364](ADR-364-bfi-capture-admission-and-continuity.md)'s distinction
between packet validity and coverage.
- Preserves [ADR-120](ADR-120-bfld-privacy-class-and-hash-rotation.md).
- [ADR-119](ADR-119-bfld-frame-format-and-wire-protocol.md) defines a BFLD envelope;
that envelope does not replace raw 802.11 decoding.
@@ -0,0 +1,115 @@
# ADR-366: CSI controls and frozen evaluation
- **Status**: proposed
- **Date**: 2026-09-29
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: csi, evaluation, controls, holdout, evidence
## Context
The public NTU-Fi HumanID experiment uses 14 classes and a custom directory
split of 238 training, 56 validation and 546 held-out recordings. It differs
from the author's folder mapping. Physical acquisition-session independence
is unknown; absence of exact duplicates cannot establish it.
**MEASURED:** each of six LSTM/GRU runs classified 56/56 validation recordings
correctly. A fixed-alpha ridge model using only mean amplitudes also scored
56/56. In a matched three-seed GRU diagnostic, raw/original, raw/shuffled and
demeaned/original inputs each scored 56/56 for every seed; demeaned/shuffled
scored 52/56, 55/56 and 56/56. Chronological motion is unnecessary for perfect
accuracy on this validation split. The physical source of the predictive signal
remains unresolved. [Measured results and reproduction receipts](../../tools/bfi_learning/RESULTS.md)
## Decision
### Separate diagnostics from claims of generalization
Use static and temporal controls before interpreting a sequence-model score.
Keep input files, optimizer budgets and seeds matched across raw/original,
raw/shuffled, demeaned/original and demeaned/shuffled conditions. Permute whole
feature rows within one recording, using source/seed information without labels.
Never mix partitions. Mark shuffled time coordinates as administrative.
Whole-recording demeaning is an offline diagnostic that sees the complete
recording. It cannot substantiate causal streaming inference. These nondefault
controls are restricted to the custom public identity protocol and cannot alter
next-observation BFI forecasting. Fit normalization on training inputs only.
Report recording-level accuracy, macro-F1, included recordings and seed variation
alongside validation cross entropy averaged over selected windows. Recording
predictions aggregate softmax probabilities across their selected windows;
the current loss is not computed from those recording aggregates. Treat window
accuracy as secondary because overlapping windows and repeated seeds on the same
recordings are not independent participant trials. Retain session-independence
limitations beside every custom-split result.
### Freeze selection before final evaluation
Keep the [predeclared control protocol](../../tools/bfi_learning/CONTROLS.md):
the twelve 50-epoch diagnostic runs are excluded from final-model selection.
Only the six original raw/original 200-epoch LSTM/GRU runs are eligible. Select
lowest window-averaged validation cross entropy, breaking ties by earlier
completion. Do not substitute recording-aggregated loss or change this rule in
response to a held-out outcome.
Freeze source, dataset, metadata, configuration and checkpoint hashes before
the scheduled final evaluation. Load frozen weights without retraining. The
trainer creates an exclusive one-use claim before holdout scoring and retains
it even if scoring fails; a failure requires explicit review, not an automatic
retry. This claim is scoped to the selected frozen-run directory; the controller
enforces one final evaluation across the task's candidate directories. Stop
tuning against that holdout after its result is inspected. At this decision's
evidence checkpoint, final holdout metrics remain absent.
### Preserve executable provenance
Use bounded numeric NPZ inputs/checkpoints with pickle disabled and validate ZIP
central-directory bounds before allocation. Verify hashes and restore recorded
transformations during frozen evaluation. The launcher checks returned seed,
protocol, configuration and `test: null` during tuning. Save incomplete runs
and denominator changes explicitly rather than substituting successful runs.
Current Python tools are bounded offline reference experiments. Production
benchmark integration continues through the Rust protocols and per-domain
scorecards of ADR-291/317. A fair state-of-the-art comparison needs matched data,
task, representation, partition, adaptation access and evaluation; this custom
CSI result establishes neither BFI identity performance nor paper reproduction.
## Alternatives
Validation accuracy alone cannot distinguish temporal learning from static
cues. Random window splits would leak recordings across partitions. Expanding
the eligible model set after diagnostics would change the predeclared selection
procedure. Retain the frozen recording split and candidate set.
## Consequences
### Positive
Controls expose when model complexity or temporal claims lack experimental
support. The final evaluation has a traceable selection rule and one-use record.
### Negative
A saturated validation set limits further optimization, and strict one-use
evaluation makes interrupted scoring an explicit recovery decision. New-session
evidence requires new data rather than additional fitting to this split.
### Neutral
The GRU has 24.8% fewer parameters than the tested LSTM, but measured peak GPU
allocation was higher and elapsed times do not isolate an architecture speedup.
Preserve these separate resource findings when comparing models.
## Validation and links
Run the focused `test_prepare.py`, `test_train.py`, `test_static_baseline.py`
and `test_run_batch.py` suites under `tools/bfi_learning`. Synthetic isolation
and restore tests complement the retained real-data receipts; neither establishes
new-session generalization.
- Extends [ADR-291](ADR-291-public-benchmark-evaluation-harness.md) and
[ADR-317](ADR-317-benchmark-multi-domain-scorecard.md) without superseding them.
- [Comparison contract](../../tools/bfi_learning/RESEARCH.md),
[trainer](../../tools/bfi_learning/train.py) and
[overnight frozen-selection procedure](../../tools/bfi_learning/OVERNIGHT.md).
@@ -0,0 +1,110 @@
# ADR-367: Bounded research swarm and compute ownership
- **Status**: proposed
- **Date**: 2026-09-29
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: orchestration, ruflo, codex, compute, provenance
## Context
Research, implementation and independent review can run concurrently, while
GPU training, capture hardware and final evaluation need clear ownership.
Registration in a swarm registry does not prove that a worker executed a task.
Unbounded retries, duplicate schedulers or overlapping jobs can corrupt evidence
and spend resources without improving an experiment.
**MEASURED at the September 29 checkpoint:** local GPU runs and Codex reviews
completed with receipts. Federation enrollment returned `registration paused`
and relay membership was rejected; no cross-host worker accepted a task.
Shared-memory writes were blocked by the native-WAL guard. These limitations
and the actual work locations are recorded in the
[overnight procedure](../../tools/bfi_learning/OVERNIGHT.md).
## Decision
### Assign one owner to each mutable resource
The root controller owns each capture action, GPU batch, checkpoint-selection
decision and final evaluation. Delegated workers receive bounded source/review
tasks and explicit file ownership. Worker findings are untrusted proposals for
controller review. Distinguish registration, dispatch, execution and verified
completion in receipts; only execution evidence supports a completed-work claim.
Use the existing app heartbeat as the sole recurring scheduler for this task.
Finite serial GPU loops are jobs within a wake, not independent recurring
schedulers. Resume from receipts and verified lock ownership; completed runs
are not repeated merely because a wake or supervisor restarts.
### Bound execution and audit outputs
The [GPU launcher](../../tools/bfi_learning/run_batch.py) uses Linux process
groups, an exclusive batch lock, a 900-second trainer budget and a 1,020-second
child timeout. The current experiment requires at least 4 GiB free GPU memory,
caps the trainer's PyTorch allocator fraction at 20%, and bounds logs/metrics
to 8 MiB. This allocator limit is not a whole-device memory reservation.
Verify child hashes, seed, protocol, configuration and absence of test metrics
before declaring success. Preserve unrelated GPU services; cleanup targets only
owned processes. Audit matrix and batch ownership before changing stale locks.
The task-specific [Codex review wrapper](../../tools/bfi_learning/codex_iteration.py)
uses bounded source snapshots on stdin, read-only sandboxing, execution rules,
an exclusive lock, timeout and private event logs. This differs from the generic
contributor adapter's configuration contract. Check events for unexpected tool
execution; never execute suggestions automatically or grant child text authority.
Preserve federation and memory safety guards when a backend is unavailable.
Use private local receipts as the fallback and report degraded coordination.
Retry only after a relevant service/writer change or a concrete causal fix.
### Bind compute to an explicit task budget and end time
Use local capacity first. A paid GPU needs a measured capacity requirement and
an explicitly authorized cumulative budget covering actual spend, outstanding
reservations and fees. Before rental, check current prices, active instances
and remaining allowance; reserve maximum bounded cost, enforce termination and
verify resource cleanup. Transfer only authorized public licensed data and
reviewed source. A future run needs its own applicable authorization.
For the current run, the authorized cap is USD 200, local RTX 5080 capacity has
been sufficient and the recorded spend is USD 0. The existing schedule ends
September 30, 2026 at 07:00 America/Toronto. Its final wake performs the frozen
evaluation, reconciles owned jobs/resources and the ledger, records results,
then pauses the heartbeat. These are run-specific values, not durable spending
or scheduling authority granted by this ADR.
## Alternatives
Multiple controllers per GPU, detached indefinite loops, and treating a
registration response as an executed agent task make ownership unverifiable.
Retain explicit owners, finite jobs and execution receipts instead.
## Consequences
### Positive
Parallel review remains useful while resource mutation and final evaluation
have one accountable owner. Receipts support recovery without silent reruns.
### Negative
Serial GPU ownership can leave capacity unused. Federation or memory outages
reduce coordination features, and bounded jobs may need reviewed continuation.
### Neutral
Source and sanitized documentation belong in the isolated worktree. Raw RF
data, subject recordings, weights, logs, credentials and transcripts stay outside
Git. A completed experiment does not authorize model promotion, firmware
flashing, merging or publication.
## Validation and links
Run `test_run_batch.py` and `test_codex_iteration.py` under `tools/bfi_learning`.
Audit real execution/cleanup receipts, lock owners, ledger and scheduler state
at completion; successful synthetic tests alone cannot establish these outcomes.
- Preserves [ADR-283](ADR-283-ruview-community-metaharness-flywheel.md)'s
proposal-only learning and maintainer promotion boundary.
- Follows [ADR-321](ADR-321-decision-policy-action-authorization.md).
- Depends on [ADR-366](ADR-366-csi-controls-and-frozen-evaluation.md) for frozen
candidate selection and final holdout handling.
@@ -0,0 +1,187 @@
# ADR-368: RuView compute kernel as a WASM + napi-rs npm package
- **Status**: proposed
- **Date**: 2026-09-30
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: npm, wasm, napi-rs, metaharness, vitals, supply-chain
- **Related**: ADR-021 (vitals), ADR-263/265 (npm harness review and
distribution), ADR-283 (community metaharness), ADR-285 (WASM-first Homecore
metaharness)
## Context
`@ruvnet/ruview` (ADR-283) is a dependency-free operator/contributor harness:
it guides, verifies, and delegates, but it cannot run RuView signal processing.
JavaScript consumers (MCP hosts, dashboards, browser demos, agents) that want
real RuView computation must today start the Rust sensing server or re-implement
DSP in JS, which drifts from the Rust source of truth.
We want one Rust implementation reachable from npm with:
1. a portable default that works on every Node platform and in browsers;
2. an optional native path for hosts that want it;
3. the metaharness trust posture: least authority by default, the backend that
actually ran is always reported, fail-closed on missing or tampered
artifacts, and no accuracy claim without an evidence tag.
## Decision
### One core, one ABI, two transports
- **`v2/crates/ruview-kernel`** (workspace member) wraps the ADR-021 pipeline
from `wifi-densepose-vitals` (EMA preprocessing, breathing 0.1–0.5 Hz, heart
0.8–2.0 Hz, anomaly z-score) in a streaming `Session`, a deterministic
SplitMix64 synthetic-CSI generator, and a bounded session registry.
- The entire ABI is one function: `call(op, request_json) -> envelope_json`,
returning `{"ok":true,"result":…}` or
`{"ok":false,"error":{"code","message"}}`. Operations: `info`,
`validate_config`, `analyze`, `synthesize`, `session_open|push|summary|close`.
Keeping the ABI to a single string function makes backend parity a property
of the transport rather than of duplicated bindings.
- **WASM transport** (`src/wasm_abi.rs`, `wasm32-unknown-unknown` only):
`rvk_alloc`/`rvk_free`/`rvk_call`/`rvk_abi_version`. The module has **zero
imports** — no filesystem, network, clock, or randomness. The JS loader
refuses any module that imports host functions.
- **napi-rs transport** (`v2/crates/ruview-kernel-napi`): exports only
`abiVersion()` and `call(op, json)`. It is a standalone Cargo workspace
(listed in the v2 `exclude`) so napi never enters
`cargo test --workspace`, and it builds with `panic = "unwind"` plus
`catch_unwind` so a defect becomes an error envelope, not a Node crash.
### Input is untrusted at the ABI
Requests are bounded before processing: 16 MiB per request, 512 subcarriers,
100 000 frames per call or synthesis, 64 concurrent sessions. Unknown JSON
fields are rejected (`deny_unknown_fields`); frame widths must match the
configured subcarrier count; values must be finite with magnitude ≤ 1e9; all
frames of a batch are validated before any is processed. The core returns an
error envelope instead of panicking on caller data (release builds of the WASM
module use `panic = "abort"`, which the loader surfaces as a `trap` and
recovers from with a fresh instance).
### npm package `@ruvnet/ruview-kernel` (`harness/ruview-kernel`)
- Zero runtime dependencies. `loadKernel({backend})` with
`backend ∈ {wasm (default), napi, auto}` or `RUVIEW_KERNEL_BACKEND`.
WASM is the default because it carries no host authority. `napi` never
silently falls back; `auto` prefers napi and records `fallbackReason`.
- Artifacts are built from source by `npm run build` (`scripts/build.mjs`,
no shell) into `wasm/` and `native/ruview_kernel.<triple>.node`, with
`SHA256SUMS`. The loader verifies checksums; a mismatch is always fatal and
never an `auto` fallback trigger; `requireIntegrity` also refuses
unchecksummed artifacts. Built artifacts are git-ignored and never committed.
- `src/wasm.js` is filesystem-free, so browsers can use
`loadWasmKernelFromBytes(bytes)`.
- CLI `ruview-kernel`: `doctor`, `info`, `selftest`, `parity`, `synth`,
`analyze --file`, `bench`. `synth --out` refuses to overwrite files;
`analyze` reads only a bounded regular file.
- `selfTest()` runs synthesize → analyze → chunked streaming session → hostile
input, and asserts known-tone recovery. `parity()` diffs every output of
both backends with a 1e-9 relative tolerance, because `sin`/`exp` come from
different libm implementations on wasm32 and native targets; exact JSON
equality is reported separately and is not required.
### Metaharness integration
`@ruvnet/ruview` gains the read-only tool `ruview_kernel_selftest`
(CLI: `ruview kernel [--backend …]`). It dynamically imports the fixed
specifier `@ruvnet/ruview-kernel`, so `@ruvnet/ruview` keeps zero runtime
dependencies, and it returns `kernel_not_installed` when the package is absent.
MCP arguments are limited to `backend` and `seconds`; they cannot choose a
module or artifact path. The capability appears in `ruview_guidance` as
`portable-compute-kernel`.
## Evidence
- **SYNTHETIC** (`cd harness/ruview-kernel && node bin/cli.js selftest`):
60 s at 20 Hz, 56 subcarriers, 15 bpm breathing and 72 bpm heart tones →
recovered 15.0 bpm respiratory (valid) and 70.6 bpm heart (degraded
confidence 0.44). Tolerances asserted: ±3 bpm respiratory, ±8 bpm heart.
- **SYNTHETIC** parity (`node bin/cli.js parity`): 900 frames / 45 readings,
no difference above 1e-9 relative between wasm32 and linux-x64-gnu napi;
outputs are not byte-identical.
- **MEASURED** on one Linux x64 development container
(`node bin/cli.js bench --backend wasm|napi --seconds 120`): about 78 ms
(wasm) and 63 ms (napi) per 2 400-frame `analyze`, including JSON transfer.
Single-host timing, not a cross-platform claim.
- The WASM release artifact is about 230 KB and imports nothing
(`node scripts/verify-artifacts.mjs --require-wasm`).
None of these numbers says anything about real-world vital-sign accuracy.
That still requires the ADR-293 ground-truth protocol with a reference device.
## Amendment 1 (2026-10-01): binary transport and optimization evidence
**Profile (MEASURED on one Linux x64 development container, 2 400 frames ×
56 subcarriers).** JSON was the dominant cost:
- JS `JSON.stringify` of the request alone took 52–78 ms against
107–124 ms for a whole `analyze`.
- Rust then re-parsed about 270 000 numbers.
**Decision.** Add a binary fast path without changing the ABI version.
- New operations `analyze_flat` and `session_push_flat`, carried by
`call_f64(op, json, &[f64])`:
- WASM: `rvk_call_f64`, which reads bytes so alignment does not matter;
- napi: `callF64(op, json, Float64Array)`.
- `analyze()` and `session.push()` pack uniform frames into one
`Float64Array` (all amplitudes, then all phases) and use the binary path
automatically. Irregular input still takes the JSON path so its error names
the bad frame.
- `analyzeFlat(Float64Array, …)` is the zero-copy entry.
- `Session::step` is shared by both paths.
**Evidence.**
- **Byte-identical outputs.** On identical `f64` inputs the two paths
produce byte-identical JSON (`tests/abi.rs`).
- **A JSON precision finding.** serde_json's default float parser is not
correctly rounded, so the JSON transport perturbs inputs by ULPs (34 of 160
amplitude values in a 2 s, 4-subcarrier SYNTHETIC sample). The binary path is bit-exact.
- **Speed.** MEASURED, `ruview-kernel bench --seconds 120`, same host:
| Backend | JSON transport | Binary transport | Speed-up |
|---|---|---|---|
| wasm | 134.8 ms | 47.8 ms (~50 000 frames/s) | 2.82× |
| napi | 109.7 ms | 44.8 ms (~53 600 frames/s) | 2.45× |
- **Remaining cost is DSP.** Native timing gives heart-rate extraction
20.8–22.9 ms of 30.6–38.3 ms (about 60–68%, MEASURED; reproducer:
`cargo run --release -p ruview-kernel --example profile_stages`): the per-frame autocorrelation over the 15 s window
in `wifi-densepose-vitals`. That crate is shared with the production sensing
server, and an incremental ACF would change floating-point summation order
and therefore outputs. It is left as a follow-up that needs its own review
and regression evidence.
**Rejected after measurement.** MEASURED, three runs on the same host;
reproducer: `node bench/wasm-variants.mjs` in `harness/ruview-kernel`. Each
run is the median of 15 `analyzeFlat` calls on 2 400 frames. All variants
produced byte-identical outputs.
| Variant | Size | Median ms (3 runs) | Decision |
|---|---|---|---|
| baseline (`opt-level = 3`) | 244 KB | 38.3–40.6 | kept |
| `+simd128` | 242 KB | 37.4–54.7 | rejected: no consistent gain, and it needs SIMD-capable engines |
| `opt-level = "s"` | 227 KB | 40.8–70.1 | rejected: consistently slower for a 17 KB size saving |
## Consequences
- JavaScript consumers get real RuView DSP without a server, in Node or the
browser, with one Rust source of truth.
- A second native build matrix exists. Until per-platform packages
(`@ruvnet/ruview-kernel-<triple>`) are published by CI, only WASM is
portable; the loader already resolves those package names.
- JSON transfer dominates the cost of small calls; a typed-array fast path can
be added later behind a new ABI version.
## Rollout and rollback
1. CI (`ruview-kernel-npm.yml`) builds both transports from source, runs the
Rust tests, the package tests with `RUVIEW_KERNEL_REQUIRE_NAPI=1`, parity,
artifact verification, and `npm pack --dry-run`.
2. Publication stays CI-only with provenance (ADR-265) and requires explicit
maintainer approval; this ADR does not add a publish job.
3. Rollback: remove the `ruview_kernel_selftest` tool (the harness has no
dependency on the package) and deprecate the npm version; the Rust crates
are additive.
@@ -0,0 +1,74 @@
# ADR-369: npm RuView operator surface — CLI, MCP, and SDK over one registry
- **Status**: proposed
- **Date**: 2026-09-30
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: npm, mcp, sdk, cli, metaharness, least-authority
- **Related**: ADR-263/265 (npm harness review and distribution), ADR-283
(community metaharness), ADR-368 (compute kernel), ADR-370 (flashing),
ADR-371 (training), ADR-372 (doctor)
## Context
`@ruvnet/ruview` 0.5.x could onboard, lint claims, verify the proof, monitor a
node, and read Cognitum Spaces. Three operator jobs still left the package:
- **Flashing** was a Windows-only stub that returned `manual_step_required`.
- **Training** had only a skill document; nothing ran a trainer or checked
results mechanically.
- **Diagnostics**: `doctor` printed six PASS/FAIL lines without remedies.
There was also no programmatic API. Integrators had to shell out to the CLI or
speak MCP.
## Decision
1. **One registry, three front ends.** Every capability is a `ruview_*` tool
in `src/tools.js`, with a JSON schema, a `TOOL_POLICY` class, and a
structured, fail-closed result. The CLI verbs, the MCP server, and the new
SDK all go through `runTool`. There is one argument validator and one
authorization path.
2. **SDK** (`@ruvnet/ruview/sdk`, typed by `src/sdk.d.ts`). `createRuView()`
returns a frozen object with `doctor`, `firmware.{ports,plan,flash,monitor}`,
`training.{plan,run,gate,calibrate}`, `kernel.{selfTest,load}`, `guidance`,
`claimCheck`, `verify`, `spaces`, `memorySearch`, `call`, `tools`, and
`startMcpServer`.
- Calls run with `source: 'sdk'`: trusted in-process code, like the CLI.
Mutating tools still refuse to act without `confirm: true`.
- `{ strict: true }` turns `ok:false` into a `RuViewError`.
3. **Plan/act split for every mutation.**
- `ruview_firmware_plan` and `ruview_train_plan` are read-only and their
schemas do not accept `confirm`. An MCP client without write grants can
review the exact command.
- The acting tools (`ruview_node_flash`, `ruview_train`) keep the existing
grant-plus-confirm gate: `hardware-write` and `workspace-write`.
4. **New tools**: `ruview_doctor`, `ruview_firmware_plan`,
`ruview_firmware_ports`, `ruview_train`, `ruview_train_plan`,
`ruview_train_gate`. `ruview_node_flash` is re-implemented (ADR-370).
`TOOL_POLICY`, `.harness/claims.json`, and `.harness/mcp-policy.json` are
kept in lock-step, and a test fails on any drift.
5. **Process execution** for operator tools goes through `src/exec.js`:
- argv only, never a shell;
- a scrubbed environment: the base allowlist plus toolchain locations such
as `LIBTORCH`, `CARGO_HOME`, `VIRTUAL_ENV` and `IDF_PATH`, and no
credentials;
- bounded output, and secrets redacted.
6. **Zero runtime dependencies stays.** The optional compute kernel
(ADR-368) is loaded dynamically. Version 0.6.0. The unpacked-size ceiling
rises from 160 KiB to 224 KiB for the new modules, with the budget recorded
in both npm workflows.
## Consequences
- Agents, scripts, and humans get the same behaviour and the same refusals.
- The package now drives real hardware and training. Keeping the plan/act
split and argv-only execution is essential, and tests pin both.
- The surface is larger (16 MCP tools). Hosts that cap tool counts can
filter by annotations (`readOnlyHint`).
## Acceptance
- `cd harness/ruview && npm test && npm run test:security` (drift test in
`test/sdk.test.mjs`).
- `npx @ruvnet/ruview tools` lists 16 tools. `node -e "import('@ruvnet/ruview/sdk')"`
resolves from an installed tarball.
@@ -0,0 +1,83 @@
# ADR-370: Cross-platform, verified ESP32 firmware flashing from npm
- **Status**: proposed
- **Date**: 2026-09-30
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: firmware, esp32, esptool, supply-chain, hardware-evidence
- **Related**: ADR-028 (capability audit / witness), ADR-369, firmware
`README.md` §0 and §2
## Context
The documented flash procedure is four `esptool write_flash` offsets. It is
typed by hand, and a wrong chip, wrong flash size, or tampered image can brick
a board or silently run the wrong firmware. The previous `ruview_node_flash`
was Windows-only and never flashed. Project rules also say that a successful
build or write is **not** hardware validation; that requires a captured
boot/runtime log from real silicon.
## Decision
`src/firmware.js` implements plan → verify → preflight → write → evidence:
1. **Variants**:
- `s3-8mb` → `esp32s3` / 8MB
- `s3-4mb` → `esp32s3` / 4MB; prefers `*-4mb.bin` images
- `c6` → `esp32c6` / 4MB
Offsets follow the shipped partition tables: bootloader `0x0`, partition
table `0x8000`, optional OTA data `0xf000`, app `0x20000`. NVS is never
written, so WiFi and node configuration survive.
2. **Bundle verification**. A bundle is an extracted release flash bundle,
`release_bins/<variant>`, or an ESP-IDF `build/` directory.
- Every image must match `SHA256SUMS` or `SHA256SUMS.txt`. A mismatch is
always fatal.
- Missing checksums are refused unless `allow_unverified` is set, which is
for local builds only.
- Images must be regular, non-empty files of at most 16 MiB, inside the
bundle; symlinks that escape the bundle are refused.
- An 8 MB variant refuses a 4 MB partition table.
- A bundle without OTA data produces an explicit warning (a node on `ota_1`
keeps booting `ota_1`).
3. **Argv, not shell.**
- The port must match `COM<n>` or `/dev/(tty|cu)*`.
- Baud comes from a fixed set.
- The command is `python -m esptool --chip … --port … --baud … write_flash
--flash_mode dio --flash_size … <offset> <file>…`. The underscore forms
work in both esptool 4.x and 5.x.
4. **Chip preflight.** `esptool chip_id` must report the variant's chip. The
parser covers the 4.x "Chip is" and 5.x "Chip type:" / "Detecting chip
type…" formats. A mismatch or missing response stops the flow before any
write.
5. **Evidence.** After a write, a read-only pyserial capture (default 15 s,
port and duration passed as argv) is summarized: line count, CSI
callbacks, MGMT+DATA upgrade, panic markers, firmware version.
- `hardwareValidated: true` only when CSI callbacks appear with no panic;
the result's `evidence` is then `MEASURED` with a boot-log reason.
- Otherwise the result states the board is not validated.
6. **Authority.** Without `confirm` the result is a plan. MCP needs the
`hardware-write` grant plus `confirm`. `ruview_firmware_plan` and
`ruview_firmware_ports` are read-only.
7. **Provisioning stays out.** WiFi credentials remain in `provision.py`, so
they never enter harness arguments, logs, or MCP transcripts.
## Evidence
- `npx @ruvnet/ruview doctor --group firmware` verified all four checked-in
`release_bins` bundles (c6-adr110, c6-onboarding, s3-adr110,
s3-fair-adr110) against their `SHA256SUMS.txt` in this change's development
container.
- The write and preflight paths are covered by tests with injected fake
hardware: mismatch refusal, probe failure, flash failure, boot-log
validation, and a silent log that yields no validation.
- **No real board was flashed** during this change, so real-silicon validation
is outstanding.
## Consequences
- Operators on any OS get one verified command. Agents can review plans
without hardware authority.
- The workflow depends on Python, esptool, and pyserial. `doctor` reports each
one with a fix.
- Follow-up: an ADR-028 witness record could store the boot-log summary and
image digests.
@@ -0,0 +1,91 @@
# ADR-371: Harness training runner and mean-pose evidence gate
- **Status**: proposed
- **Date**: 2026-09-30
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: training, evaluation, evidence, pose, honesty
- **Related**: ADR-079 (camera-supervised pose), ADR-151 (room calibration),
ADR-181 (baseline-relative PCK), ADR-369
## Context
RuView has a retracted pose result (a CLAIMED 92.9%/100% that did not survive
baseline and leakage review). The project rule is that pose
PCK is quoted only with the mean-pose baseline, on a leakage-free held-out
split, with an evidence tag. The `train-pose` skill says this in prose, but
nothing enforces it. Running the trainers also needs repository knowledge:
the `tch-backend` feature, a libtorch version match, and where checkpoints go.
## Decision
### Gate (`ruview_train_gate`, pure, read-only)
The input is a small report: metric, model and baseline score, split,
train/test subjects, train end and test start, `n_test`, reproducer, and data
source.
**Hard failures:**
- no baseline;
- a random-frame split, or any split outside chronological / blocked-gap /
grouped-subject / grouped-session;
- subject overlap in a grouped-subject split;
- missing or overlapping time bounds for chronological or blocked-gap splits;
- no improvement over the baseline.
**Warnings:**
- subjects not reported;
- subjects overlapping in non-subject splits;
- `n_test` < 100;
- a near-perfect score: at or above the `implausible_score` threshold in
`src/training.js`.
**Evidence tag:** `SYNTHETIC` when `data: synthetic`, `MEASURED` only with a
reproducer, otherwise `CLAIMED`.
On PASS the gate returns the one acceptable sentence:
`Held-out <metric> X% vs Y% mean-pose baseline = +Z pp (<tag>, …, <split> split).`
### Runner (`ruview_train`, `ruview_train_plan`)
Modes:
- `pose-smoke`: `wifi-densepose-train` `train --dry-run` on the synthetic
dataset.
- `pose`: `--data-dir`, MM-Fi recordings.
- `room`: `wifi-densepose train-room`, or `cargo run -p wifi-densepose-cli`
when the binary is absent.
Rules:
- All caller paths (config, data, checkpoints, enrollment, output) must
resolve inside the trusted checkout. Realpaths are checked for inputs that
already exist.
- Pose runs use `cargo run --manifest-path v2/Cargo.toml` with the working
directory set to the checkpoint directory, default
`v2/target/ruview-train/<mode>`. The trainer's relative `logs/` therefore
never lands in the source tree.
- Without `confirm` it returns a plan. MCP requires `workspace-write` plus
`confirm`.
- Failures are classified with remedies:
- libtorch absent;
- libtorch version mismatch (tch 0.24 expects torch 2.11.0);
- link failure.
## Evidence
- **SYNTHETIC** (`npx @ruvnet/ruview train --mode pose-smoke --samples 16
--confirm`, CPU PyTorch 2.11.0, `LIBTORCH_USE_PYTORCH=1`, Linux x64): the
real trainer compiled, ran 20 epochs with early stopping, and wrote
safetensors checkpoints and `logs/training_log.csv` under
`v2/target/ruview-train/pose-smoke`. The trainer labels its own smoke
metrics "DO NOT REPORT". None are reported here.
- With PyTorch 2.14.1 the tch 0.24 C++ shim failed to compile. The runner
classified this as a version mismatch rather than a missing install.
- The gate is covered by `test/training.test.mjs`, including the leakage
patterns behind the retraction.
## Consequences
- A number can pass through the harness only with its baseline and split
attached. The gate does not verify the arithmetic that produced the input;
reviewers must still re-run the reproducer.
- Training needs the Rust toolchain and a matching libtorch. `doctor` covers
Rust; libtorch is detected at run time.
@@ -0,0 +1,66 @@
# ADR-372: Structured debugging doctor
- **Status**: proposed
- **Date**: 2026-09-30
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: diagnostics, onboarding, support, mcp
- **Related**: ADR-369, ADR-370, ADR-371, docs/TROUBLESHOOTING.md
## Context
Most failed RuView setups come down to a missing driver, pyserial, esptool, a
wrong port, uninitialized submodules, a missing wasm32 target, or a sensing
server that is not running. The old `doctor` checked only the harness itself,
gave no remedies, and had no machine-readable output for agents.
## Decision
`src/doctor.js` returns
`{ok, summary{pass,warn,fail,skip}, checks[{group,id,status,detail,remedy}], nextSteps}`.
Groups:
| Group | Checks |
|---|---|
| `runtime` | Node ≥ 20 |
| `harness` | packaged-file manifest integrity, claim-guardrail self-test, reviewed brain load |
| `hosts` | `claude` / `codex` on PATH |
| `repo` | checkout, workspace submodules |
| `rust` | cargo, wasm32 target (run inside `v2/` so the pinned toolchain is used), `wifi-densepose` binary |
| `python` | interpreter, pyserial, esptool version |
| `firmware` | checksum verification of every `release_bins` bundle |
| `serial` | pyserial port enumeration; optional named port; optional chip probe |
| `sensing` | optional sensing-server `/health` |
| `kernel` | optional `@ruvnet/ruview-kernel` WASM/napi status |
Rules:
- `fail` is reserved for a requested capability that cannot work. Absent
optional tooling is `warn`. Every non-pass check carries a remedy. The CLI
exit code is non-zero only on `fail`.
- The doctor is read-only. Two exceptions exist, both CLI/SDK options that are
absent from the MCP schema:
- `--probe` runs `esptool chip_id`, which resets the board but writes
nothing.
- `--url` makes an HTTP(S) request.
- Output is human-readable by default. `--json` gives the structured report.
`--group a,b` limits the scope.
## Evidence
Development container (Linux x64, Node 22):
- Before `pip install pyserial esptool`, the doctor reported them as `warn`
with the exact pip commands.
- After installation it showed `esptool 5.4.0` and enumerated `/dev/ttyS0`.
- All four firmware bundles verified.
- It caught manifest drift during development, and a missing wasm32 target
when run outside `v2/`. That second case led to the `v2/` working-directory
rule.
## Consequences
- Support threads can ask for `npx @ruvnet/ruview doctor --json`, and agents
can act on `nextSteps`.
- Checks run real subprocesses, so a full run takes seconds; `--group` keeps
targeted runs fast.
@@ -0,0 +1,244 @@
# ADR-373: Host device access layer — ESP32, mmWave, LiDAR
- **Status**: proposed
- **Date**: 2026-09-30
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: hardware, esp32, mmwave, lidar, serial, udp, mcp, least-authority
- **Related**: ADR-018 (CSI frame), ADR-063 (mmWave fusion), ADR-320
(sensor HAL), ADR-369 (npm operator surface), ADR-370 (flashing),
ADR-374 (remote hosts), `integrations/iphone-lidar`
## Context
Operators and agents need to answer basic hardware questions, such as "is the
node streaming?", "is the radar talking?" and "does the LiDAR see the room?",
before calibration, fusion or training. Until now each modality had its own
tool:
- the ESP32 serial monitor;
- `scripts/mmwave_fusion_bridge.py`;
- the iPhone LiDAR web relay;
- the sensing server itself.
None of these was reachable through the npm harness, its MCP server or its
SDK.
The package must stay free of runtime dependencies (ADR-263). Node has no
serial API. Hardware access is also authority: an MCP client should not get
it by default.
## Decision
Add `src/devices/` to `@ruvnet/ruview` (0.7.0) with four read-oriented tools.
| Tool | Transport | What it returns |
|---|---|---|
| `ruview_devices_scan` | pyserial port list | ports with USB VID:PID, bridge chip, likely roles (esp32 / mmwave / rplidar), and the command that confirms each role |
| `ruview_esp32_capture` | `node:dgram` UDP, receive-only | per node: packet counts by kind (magics `0xC5110001`–`0xC5110007`), CSI rate, sequence loss, RSSI, antennas/subcarriers/frequency, latest device vitals |
| `ruview_mmwave_read` | serial | MR60BHA2 (60 GHz, 115200) and LD2410 (24 GHz, 256000) frames, checksum errors, presence, distance, device-reported heart and breathing rate |
| `ruview_lidar_read` | serial or WebSocket | RPLIDAR SCAN summary (points, revolutions, range, angular coverage), or iPhone relay depth statistics |
### Rules
1. **Firmware-identical parsing.**
- The MR60BHA2 and LD2410 parsers are ports of
`firmware/esp32-csi-node/main/mmwave_sensor.c`: same SOF, `~XOR` header
and data checksums, 30-byte payload cap, and F4F3F2F1…F8F7F6F5 framing.
- ESP32 packets are parsed as the little-endian structs in `csi_collector`
and `edge_processing.h`.
- Host and node therefore agree on what a valid frame is.
2. **Serial without dependencies.** A fixed Python script (the pyserial
"pump") reads the port for a bounded time and number of bytes.
- Port, baud, duration and command bytes are passed as argv and validated
first: the port regex, a fixed baud set, and command bytes of at most 64
hex bytes.
- Output is lowercase hex, so the secret-redaction layer cannot corrupt
binary data.
- DTR control is best-effort and reported when a port does not support it.
3. **Receive-only network.**
- The ESP32 capture binds a local UDP port (bind address from a fixed set,
port ≥ 1024) and never sends to a node.
- `EADDRINUSE` is reported as "sensing server already bound".
4. **Minimal actuation.** An RPLIDAR scan sends `A5 20` (start) and `A5 25`
(stop), and drives DTR low (the A1 motor). It spins the motor but writes no
configuration. Nothing else is written to any device.
5. **No raw sensor data in results.**
- iPhone depth frames are validated with the same rules as
`integrations/iphone-lidar/web/codec.mjs` and reduced to statistics.
- The relay token comes only from `RUVIEW_LIDAR_TOKEN`. It is rejected in
the URL and never echoed.
6. **Authority.**
- All four tools require the new `device-access` MCP grant.
- The CLI and SDK run them directly, as trusted local code.
- Serial port names are pattern-checked in the schema and again in the
driver. `/dev/pts/*` is deliberately excluded: it could read another
terminal's input.
7. **Evidence.**
- A successful read is tagged `MEASURED` as a *reading* on this host.
- mmWave vitals are labelled as the radar's own estimates.
- None of these outputs is an accuracy claim.
## Evidence
Development container, Linux x64, Node 22. No physical sensors were attached.
- **ESP32 UDP**: a simulated node sent 60 ADR-018 CSI frames plus 3 vitals
packets to `127.0.0.1:5005` through `ruview esp32`.
- Result: 1 node, CSI rate 15 Hz, zero sequence loss, vitals decoded
(15.2 bpm / 69 bpm, SYNTHETIC).
- Unit test on real loopback: 2 dropped sequence numbers out of 7 produce a
loss fraction of 0.2857.
- **MR60BHA2**, emulated on a pseudo-terminal and read through real pyserial:
- auto-detected `mr60bha2`;
- 200 frames, 0 checksum errors;
- presence 1.0, distance 87 cm, 14.8 / 68.5 bpm (the emulator's values,
SYNTHETIC).
- **RPLIDAR**, emulated on a pseudo-terminal that answers `A5 20`:
- 3564 points, 99 revolutions, full angular coverage;
- the missing DTR support was reported.
- **iPhone LiDAR**, the real relay (`integrations/iphone-lidar/web/relay.mjs`)
with a simulated phone:
- 30 frames at 10 fps, median depth 1.824 m (SYNTHETIC);
- a wrong token gives `connect_failed` with a remedy.
- **Not yet done**: real ESP32-S3/C6, MR60BHA2, LD2410, RPLIDAR and iPhone
captures. Each modality needs one real capture before its parser is called
hardware-validated.
## Amendment 1 (2026-10-01): e2e suite and latency
- `harness/ruview/test/e2e/devices.e2e.mjs` (`npm run test:e2e:devices`, CI
workflow `ruview-device-e2e.yml`) automates the emulated-device runs above
through the real CLI:
- MR60BHA2 and RPLIDAR on pseudo-terminals, read via real pyserial;
- an ESP32 UDP node;
- the real iPhone relay.
- The harness still refuses `/dev/pts/*` port names. The suite links each pty
to `/dev/ttyRUVIEWE2E<n>` with root or `sudo -n`.
- `RUVIEW_E2E_REQUIRE=1` turns missing prerequisites into failures.
- mmWave auto-detect keeps its probe frames (≤ 2 s per model) and reads only
the remainder of the window. A 5 s request previously cost about 9 s.
- `ruview_lidar_read` (iPhone) gains `max_frames` and returns immediately on
a refused connection.
- MEASURED wall time for the device e2e suite on the development container:
19.6 s before these changes, 5.6 s after (concurrent cases, no redundant
re-reads).
## Amendment 2 (2026-10-01): Realtek nodes, honest capture, live analysis
Driven by a hardware run of this branch against an RTL8721Dx board (PL2303GC,
COM10) and an ESP32-C6 (CP210x, COM16) on Windows 11:
- **Realtek RAC1/RHB1 (ADR-323).** `ruview_esp32_capture` decodes the 49-byte
RAC1 envelope (8- and 16-bit tones, CRC-32 verified, SYNTHETIC flag kept)
and counts RHB1 heartbeats per sender. Before this, 3,439 live RAC1 frames
in 15 s were all reported as unknown.
- **No false success.** The capture now fails with `no_decodable_packets`
or `heartbeat_only` when packets arrive but none decode. It previously
returned `ok: true` with a MEASURED label for zero decoded packets.
`heartbeatOnlySenders` names boards that are alive but deliver no CSI — the
stall observed on a Realtek board before a reset.
- **More ESP32 packet kinds.** ADR-110 sync (`0xC511A110`) and ADR-081 mesh
envelopes (`0xC5118100`, counted by message type, never a phantom node).
A node sending several CSI shapes (the C6 sends 1x256, 1x64 and 1x128)
reports each in `csiShapes` instead of only the last one.
- **Live CSI → kernel.** `--analyze` runs the busiest (or `--node-id`) node's
dominant single-antenna shape through `@ruvnet/ruview-kernel` at the
measured arrival rate, with a bounded frame buffer (`analyze_max_frames`,
default 6,000). Results are signal-processing estimates with no reference.
- **Serial monitor no longer reboots nodes.** The monitor opens the port with
DTR/RTS deasserted. MEASURED on the C6: uptime kept rising across a monitor
run (1,612 s → 1,625 s); before, opening the port reset it (uptime ~15 s).
It takes `--baud` (Realtek logs at 1,500,000) and explains the C6/S3
USB-Serial/JTAG console when the UART is silent.
- **Device scan.** Prolific `067B:23A3` (PL2303GC) is classified `realtek`.
- **Windows.** The hosts file gets an owner-only ACL (`icacls`), failing
closed; POSIX `0o600` is ignored on Windows. The symlink-escape test skips
only where Windows refuses to create symlinks.
- **Parser speed.** Int8Array view and `sqrt` instead of per-byte reads and
`Math.hypot`: MEASURED 0.42–0.52 M → 1.33–1.67 M packets/s (about 3.2x;
three interleaved runs, median of seven each, Windows 11 x64, Node 24).
Hardware evidence (MEASURED, this host): C6 node 42, 45 s, 1,160 packets,
1,157 decoded, 0 sequence loss, 541 1x256 frames at 12.1 Hz analyzed by the
napi kernel (integrity verified). The RAC1 decoder is cross-checked against
the Rust `realtek-csi-sim` encoder (200/200 frames at 8- and 16-bit tones);
a live RAC1 re-run is pending reconnection of the Realtek board.
Real flash (MEASURED, same C6, COM16): `ruview flash --variant c6 --confirm`
with the checksum-verified `release_bins/c6-adr110` bundle wrote 4 images
(hash verified by esptool, NVS kept: node 42 rejoined). The boot log showed
13 CSI callbacks, the MGMT+DATA upgrade and no panic. After boot, `monitor`
saw CSI without a reset (uptime 36 s → 62 s) and the UDP capture decoded
183/183 packets with zero loss. The run exposed one more Windows bug, now
fixed: the boot-log capture wrote decoded text to a cp1252 stdout (the
scrubbed child env) and aborted on the firmware's U+2192 log character, so
`bootLog.captured` was false and an earlier abort would have hidden the CSI
evidence. It now writes raw bytes; a regression test forces cp1252.
Live RTL8721Dx re-run (MEASURED, board 4 on COM10, node 3, alongside the C6):
`devices` classified COM10 as `realtek`; a 20 s capture decoded 7,404/7,404
packets (6,959 RAC1 frames at 348 Hz, RSSI −38.9 dBm, 200 RHB1 heartbeats);
`monitor --baud 1500000` saw 62 CSI log lines and did not reset the board
(sequence kept rising, 80,856 → 88,257). `--analyze --node-id 3` ran 12,549
live frames at a measured 313.7 Hz through the WASM kernel (integrity
verified). The board also exposed a loss-accounting bug carried over from the
original code: one stray sequence value (+52,835, then straight back) was
counted as 52,834 lost frames, giving 97% "loss". The tracker now treats a
single out-of-window value as a stray, needs two agreeing frames to accept a
counter reset, and lets a late packet fill its own gap. Against ground truth
(unique sequence numbers over the span, 15 s): 4,783/4,783 present, true loss
0, 9 strays — the tracker reported loss 0 and 9 strays.
## Amendment 3 (2026-10-01): ESPHome radar kits and CSI buffer starvation
- **ESPHome source for radar.** The Seeed MR60BHA2 kit (a XIAO ESP32-C6 with
the 60 GHz radar) runs ESPHome, which owns the radar UART. Raw frames never
reach USB, so `mmwave --model auto` correctly returned `no_valid_frames`.
`ruview_mmwave_read` now takes `source: "esphome"` with a `host`.
- **Client:** a dependency-free, read-only client for the plaintext ESPHome
native API (TCP 6053) runs hello → connect → device info → list entities
→ subscribe states for a bounded window. It never sends a command.
- **Roles:** presence, heart, breathing, distance and target count are
found by entity name. The result uses the same summary fields as the
serial path.
- **Decoding:** ESPHome's `missing_state` flag and proto3 zero defaults are
honoured, so absent readings are excluded rather than averaged as 0.
- **Address policy:** hosts must resolve to private, link-local, CGNAT
(Tailscale) or loopback addresses.
- **Unsupported cases:** encrypted (Noise) and password-protected APIs are
reported as unsupported.
- **Live kit (MEASURED, 192.168.1.102, ESPHome 2026.9.0, project
`seeedstudio.mr60bha2_kit` `spaces-1.0`).**
- The kit had been configured for an out-of-range SSID. It was moved to
the operator's network through its own captive portal, without
reflashing.
- A 15 s read returned 154 state updates:
- presence: detected, 1 target;
- mean distance: 40.0 cm;
- device-reported heart rate: mean 75.3 bpm;
- device-reported breathing rate: mean 10.7 bpm.
- These are firmware-computed values, not validated against a reference.
- **Realtek heartbeat-only stall: root cause.** Reading the stalled board's
console without a reset showed continuous `[WLAN-W] lack of csi buf!` /
`csi buf not enough`.
- The radio produces CSI reports, but the firmware does not return report
buffers, so CSI stops while heartbeats continue.
- `ruview monitor` now counts these lines and fails with
`csi_buffer_starvation`. Observed live: 399 lines in 8 s, 0 CSI.
- The lasting fix belongs in the RTL8721Dx firmware (outside this
repository).
- **Views.** Radar results render in the terminal UI and in the console
widget (`ruview_mmwave_read` is now a UI tool). Both label vitals
"device-reported, not validated".
## Consequences
- One command per modality works across CLI, MCP and SDK, with actionable
failures (`pyserial_missing`, `port_open_failed`, `no_valid_frames`,
`port_in_use`, `connect_failed`).
- Python and pyserial become the serial dependency. `doctor` reports both.
- The package's unpacked-size ceiling rises to 320 KiB (from 224 KiB) in
both npm workflows, with the published package still free of runtime
dependencies.
- Follow-up: feed these readers into `ruview-hal` observations (ADR-320)
for fusion.
@@ -0,0 +1,78 @@
# ADR-374: Remote host access over SSH (read-only)
- **Status**: proposed
- **Date**: 2026-09-30
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: remote, ssh, hardware, least-authority, mcp
- **Related**: ADR-369, ADR-373
## Context
RuView hardware usually hangs off a different machine from the one the
operator or agent works on: a Raspberry Pi next to the ESP32s, a NUC running
the sensing server, a Cognitum Seed. Cloud agent sessions cannot reach local
USB at all.
"Host access" should not mean opening a new network service with its own
credentials: that adds attack surface and secret handling. Operators already
have SSH.
## Decision
1. **Where work runs.**
- Hardware tools always execute on the machine the hardware is attached
to.
- Interactive work runs in a local Claude Code session on that machine. It
can be driven from the Claude app through Remote Control
(`claude remote-control` in the repo).
- Other machines are reached over SSH.
2. **Hosts registry.**
- Stored in `~/.config/ruview/hosts.json` (directory 0700, file 0600).
- Written only by the CLI: `ruview hosts add --name --ssh [--port]
[--version]`. MCP can list hosts but never add them.
- Names must match `^[a-z0-9][a-z0-9-]{0,31}$`. Targets must be
`[user@]hostname`: no options, spaces, shell metacharacters or leading
dash.
3. **Transport.**
- Local ssh arguments: `ssh -o BatchMode=yes -o ConnectTimeout=10 -o
StrictHostKeyChecking=yes -T [-p N] -- <target> <command>`.
- No password prompts, and no trust-on-first-use from the tool. The
operator pins the host key once interactively.
- The remote command is `npx -y @ruvnet/ruview@<exact version> call <tool>
--read-only --args-json <json>`, with every token single-quoted for the
remote POSIX shell.
4. **Read-only, twice.**
- Locally, `ruview_host_run` accepts only tools that are read-only, use no
credentials, and are not `ruview_host_*` (no host hopping). It validates
arguments against the tool schema before sending.
- Remotely, `call --read-only` repeats the check and schema validation.
- Flash, train, calibrate and Cognitum Spaces reads are never forwarded.
5. **Grants.** Over MCP, `ruview_host_run` needs `remote-host` plus the
remote tool's own grant (for example `device-access`).
6. **Failures** are classified with remedies: `host_key_unverified`,
`ssh_auth_failed`, `host_unreachable`, `remote_node_missing`,
`unknown_host`, `remote_tool_not_allowed`.
## Evidence
Tests in `harness/ruview/test/remote.test.mjs` cover:
- host-entry injection cases (`-oProxyCommand=…`, `;`, spaces, `$(…)`,
backticks);
- quoting, verified by running the quoted command through a real `sh`;
- the refusal list;
- grant composition;
- the file mode;
- `call --read-only` refusing `ruview_node_flash` through the real CLI.
No SSH server was available in the development container, so a live
round-trip to a real host is still to be done.
## Consequences
- Remote hosts get the same tools with no new daemon, port or credential
store.
- The remote needs Node 20+ and npm registry access (or a pre-installed
version), and `ssh` must be on the local PATH. `doctor --group remote`
checks the local side.
- Remote mutations stay manual by design.
@@ -0,0 +1,202 @@
# ADR-375: MCP Apps console, HTTP transport and terminal UI for `@ruvnet/ruview`
- **Status**: proposed
- **Date**: 2026-10-01
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: npm, mcp, mcp-apps, chatgpt, ui, cli, http, least-authority, performance
- **Related**: ADR-263 (harness hardening), ADR-265 (distribution and size
budget), ADR-369 (operator surface and SDK), ADR-373 (device access),
ADR-374 (remote hosts)
## Context
The harness exposes one tool registry through the CLI, an MCP stdio server and
an SDK (ADR-369). Hardware testing of the device tools (ADR-373 Amendment 2)
showed three gaps in how results reach people and models:
- **MCP results were text only.** Every tool returned pretty-printed JSON in a
text block, with no `structuredContent`. The server declared protocol
`2024-11-05`, and `resources/list` was always empty, so MCP Apps hosts and
ChatGPT had nothing to render.
- **ChatGPT could not connect.** ChatGPT connectors reach MCP servers over
HTTPS. The harness spoke stdio only.
- **The CLI printed raw JSON to humans.** A live capture of two nodes was a
1.2 kB JSON object in the terminal, with alerts such as heartbeat-only
senders easy to miss.
Reference implementations for the target hosts (the `web-based-chatgpt-mcp-starter`
and `signal-to-swarm` sites) declare a `ui://` HTML resource and point UI tools
at it through both the MCP Apps key (`_meta.ui.resourceUri`) and the ChatGPT
keys (`openai/outputTemplate`, `openai/widgetAccessible`).
The package must stay free of runtime dependencies (ADR-263) and inside a
reviewed size budget (ADR-265).
## Decision
### 1. One console widget, two host families
`src/ui/console-widget.js` holds one self-contained HTML resource,
`ui://ruview/console-v1.html`, with mimeType `text/html;profile=mcp-app`.
- **Tools.** `ruview_esp32_capture`, `ruview_devices_scan` and `ruview_doctor`
carry `_meta.ui.resourceUri`, `ui/resourceUri`, `openai/outputTemplate` and
`openai/widgetAccessible`.
- **Data intake.** The widget reads results from either host family:
- MCP Apps: a `ui/initialize` handshake over `postMessage`, then
`ui/notifications/tool-input` and `ui/notifications/tool-result`;
- ChatGPT: `window.openai.toolOutput` and the `openai:set_globals` event.
- **Refresh.** A Refresh button re-runs the same tool with the same
arguments, through `window.openai.callTool` or an MCP Apps `tools/call`
request.
- **Safety.**
- The resource declares an empty CSP allowlist and makes no network
requests.
- Every value reaches the DOM through `textContent`; there is no
`innerHTML`.
- Messages are accepted only from the parent frame.
- A test pins all of this.
- **Design.** The visual language follows the reference sites: dark ground,
`#d0ff72` accent, monospace labels, and plain wording about what each value
is. Device vitals are labelled "device-reported (not validated)". Kernel
outputs carry the "no reference measurement" note, and SYNTHETIC frames
raise a warning.
### 2. Structured results and protocol negotiation
- **Protocol negotiation.** `initialize` negotiates `2025-06-18`,
`2025-03-26` or `2024-11-05`, and advertises `resources`.
- **Result shape.** `tools/call` returns `structuredContent` (always an
object), a text block with the same JSON in compact form, and
`_meta['ruview/tool']` set to the canonical tool name (so an aliased call
still refreshes correctly).
- **Compact text.** Compact rather than pretty-printed JSON cuts each result
by 17–33% (MEASURED on this host: guidance 17.5%, device scan 20.7%, live
capture 33.4%). Clients that read only `content` still receive the
complete result.
- **Titles.** Tools gain `title` and `annotations.title`.
`handleRpc` is transport-neutral, and `createDispatcher` holds the FIFO tool
chain both transports share (ADR-263 O2).
### 3. Streamable HTTP transport
`ruview mcp start --http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]`
starts `src/mcp-http.js`.
- **Authentication on every request.** A request must carry either
`Authorization: Bearer <token>`, or the secret-path form `/mcp/<token>`.
The second exists because ChatGPT connectors cannot send a static header.
- Tokens are compared in constant time and must be at least 16 characters.
- The token comes from `RUVIEW_MCP_TOKEN`, never argv. If unset, one is
generated and printed once to stderr.
- **Least authority.**
- It binds to loopback by default.
- Browser `Origin`s are refused unless allowlisted (DNS-rebinding defence).
Server-to-server clients send no Origin.
- `workspace-write` and `hardware-write` are never honoured over HTTP, even
when granted in `RUVIEW_MCP_GRANTS`. Flashing and calibration stay
stdio/CLI-only.
- **Protocol surface.**
- POST only; single JSON responses with no SSE stream and no sessions.
- Batches and unsupported `MCP-Protocol-Version` values are refused.
- Bodies are capped at 256 KiB.
- Notifications return 202.
- `/healthz` reports only the name and version.
Exposing the port to ChatGPT (a tunnel or reverse proxy with TLS) is the
operator's choice. The startup banner says so and warns on a non-loopback
bind.
### 4. Terminal UI
`src/cli-ui.js` renders results for a TTY:
- aligned tables, with unicode rate bars;
- colour that respects `NO_COLOR` and `TERM=dumb`;
- alerts and fixes ahead of the data;
- a stderr countdown for timed captures.
Pipes and `--json` keep receiving exact JSON. `ruview call` always prints
JSON, because remote hosts parse it.
`ruview esp32 --watch [--seconds 3]` redraws repeated capture windows with a
per-node rate sparkline.
`ruview monitor` now flags Ameba CSI report-buffer starvation (`lack of csi
buf` / `csi buf not enough`). This is the root cause of the Realtek
heartbeat-only stall, observed live: 399 starvation lines in 8 s, 0 CSI.
### 5. Startup
- **Launcher split.** `bin/cli.js` is a thin launcher, and `src/cli.js` loads
the tool registry, doctor, brain, host adapters and transports on demand.
- **Measured effect.**
- `--help`/`--version`/`skills` skip the registry: about 20% faster, median
of 9 interleaved runs against the previous build on this host (MEASURED).
- The MCP path needs the whole registry to list tools, so its start time is
unchanged within noise.
- **Compile cache rejected.** Node's on-disk compile cache
(`module.enableCompileCache`) was evaluated: module load was 27.7 ms with
it and 28.5 ms without (25 interleaved runs, MEASURED). That is within
noise, so it was not adopted.
### 6. Size budget
The additions cost about 38 kB:
- widget, 12.6 kB;
- HTTP transport;
- terminal UI;
- CLI split.
The `@ruvnet/ruview` unpacked budget rises from 320 KiB to 384 KiB in
`npm-packages.yml` and `ruview-npm-release.yml`. The package is about 338 kB.
## Evidence
All on Windows 11 x64, Node 24, with an ESP32-C6 (CP210x, COM16) and an
RTL8721Dx (PL2303GC, COM10) attached. All results MEASURED unless noted.
**Browser e2e.** A real MCP Apps host flow in headless Chrome:
- The host lists tools and reads the `ui://` resource over HTTP MCP, then
loads it in a sandboxed iframe (`allow-scripts` only).
- It answers `ui/initialize` and delivers a live `ruview_esp32_capture`
result.
- The widget's Refresh button triggered a second real capture through the
host (108 → 105 packets).
- The devices and doctor views rendered, in dark and light themes. The only
console error was the test host's own missing favicon.
**Security probes** against the live HTTP server:
- no token: 401;
- wrong token: 401;
- foreign Origin: 403;
- GET: 405;
- `ruview_node_flash` with `hardware-write` granted in the environment:
`authority_denied`.
**Tests.**
- `npm test`: 160 pass, 1 skip.
- `test:security`: 83 pass, 1 skip.
- New suites: `mcp-apps.test.mjs` (protocol, metadata, widget safety, HTTP
auth/origin/size/write denial) and `cli-ui.test.mjs` (renderers, colour
rules, the starvation detector run against a stand-in serial module).
**Tarball smoke** from a clean install:
- CLI and SDK work;
- stdio MCP negotiates `2025-06-18` and serves the widget and structured
results;
- HTTP MCP lists 22 tools, 3 with `ui://` metadata.
## Consequences
- MCP Apps hosts and ChatGPT render node streams, device scans and
diagnostics instead of raw JSON, and can refresh them in place.
- Remote MCP clients can use the read tools over authenticated HTTP. Write
tools remain local by construction.
- Each tool result costs fewer model tokens.
- The widget is a second place where result shapes are interpreted. Changes
to the capture, devices or doctor result shapes must update it, and its
version suffix (`console-v1`) changes when the contract does.
- Host behaviour for MCP Apps and ChatGPT keys is followed as documented by
the reference implementations. A live ChatGPT connector session (public
HTTPS URL) has not been run yet: CLAIMED, pending.
@@ -0,0 +1,142 @@
# ADR-376: `ruview` — one npm install for every RuView component
- **Status**: proposed
- **Date**: 2026-10-01
- **Deciders**: rUv (requested and approved the release); RuView maintainers
- **Tags**: npm, packaging, umbrella, mcp, homecore, kernel, release
- **Related**: ADR-263 (harness hardening), ADR-265 (distribution, D2 CI-only
publishing, D4 bin ownership), ADR-285 (Homecore metaharness), ADR-368
(kernel), ADR-369 (operator surface), ADR-373 (device access), ADR-375 (MCP
Apps console, HTTP transport, terminal UI)
## Context
RuView is positioned as an ambient intelligence platform rather than a sensor.
Its sensing is ahead of comparable packaged options; packaging is the
bottleneck. Today the capabilities are split across three packages:
| Package | Contents | Published before this ADR |
|---|---|---|
| `@ruvnet/ruview` | operator harness | 0.5.1 |
| `homecore` | Homecore developer metaharness | never |
| `@ruvnet/ruview-kernel` | WASM vitals kernel | never |
The unscoped name `ruview` was unclaimed. A user has to know all three
packages, install them separately, and run three CLIs and two MCP servers.
## Decision
1. **The umbrella.** Publish an unscoped `ruview` package
(`harness/ruview-umbrella`). It depends on exact versions of the three
component packages and adds no capability of its own:
- **CLI.** One binary, `ruview`:
- `ruview homecore …` → the Homecore CLI;
- `ruview kernel <doctor|info|selftest|parity|synth|analyze|bench>` → the
kernel CLI;
- bare `ruview kernel [--backend …]` keeps its harness meaning (the
SYNTHETIC self-test);
- `ruview capabilities` lists every component, version and tool;
- everything else goes to the `@ruvnet/ruview` CLI unchanged.
- **MCP server.** One server, `ruview mcp start [--http]`. It serves the
harness tools (`ruview_*`, including the `ui://` console) plus the
Homecore tools (`homecore_*`, already prefixed, so no collision).
- Non-Homecore traffic goes to the harness handler, so the harness
authority model, grants and HTTP rules (ADR-375) apply unchanged.
- Homecore calls go to Homecore's `runTool`, with Homecore's own
policy.
2. **Bin ownership (amends ADR-265 D4).** The `ruview` command name moves to
the umbrella. `@ruvnet/ruview`'s bin is renamed `ruview-harness`.
- **Why:** a clean-directory tarball install showed the collision. With
both packages declaring `ruview`, `node_modules/.bin/ruview` linked to
the harness CLI, so `ruview capabilities` and `ruview homecore` failed.
Which package wins that link is not guaranteed.
- **Unaffected:** `npx @ruvnet/ruview …` still works, because npx runs a
package's only bin whatever its name. The umbrella delegates every
non-umbrella verb to the harness, so `ruview <verb>` behaves as before.
- **Changed:** a global install of `@ruvnet/ruview` alone now provides
`ruview-harness`.
3. **Harness changes needed to embed it** (no new runtime dependencies):
- **New exports:** `./cli`, `./cli-ui`, `./mcp`, `./mcp-http`, `./ui`,
`./esphome` and `./kernel`.
- **Injectable MCP handler:** `run(args, { mcpHandler })` and
`startMcpServer({ handler })`, so the merged server reuses the stdio and
HTTP transports.
- **Kernel declared as an optional peer:** `@ruvnet/ruview-kernel`.
- **`setKernelImporter`:** lets the umbrella resolve the kernel from its
own dependency tree. Node resolves bare specifiers from the importing
file's real path, which misses a sibling dependency under linked or
pnpm-style installs.
- Found while testing: `kernel_not_installed` through a linked umbrella.
- Only embedding code can call it; MCP arguments cannot choose the
module.
- **Homecore** gains a `./cli` export; **the kernel** gains `./cli`.
4. **Versions for this release.**
| Package | Version | Notes |
|---|---|---|
| `@ruvnet/ruview` | 0.8.0 | 0.7.0 was never published |
| `homecore` | 0.1.0 | first publish |
| `@ruvnet/ruview-kernel` | 0.1.0 | first publish; WASM only |
| `ruview` | 0.8.0 | umbrella |
The kernel ships WASM only. The napi backend stays optional and absent,
because a workstation build would cover only win32-x64. `loadKernel({
backend: 'auto' })` reports the honest fallback.
5. **Publishing exception (one-off, amends ADR-265 D2 for this release).**
- rUv explicitly chose a local `npm publish` from the operator
workstation (ruvzen) over the CI-only release workflow.
- Packages are published in dependency order: kernel → homecore →
@ruvnet/ruview → ruview.
- Each is published only after its full gate passes locally (tests,
security tests, manifest, version-literal and pack-content/size gates),
plus a clean-directory tarball install and smoke test of the umbrella.
- These versions carry no npm provenance attestation.
- Subsequent releases return to the CI workflow. Adding `ruview` and
`@ruvnet/ruview-kernel` to `ruview-npm-release.yml` is follow-up work.
## Release outcome (2026-10-01)
**Published from ruvzen** (exact tarballs that passed the clean-directory
smoke test; sha256 in the release thread):
| Package | Version |
|---|---|
| `@ruvnet/ruview-kernel` | 0.1.0 |
| `homecore` | 0.1.0 |
| `@ruvnet/ruview` | 0.8.0 |
All three were re-installed from the registry and smoke-tested:
- `npx -y @ruvnet/ruview@0.8.0` works;
- the harness self-test resolves the published kernel;
- a live ESPHome radar read succeeded.
**`ruview` was refused by npm:** `403 Forbidden — Package name too similar to
existing package iview` (typosquat protection).
- The umbrella is ready in `harness/ruview-umbrella`. Until npm support grants
the name, it is unpublished.
- If the request is declined, the fallback is a scoped name
(`@ruvnet/ruview-platform`).
## Consequences
- `npx ruview` is the single entry point for operators and agents. One MCP
connector gives an agent, or ChatGPT, every RuView and Homecore tool.
- The component packages stay independently installable and testable.
`@ruvnet/ruview` remains free of runtime dependencies.
- The umbrella pins exact component versions, so each component release needs
an umbrella release to reach `npx ruview` users.
- The local publish trades provenance for speed for this release only. The
exception is recorded here and in the #swarm coordination thread.
## Evidence
- **Umbrella tests:** 4/4, covering CLI dispatch to all three components,
merged MCP tool listing and routing, the harness authority model through
the merged server, and stdio and HTTP transports.
- **Component gates:**
- harness: 166 tests, 89 security tests;
- Homecore: 27 tests, 23 security tests;
- kernel: 21 tests, 3 skipped native cases; 12 security tests;
- all manifests verified, 0 audit findings.
- **Clean-directory tarball smoke:** recorded in the release commit and the
#development post.
@@ -0,0 +1,101 @@
# ADR-377: `ruview-live` — a Claude Code mod shipped inside `@ruvnet/ruview`
- **Status**: proposed
- **Date**: 2026-10-01
- **Deciders**: rUv (requested integration into the npm package); RuView maintainers
- **Tags**: claude-code, mods, function-hooks, ui, npm, least-authority
- **Related**: ADR-373 (device access), ADR-375 (MCP Apps console, terminal UI),
ADR-376 (umbrella package); Claude Code mods (function hooks): the
claude-code issue 91870 and the `mods/` built-ins (`diff` is the closest
analogue)
## Context
Claude Code mods are plugins whose behaviour is a function-hooks module: one
`register(on, options)` that hooks engine events as `($, e, next)`. They can:
- register slash commands;
- open panes beside the transcript and draw them with the surface's elements;
- set a status line;
- run processes through `$.process.run`.
That fits RuView's goal of putting the live state of the sensors where an
operator or agent already works.
RuView already has:
- a read-only CLI with `--json` results (ADR-373, ADR-375);
- an MCP server and the ChatGPT/MCP Apps console;
- a plugin marketplace in this repository.
## Decision
1. **Ship a mod, `ruview-live`, inside `@ruvnet/ruview`** (`mod/`, packaged
files `mod/.claude-plugin/`, `mod/hooks/`).
- It is listed in the repository marketplace as `ruview-live@ruview`, with
source `./harness/ruview/mod`.
- One copy serves both installs: the npm package and the marketplace.
2. **Behaviour.**
- **`/ruview`** toggles a pane (`ruview-live`). While it is open, a
`$.clock.every` timer (default 15 s, minimum 5 s) runs one read-only
capture.
- **`/ruview refresh`** runs one capture and returns the summary.
- **`/ruview off`** closes the pane.
- **The pane** shows nodes (rate, loss, RSSI, shape, SYNTHETIC flag), the
optional ESPHome radar (presence, distance, device-reported vitals), and
alerts, including heartbeat-only boards with the CSI-buffer-starvation
hint. It has Refresh (`r`) and Close (`c`) buttons.
- **The status line** is set on every refresh.
3. **Data comes only from the harness CLI beside the mod.**
- The mod runs `node <plugin root>/../bin/cli.js esp32 … --json` and
`mmwave --source esphome … --json`, as fixed argv arrays, through
`$.process.run`. So the pane shows what the tested tools return.
- Mods may import nothing but their own files and `claude-code` (the
validator refuses `node:url`). The CLI is therefore located through
`$.plugin.root`.
4. **Least authority.**
- The mod never runs a write verb (flash, calibrate, train). A unit test
pins the argv.
- The radar host is validated before use.
- Options are bounded: ports 1024–65535, refresh ≥ 5 s, capture 1–10 s.
5. **Discovery.** `ruview mod` (and `ruview mod --json`) prints the installed
path, the `claude --plugin-dir` command, the marketplace commands, usage,
settings, and the early-access note.
## Evidence
All on Claude Code 2.1.286, Windows 11 x64.
- **Validation:** `claude plugin validate harness/ruview/mod` passes. It lists
hooks `session.start`, `command.run{ruview}`, `ui.render{Pane}`,
`ui.close{ruview-live}` and `session.end`, and calls `$.clock.every`,
`$.clock.now`, `$.command.register`, `$.process.run` and `$.ui.*`.
`claude plugin validate .` passes for the marketplace.
- **Engine tests:** `claude plugin test harness/ruview/mod`, with
`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`, passed 2/2 in three consecutive
runs. They check:
- `/ruview` opens the pane and runs the exact read-only argv;
- the status reads `RuView · 2 nodes · 1 alert`;
- the timer refreshes while open and **never after close**;
- an honest `no_packets` status.
- **Harness unit tests:** `test/mod.test.mjs`, 6/6, under plain Node:
settings bounds, host validation, argv, failure parsing, model/status, pane
drawing and button wiring, and the no-imports rule.
- **Real engine, headless (MEASURED):**
`claude --plugin-dir ./mod -p "/ruview refresh"` answered
`RuView · 0 nodes · 1 alert` (no CSI node was streaming). With `radarHost`
set to the live Seeed MR60BHA2 kit, it answered
`RuView · 0 nodes · radar clear · 1 alert`.
- **Note:** under Git Bash a leading `/ruview` is rewritten into a Windows path
unless `MSYS_NO_PATHCONV=1` is set. This is not an issue inside the Claude
Code prompt.
## Consequences
- An operator sees node and radar health beside the conversation without
asking for it. Agents see the same facts through MCP.
- **Mods are early access.** The API may change between Claude Code releases,
and hooks modules load only where function hooks are enabled. The mod is
therefore optional, and nothing else in the package depends on it.
- **Trust.** Like every mod, it runs inside Claude Code with Claude Code's
access. It is read-only by construction, and the README says to install it
only from a trusted source.
- A refresh binds UDP 5005 for the capture window. While the sensing server
holds the port, the pane reports `port_in_use` instead of reading.
@@ -0,0 +1,144 @@
# ADR-378: `ruview-live` showcase views — CSI waterfall, radar fan, animated cells
- **Status**: proposed
- **Date**: 2026-10-01
- **Deciders**: rUv ("make this a showcase … the art of the possible for mods"); RuView maintainers
- **Tags**: claude-code, mods, ui, raster, animation, csi, radar, honesty
- **Related**: ADR-377 (the `ruview-live` mod), ADR-373 (device access),
ADR-375 (terminal UI), ADR-018 / ADR-323 (CSI formats)
## Context
ADR-377 shipped `/ruview`: a pane of cards built from text and sparklines. The
mod engine can do much more on the terminal surface:
- `Raster` is a grid of cells, each with a glyph and a 24-bit foreground and
background.
- `$.ui.blit` repaints a mounted Raster in place, up to 60 frames a second,
without redrawing the tree.
- Buttons take hotkeys.
The pane only showed summaries. It never showed the signal itself: per-subcarrier
CSI amplitude over time, or the radar's range as geometry.
## Decision
### Three views, keys `1` `2` `3`
| View | What it draws | Data |
|---|---|---|
| Overview | the ADR-377 cards | unchanged |
| CSI waterfall | subcarrier bins across, time down; two frames per text row through `▀` (foreground = upper frame, background = lower); 5th–95th percentile colour limits with a colour bar | `ruview esp32 --spectrum` (new) |
| Radar | top-down 120° fan: range rings, a fading trail of past distances, the current distance as an arc; braille line charts of heart, breathing and distance | ESPHome radar kit (ADR-373) |
`n` cycles nodes in the waterfall when more than one streams. The live views
refresh every `liveRefreshSeconds` (default 4, range 2–60); the overview keeps
`refreshSeconds`.
### Harness: `--spectrum`
`captureEsp32` and the `ruview_esp32_capture` tool take three new arguments:
- `spectrum`: boolean;
- `spectrum_bins`: 8–128, default 48;
- `spectrum_frames`: 8–256, default 64.
A `SpectrumCollector` keeps a ring of the newest frames for each node and CSI
shape. It averages each frame's amplitudes into equal bins, and reports the
dominant shape per node with its arrival rate and `synthetic` flag. The
collector is read-only and bounded (8 nodes), and it adds nothing to a capture
that does not ask for it.
### Animation with `$.ui.blit`
One 80 ms timer (about 12 fps) repaints only these keyed Rasters:
- **shimmer**: a highlight sweeping along the rule under the title. Pure
decoration.
- **pulse**: a ♥ that beats at the device-reported heart rate and a gauge that
fills and empties at the device-reported breathing rate. It is labelled *a
metronome, not a waveform*.
- **fan**: a sonar ring that leaves the sensor and reaches the measured range.
- **waterfall replay**: frames arrive a capture at a time, so the view drains
them at their measured arrival rate instead of jumping every refresh. It is
labelled *replayed at the arrival rate*.
The first paint and every frame come from the same function (`picturesOf`). A
test asserts that each picture keeps its size across frames, because a blit of
another size is refused.
Blits are fire-and-forget: a blit resolves only once a frame is painted, and
the surface folds blits between frames anyway.
### Honesty rules (CLAUDE.md)
- The waterfall says **MEASURED — live CSI amplitude received on this host** or
**SYNTHETIC — simulator frames**, from the packets' own flag.
- The radar view states *range only: this kit reports distance, not bearing*.
The arc covers every bearing at that range; nothing implies a position.
- Vitals stay *device-reported, not validated*. Values outside 40–180 bpm
(heart) or 4–40 bpm (breathing) are flagged.
- Chart scales fit the data so a rhythm is visible. The plausible band is
shaded only where it overlaps the scale.
- Surfaces without `Raster` (desktop, VS Code, mobile) get a note in the
showcase views instead of a tree the surface would refuse. The overview works
everywhere.
- Only nodes in the latest capture stay in the waterfall. A node that stops
streaming, or a failed capture, clears its frames, so old frames are never
shown as live. Frames join across captures only when the CSI shape and bin
count both match.
- Multi-antenna ESP32 nodes are drawn from their first chain. ADR-018 lays the
tones out antenna-major; this applies to the waterfall only, and the vitals
kernel still takes single-chain frames.
- Animation runs on the real clock, the same one the full render reads. The
pulses keep the reported rates even when a frame fires late.
### Structure
Mods may import only their own files, so the hooks module is split into:
- `model.mjs`: settings, argv, results, model and history (pure);
- `raster.mjs`: Grid, base64, colour map, waterfall, braille, radar fan, line
chart (pure);
- `anim.mjs`: frame functions of time (pure);
- `views.mjs`: drawing;
- `register.mjs`: wiring, timers and blits. It re-exports the rest so tests
import one module.
## Evidence
- **MEASURED** (real silicon, 2026-10-01):
- Hardware: an ESP32-C6 on COM11, flashed with the hardware-verified
`release_bins/c6-adr110` image (v0.7.0; SHA-256 checked) and provisioned to
ruv.net, node 6.
- Boot log: `Got IP: 192.168.1.103` and `UDP sender initialized:
192.168.1.45:5005`.
- Captures: `ruview esp32 --spectrum` received 29–50 frames per 2 s at 7–18
Hz and −37 to −78 dBm, in 1×64 and 1×256 shapes.
- Waterfall: a 1×256 capture rendered as 114 bins showing the occupied band
against guard and null tones.
- Reproducer: `npx @ruvnet/ruview esp32 --seconds 4 --spectrum --json` with
a node targeting the host.
- **MEASURED** (device-reported): the Seeed MR60BHA2 kit at 192.168.1.102 drives
the radar view.
- **SYNTHETIC**: the `realtek-csi-sim` simulator exercises the waterfall in
development; its frames carry the synthetic flag and the view says so.
- **Tests**:
- 11 mod tests and 11 showcase tests: Grid/base64 against Node's encoder,
colour map monotone, waterfall ordering, braille bits, fan scaling and
ping, chart scaling, frame functions, constant picture sizes, MEASURED and
SYNTHETIC labels, surfaces without Raster;
- a loopback test of `--spectrum`;
- 4 engine tests (`claude plugin test mod`), including the waterfall tab
sending `--spectrum`, drawing the Raster and animating it with blits.
## Consequences
- The package grows by about 37 KB. The reviewed unpacked ceiling rises from
384 KiB to 448 KiB in `npm-packages.yml` and `ruview-npm-release.yml`. There
are still no runtime dependencies.
- The live views poll more often: a 2 s capture every 4 s by default. A
capture binds the UDP port briefly, so a sensing server on the same port
still reports `port_in_use`, as in ADR-377.
- The animation timer runs only while the pane is open, and stops on close and
session end.
+16 -1
View File
@@ -2,6 +2,21 @@
Latest proposed decisions:
- [ADR-378: `ruview-live` showcase views — CSI waterfall, radar fan, animated cells](ADR-378-ruview-live-showcase-views.md)
- [ADR-377: `ruview-live` — a Claude Code mod shipped inside `@ruvnet/ruview`](ADR-377-ruview-live-claude-code-mod.md)
- [ADR-376: `ruview` — one npm install for every RuView component](ADR-376-ruview-umbrella-npm-package.md)
- [ADR-375: MCP Apps console, HTTP transport and terminal UI for `@ruvnet/ruview`](ADR-375-ruview-mcp-apps-console-http-transport-and-terminal-ui.md)
- [ADR-374: Remote host access over SSH (read-only)](ADR-374-remote-host-access-over-ssh.md)
- [ADR-373: Host device access layer — ESP32, mmWave, LiDAR](ADR-373-host-device-access-layer.md)
- [ADR-372: Structured debugging doctor](ADR-372-structured-debugging-doctor.md)
- [ADR-371: Harness training runner and mean-pose evidence gate](ADR-371-harness-training-runner-and-evidence-gate.md)
- [ADR-370: Cross-platform, verified ESP32 firmware flashing from npm](ADR-370-cross-platform-firmware-flashing.md)
- [ADR-369: npm RuView operator surface — CLI, MCP, and SDK](ADR-369-ruview-npm-operator-surface-and-sdk.md)
- [ADR-368: RuView compute kernel as a WASM + napi-rs npm package](ADR-368-ruview-kernel-wasm-napi-npm.md)
- [ADR-367: Bounded research swarm and compute ownership](ADR-367-bounded-research-swarm-and-compute-ownership.md)
- [ADR-366: CSI controls and frozen evaluation](ADR-366-csi-controls-and-frozen-evaluation.md)
- [ADR-365: Public BFI dataset and decoder contract](ADR-365-public-bfi-dataset-and-decoder-contract.md)
- [ADR-364: BFI capture admission and continuity](ADR-364-bfi-capture-admission-and-continuity.md)
- [ADR-187: archive/v1 deprecation + model-weights honest labeling](ADR-187-archive-v1-deprecation-honest-labeling.md) (refs #509, #1125)
- [ADR-186: Training progress API — wire the orphaned in-server trainer to /ws/train/progress](ADR-186-training-progress-api.md) (refs #1233)
- [ADR-185: Python P6 SOTA bindings — AETHER, MERIDIAN, MAT](ADR-185-python-p6-sota-bindings.md)
@@ -9,7 +24,7 @@ Latest proposed decisions:
- [ADR-264: Versioned wire protocol for RTL8720F CFR and Range-FFT reports](ADR-264-rtl8720f-radar-wire-protocol.md)
- [ADR-263: Adopt RTL8720F 2.4 GHz FMCW radar as an optional RuView sensing platform](ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md)
This folder contains 210 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
This folder records significant architectural choices in the RuView / WiFi-DensePose project. The index tables below list a curated subset per domain; see the directory listing for the full set.
## Why ADRs?
+166 -3
View File
@@ -26,7 +26,12 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
- [Cognitum Seed Integration (ADR-069)](#cognitum-seed-integration-adr-069)
5. [REST API Reference](#rest-api-reference)
6. [WebSocket Streaming](#websocket-streaming)
7. [Web UI](#web-ui)
7. [RuView npm toolkit (`@ruvnet/ruview`)](#ruview-npm-toolkit-ruvnetruview)
- [Hardware from the command line](#hardware-from-the-command-line)
- [MCP for agents, ChatGPT and MCP Apps](#mcp-for-agents-chatgpt-and-mcp-apps)
- [Claude Code mod: live sensing pane](#claude-code-mod-live-sensing-pane)
- [Homecore and the vitals kernel](#homecore-and-the-vitals-kernel)
8. [Web UI](#web-ui)
8. [Vital Sign Detection](#vital-sign-detection)
9. [CLI Reference](#cli-reference)
10. [Observatory Visualization](#observatory-visualization)
@@ -451,8 +456,8 @@ authority. Versioned collections are `sites`, `buildings`, `floors`,
The dependency-free contributor harness exposes the same read path:
```bash
npx @ruvnet/ruview@0.5.0 spaces --resource alerts --limit 25
npx @ruvnet/ruview@0.5.0 mcp start
npx @ruvnet/ruview@0.9.1 spaces --resource alerts --limit 25
npx @ruvnet/ruview@0.9.1 mcp start
```
Its MCP tool is `ruview_spaces_list`. MCP reads are OAuth-only, use the fixed
@@ -958,6 +963,164 @@ Source: [`tools/ruview-mcp/`](../tools/ruview-mcp/README.md). Tracking issue: [#
---
## RuView npm toolkit (`@ruvnet/ruview`)
`@ruvnet/ruview` is RuView's operator toolkit on npm. It has no runtime
dependencies and runs on Node 20+. It is tested on Windows and Linux. One tool
registry serves:
- a CLI;
- an MCP server (stdio, or HTTP for ChatGPT);
- a typed SDK;
- a Claude Code mod.
Every result is honest about what it is:
- **MEASURED** when it comes from live packets on your machine;
- **SYNTHETIC** for simulators and self-tests;
- **device-reported** for values a radar's own firmware computes, which are
not validated against a reference.
```bash
npx @ruvnet/ruview@0.9.1 --help
npx @ruvnet/ruview@0.9.1 doctor # what works on this machine, with fixes
```
In a terminal, commands print a formatted view. Pipes and `--json` print JSON,
for scripts.
### Hardware from the command line
Run these on the machine the hardware is plugged into.
```bash
# What is plugged in (USB VID:PID → ESP32, Realtek RTL8721Dx, radar, LiDAR)
npx @ruvnet/ruview@0.9.1 devices
# CSI nodes streaming to this machine (UDP 5005): ESP32 ADR-018 frames and
# Realtek RAC1 frames, per node rate, loss, RSSI and CSI shape
npx @ruvnet/ruview@0.9.1 esp32 --seconds 10
npx @ruvnet/ruview@0.9.1 esp32 --watch # live view, redrawn every few seconds
npx @ruvnet/ruview@0.9.1 esp32 --seconds 45 --analyze # live CSI through the vitals kernel
# A board's serial console, without resetting it
npx @ruvnet/ruview@0.9.1 monitor --port COM9
npx @ruvnet/ruview@0.9.1 monitor --port COM10 --baud 1500000 # Realtek Ameba
# 60 GHz / 24 GHz radar: raw UART, or an ESPHome kit on your network
npx @ruvnet/ruview@0.9.1 mmwave --port COM5 --model auto
npx @ruvnet/ruview@0.9.1 mmwave --source esphome --host 192.168.1.50
# Flash ESP32 firmware: plan first, then write with --confirm (records boot evidence)
npx @ruvnet/ruview@0.9.1 flash-plan --port COM16 --bundle firmware/esp32-csi-node/release_bins/c6-adr110 --variant c6
npx @ruvnet/ruview@0.9.1 flash --port COM16 --bundle firmware/esp32-csi-node/release_bins/c6-adr110 --variant c6 --confirm
```
**What the alerts mean:**
| Alert | Meaning |
|---|---|
| `heartbeat-only sender` | A board is alive but sends no CSI. On a Realtek board, run `monitor --baud 1500000`. If it shows `lack of csi buf`, the board ran out of CSI report buffers (`csi_buffer_starvation`). Reset it. |
| `port_in_use` | The sensing server already holds UDP 5005. Stop it, or capture on another port your nodes target. |
| `no_decodable_packets` | Packets arrived, but none were in a known RuView format. Check the sender. |
| `seqReordered` / `seqStrays` | Out-of-order or stray sequence numbers. These are not counted as loss. |
**ESPHome radar kits** (for example the Seeed MR60BHA2): the kit's firmware
owns the radar, so read it over the network with `--source esphome`.
- If the kit cannot join your WiFi, it starts a fallback hotspot. Join it and
set your network in its captive portal, at `http://192.168.4.1`.
- Only private-network addresses are accepted.
- Encrypted or password-protected ESPHome APIs are reported as unsupported.
### MCP for agents, ChatGPT and MCP Apps
```bash
# Claude Code, Codex or any MCP client (stdio)
claude mcp add ruview -- npx -y @ruvnet/ruview@0.9.1 mcp start
# HTTP, for ChatGPT and remote clients
export RUVIEW_MCP_TOKEN="$(node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))")"
RUVIEW_MCP_GRANTS=device-access npx @ruvnet/ruview@0.9.1 mcp start --http --port 8790
```
**Connecting ChatGPT.** Clients that can send headers use
`Authorization: Bearer <token>`. ChatGPT connectors cannot, so:
1. Expose the port over HTTPS, with a tunnel or reverse proxy.
2. Add `https://<your-host>/mcp/<token>` as the connector URL.
**Least authority:**
- Hardware tools need the `device-access` grant.
- The HTTP transport binds to localhost by default.
- It refuses unknown browser origins.
- It never allows flashing or calibration, whatever grants are set.
Node captures, radar reads, device scans and doctor results render in the
**RuView console**: a widget that ChatGPT and other MCP Apps hosts show inline,
with a Refresh button that re-runs the tool.
### Claude Code mod: live sensing pane
The package ships `ruview-live`, a Claude Code mod. `/ruview` opens a pane
beside the conversation, refreshed on a timer, showing:
- your CSI nodes;
- an optional ESPHome radar;
- alerts.
It also keeps a status line such as `RuView · 2 nodes · radar present`.
Keys `1`, `2` and `3` switch between three views (ADR-378):
- **Overview**: the cards.
- **CSI waterfall**: per-subcarrier amplitude over time, labelled MEASURED or
SYNTHETIC; `n` cycles nodes.
- **Radar**: a range fan with an animated ping, plus heart, breathing and
distance charts. The values are device-reported and not validated, and the
kit reports range, not bearing.
The two live views draw in terminal cells and animate in place.
```bash
npx @ruvnet/ruview@0.9.1 mod # prints the mod's path in your install and how to load it
claude --plugin-dir "<that path>" # try it for one session
# or from the RuView marketplace, inside Claude Code:
# /plugin marketplace add ruvnet/RuView
# /plugin install ruview-live@ruview
```
| In Claude Code | Does |
|---|---|
| `/ruview` | Open or close the pane |
| `/ruview refresh` | Run one capture now; the result goes in the status line |
| `/ruview off` | Close the pane |
| `/ruview waterfall` / `/ruview radar` | Open on that view |
**Settings** (`claude plugin configure ruview-live`): `udpPort`, `radarHost`,
`refreshSeconds`, `captureSeconds` and `liveRefreshSeconds` (live views,
default 4 s).
The mod only runs the package's read-only CLI commands. Mods are early access:
if `/ruview` is missing, start Claude Code with
`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`.
### Homecore and the vitals kernel
```bash
npx homecore@0.1.0 guidance --topic plugins --query Wasmtime # Homecore developer metaharness
npx @ruvnet/ruview-kernel@0.1.0 selftest # vitals pipeline as WASM (SYNTHETIC self-test)
```
`esp32 --analyze` uses the kernel when it is installed. Its heart and breathing
estimates carry "no reference measurement": they show the signal path works,
not that the readings are accurate.
Design records:
- [ADR-373](adr/ADR-373-host-device-access-layer.md): device access;
- [ADR-375](adr/ADR-375-ruview-mcp-apps-console-http-transport-and-terminal-ui.md):
MCP Apps console, HTTP transport and terminal UI;
- [ADR-376](adr/ADR-376-ruview-umbrella-npm-package.md): the one-install
`ruview` package, pending the npm name;
- [ADR-377](adr/ADR-377-ruview-live-claude-code-mod.md): the Claude Code mod.
- [ADR-378](adr/ADR-378-ruview-live-showcase-views.md): the mod's CSI waterfall and radar views (keys 1/2/3, `esp32 --spectrum`).
---
## Web UI
The built-in Three.js UI is served at `http://localhost:3000/ui/` (Docker) or the configured HTTP port.
+8
View File
@@ -362,6 +362,14 @@ The `MSYS_NO_PATHCONV=1` prefix prevents Git Bash from mangling the `/project` p
- `build/partition_table/partition-table.bin` -- flash partition layout
- `build/esp32-csi-node.bin` -- application firmware
### Release bundles
Firmware downloads are published from dedicated `vX.Y.Z-esp32` tags after all
S3 and C6 matrix builds pass. Each release contains separate S3 8 MB, S3 4 MB,
and C6 4 MB archives plus an archive checksum manifest. The repository's
automated `vNNNN` server releases (for example `v2655`) are not firmware tags;
do not use their presence or absence of assets as firmware provenance.
### Custom Configuration
To change Kconfig settings before building:
+42
View File
@@ -19,6 +19,14 @@
#include "nvs_config.h"
#include "thermal.h"
/* firmware/esp32-csi-node/partitions_4mb.csv has two 1.81 MB app slots and
* only 256 KB free -- no room for the FAT volume this module needs. The
* whole implementation below is compiled out on that profile; every entry
* point still links via the stubs in the #else branch so main.c's call
* sites need no #ifdef of their own. See partitions_display.csv (8MB) and
* partitions_16mb.csv for the profiles where this module is active. */
#ifndef CONFIG_ESPTOOLPY_FLASHSIZE_4MB
static const char *TAG = "node_log";
/* The FAT volume reserved in partitions_16mb.csv (storage, 8000K) and never
@@ -380,3 +388,37 @@ esp_err_t node_log_clear(void)
xSemaphoreGive(s_lock);
return err;
}
#else /* CONFIG_ESPTOOLPY_FLASHSIZE_4MB */
esp_err_t node_log_init(void) { return ESP_ERR_NOT_SUPPORTED; }
bool node_log_is_active(void) { return false; }
void node_log_boot(uint32_t reset_reason, uint32_t prev_uptime_s)
{
(void)reset_reason;
(void)prev_uptime_s;
}
void node_log_periodic(void) { }
void node_log_event(uint8_t subtype, int32_t a, int32_t b)
{
(void)subtype;
(void)a;
(void)b;
}
void node_log_note_disconnect(uint8_t reason, int8_t rssi)
{
(void)reason;
(void)rssi;
}
size_t node_log_read(uint8_t *out, size_t max_records, size_t skip)
{
(void)out;
(void)max_records;
(void)skip;
return 0;
}
size_t node_log_count(void) { return 0; }
uint16_t node_log_boot_id(void) { return 0; }
esp_err_t node_log_clear(void) { return ESP_ERR_NOT_SUPPORTED; }
#endif /* CONFIG_ESPTOOLPY_FLASHSIZE_4MB */
@@ -162,16 +162,9 @@ static void serial_onboarding_task(void *argument)
(void)argument;
const int original_flags = fcntl(STDIN_FILENO, F_GETFL, 0);
if (original_flags >= 0) (void)fcntl(STDIN_FILENO, F_SETFL, original_flags | O_NONBLOCK);
/* ESP-IDF secondary consoles mirror output but deliberately do not forward
* input to STDIN. Native S3/C6 USB Serial/JTAG therefore needs a bounded
* direct VFS reader while UART-console boards continue to use STDIN. */
const int usb_fd = open("/dev/secondary", O_RDONLY | O_NONBLOCK);
char primary_line[ONBOARDING_LINE_MAX];
char usb_line[ONBOARDING_LINE_MAX];
size_t primary_length = 0;
size_t usb_length = 0;
bool primary_overflow = false;
bool usb_overflow = false;
for (;;) {
uint8_t input[64];
const ssize_t count = read(STDIN_FILENO, input, sizeof(input));
@@ -182,12 +175,6 @@ static void serial_onboarding_task(void *argument)
ESP_LOGW(TAG, "serial input unavailable: errno=%d", errno);
vTaskDelay(pdMS_TO_TICKS(500));
}
if (usb_fd >= 0) {
const ssize_t usb_count = read(usb_fd, input, sizeof(input));
if (usb_count > 0) {
consume_bytes(input, usb_count, usb_line, &usb_length, &usb_overflow);
}
}
vTaskDelay(pdMS_TO_TICKS(20));
}
}
@@ -6,3 +6,4 @@ phy_init, data, phy, 0x11000, 0x1000,
ota_0, app, ota_0, 0x20000, 0x200000,
ota_1, app, ota_1, 0x220000, 0x200000,
spiffs, data, spiffs, 0x420000, 0x1E0000,
storage, data, fat, 0x600000, 0x200000,
1 # ESP32-S3 CSI Node — 8MB flash partition table (ADR-045)
6 ota_0, app, ota_0, 0x20000, 0x200000,
7 ota_1, app, ota_1, 0x220000, 0x200000,
8 spiffs, data, spiffs, 0x420000, 0x1E0000,
9 storage, data, fat, 0x600000, 0x200000,
@@ -1,4 +1,4 @@
126a4b9f305e207aa2e0db93caf09e27f77b3bee4b4f618a0cb963f10a4364bc bootloader.bin
183c4557a93f6f0be1680f45988b7c7320130c8e0ed5d9c900add038d5cc84fb esp32-csi-node.bin
12e9fe5bd0533c3eb8ebb1cc36f775668cc4dd39f334ea65e26529b87211dc4c bootloader.bin
baff3940ede0ce1658f2f5c3ba458cc9007753eb1519231d67b1ffa724382824 esp32-csi-node.bin
7d2c7ac4888bfd75cd5f56e8d61f69595121183afc81556c876732fd3782c62f ota_data_initial.bin
4c2cc4ffd52641e23b779bd57b3908014083ac3c1aab395756478c89e70d81f0 partition-table.bin
@@ -19,6 +19,12 @@ CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_4mb.csv"
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_ESPTOOLPY_FLASHSIZE="4MB"
# Native USB/JTAG is the interactive onboarding console on C6 boards. ESP-IDF
# secondary consoles mirror output only and cannot receive RUVIEW_HELLO_V1.
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
# CONFIG_ESP_CONSOLE_UART_DEFAULT is not set
# CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG is not set
# ── CSI (required) ──
CONFIG_ESP_WIFI_CSI_ENABLED=y
+7 -3
View File
@@ -55,7 +55,8 @@ FUZZ_JOBS ?= 1
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests \
test_thermal run_thermal test_csi_sanitize run_csi_sanitize \
test_c6_antenna run_c6_antenna test_serial_onboarding run_serial_onboarding
test_c6_antenna run_c6_antenna test_serial_onboarding run_serial_onboarding \
run_delivery_contract
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding
@@ -132,9 +133,12 @@ test_serial_onboarding: test_serial_onboarding_protocol.c $(MAIN_DIR)/serial_onb
run_serial_onboarding: test_serial_onboarding
./test_serial_onboarding
run_delivery_contract:
python3 test_delivery_contract.py
host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize \
run_c6_antenna run_serial_onboarding
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna + USB onboarding)"
run_c6_antenna run_serial_onboarding run_delivery_contract
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna + USB onboarding + delivery contract)"
# --- Serialize fuzzer ---
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Static delivery contracts that do not require an ESP-IDF toolchain."""
from pathlib import Path
import unittest
FIRMWARE_ROOT = Path(__file__).resolve().parents[1]
REPOSITORY_ROOT = FIRMWARE_ROOT.parents[1]
class FirmwareDeliveryContractTests(unittest.TestCase):
def test_c6_uses_native_usb_serial_jtag_as_primary_console(self) -> None:
config = (FIRMWARE_ROOT / "sdkconfig.defaults.esp32c6").read_text()
self.assertIn("CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y", config)
self.assertNotIn("CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y", config)
onboarding = (FIRMWARE_ROOT / "main/serial_onboarding.c").read_text()
self.assertNotIn('open("/dev/secondary"', onboarding)
def test_tracked_c6_onboarding_app_matches_version_txt(self) -> None:
expected = (FIRMWARE_ROOT / "version.txt").read_text().strip().encode()
app = (FIRMWARE_ROOT / "release_bins/c6-onboarding/esp32-csi-node.bin").read_bytes()
self.assertTrue(
expected + b"\0" in app,
f"tracked C6 app does not embed version {expected.decode()}",
)
def test_firmware_tags_publish_downloadable_release_assets(self) -> None:
workflow = (REPOSITORY_ROOT / ".github/workflows/firmware-ci.yml").read_text()
self.assertIn("publish-release-assets:", workflow)
self.assertIn("esp32-csi-node-firmware-*", workflow)
self.assertIn("softprops/action-gh-release@", workflow)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -32,7 +32,7 @@
"README.md": "7b4eda2e05ab35345e50ec8b8058c0bcbe0fc52e11e8b7447f3d8d231b4ca58d",
"bin/cli.js": "c463451f4cecebf308cbfce74c553ca44edb58462fe4e7bfacc49a3bf3aa0c49",
"brain/corpus/core.jsonl": "a01a42723490e8c4bcf4ceb4233e8115cb7e27fb9a17a802f279d565dc3493fb",
"package.json": "45d9e00dc059e84e2445e9b7a8131a04c4c0241b88908c5e849b964735833601",
"package.json": "b3614446b634a5b522484adb2d1292421c57ffdc57177b054cda759356d3f87c",
"scripts/update-manifest.mjs": "5dbebd536a65c88dd26f6830c5a4b62b3f9058d971304c3c6c410c3e0e78faaa",
"scripts/verify-manifest.mjs": "787351d57f452ee58e67675e09158f17e43adba643981f5148ac144cae17fd80",
"skills/explore.md": "4fccb5aab3705fd0d266ae7cb98523d71cce4eed4623e9c10e46fb2136b690e3",
@@ -54,7 +54,7 @@
"src/repo-trust.js": "c11b2255e43f7cba0c74c33e4a972f60193490821445dce8bab0f97dd47db977",
"src/tools.js": "1ad280eebca0688a52ae08fe5647c8e42a6a8e8e5d70534cc4361dc319c22fbf"
},
"filesDigest": "ad2340db9fc044ffe69d6a9b09a560515cee1d454a405db1b9cc3547afc868c1",
"filesDigest": "01b6331f69562fc216bec49a23ef318daf2e7fed76f3714c1db26c0ebe1c7f5f",
"brainDigest": "a01a42723490e8c4bcf4ceb4233e8115cb7e27fb9a17a802f279d565dc3493fb",
"policyDigest": "73893df248c9a8d79da5451940b40d6b930b9b92999b52ef8bd538c527cd8a47",
"dependencies": {
+1 -1
View File
@@ -1 +1 @@
1027780eb92030366cf78f50402234e7a039cce328163e6f0f0f70934284164e manifest.json
61e790efeca553f1c99d31b4492fe5f535afc99e4007b8209b723b7dda429fd2 manifest.json
+1
View File
@@ -8,6 +8,7 @@
},
"exports": {
".": "./src/tools.js",
"./cli": "./bin/cli.js",
"./brain": "./src/brain.js",
"./guidance": "./src/guidance.js",
"./hosts": "./src/hosts/index.js",
+9
View File
@@ -0,0 +1,9 @@
# Built by `npm run build` (ADR-368); never committed. CI builds, checksums,
# and packs them from source.
wasm/*.wasm
wasm/SHA256SUMS
native/*.node
native/SHA256SUMS
node_modules/
*.tgz
dist-pack/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ruvnet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+94
View File
@@ -0,0 +1,94 @@
# @ruvnet/ruview-kernel
RuView's Rust signal pipeline for JavaScript. The ADR-021 vitals pipeline
(`wifi-densepose-vitals`) is compiled to **zero-import WebAssembly** (default)
and, optionally, a **napi-rs** native addon. Both expose the same one-function
JSON ABI, so results match across backends (ADR-368).
> Outputs are signal-processing estimates, not clinical or camera-grade
> measurements. `synthesize()` data and every self-test number are
> **SYNTHETIC**.
## Build from a RuView checkout
```bash
rustup target add wasm32-unknown-unknown
cd harness/ruview-kernel
npm run build # wasm + napi for this platform, with SHA256SUMS
npm test # unit, security, and end-to-end tests
node bin/cli.js parity # wasm vs napi on identical SYNTHETIC input
```
`npm run build:wasm` alone is enough for the portable backend.
## CLI
```bash
ruview-kernel doctor # both backends, ABI, integrity
ruview-kernel selftest [--backend wasm|napi|auto]
ruview-kernel synth --seconds 60 --out frames.json
ruview-kernel analyze --file frames.json --readings
ruview-kernel bench --backend napi # MEASURED on this host only
```
`analyze` accepts a JSON array of frames, `{ "config": {...}, "frames": [...] }`,
or JSONL (one `{"amplitudes":[...],"phases":[...]}` per line).
## API
```js
import { loadKernel, selfTest } from '@ruvnet/ruview-kernel';
const kernel = loadKernel(); // wasm; or { backend: 'napi' | 'auto' }
console.log(kernel.backend, kernel.fallbackReason, kernel.integrity);
const { frames } = kernel.synthesize({ seconds: 60, breathing_bpm: 15 });
const report = kernel.analyze(frames, { n_subcarriers: 56, sample_rate_hz: 20 });
console.log(report.summary.last.respiratory); // { bpm, confidence, status }
const session = kernel.openSession({ n_subcarriers: 56 });
const readings = session.push(frames.slice(0, 200));
session.close();
```
### Binary fast path
`analyze()` and `session.push()` automatically send uniform frames as one
`Float64Array` (all amplitudes, then all phases) instead of JSON numbers.
Irregular input still goes through JSON, so errors name the bad frame. To
skip packing entirely:
```js
const data = new Float64Array(frames * 56 * 2); // amplitudes…, then phases…
kernel.analyzeFlat(data, { n_subcarriers: 56 }, { phases: true });
```
MEASURED on one Linux x64 host (`ruview-kernel bench --seconds 120`): 2.8×
(wasm) and 2.5× (napi) faster than the JSON transport for 2 400 frames. The
binary path is also bit-exact, while JSON number parsing can move inputs by
ULPs.
In a browser, fetch the module yourself and use the filesystem-free entry:
```js
import { loadWasmKernelFromBytes } from '@ruvnet/ruview-kernel/wasm';
const kernel = loadWasmKernelFromBytes(new Uint8Array(await (await fetch(url)).arrayBuffer()));
```
## Backends and trust
| Backend | Authority | Selection |
|---|---|---|
| `wasm` | none: module imports nothing, loader rejects any import | default |
| `napi` | full process (native code) | `backend: 'napi'`; never falls back silently |
| `auto` | napi if present, otherwise wasm with `fallbackReason` | opt-in |
Artifacts are checked against `SHA256SUMS`; a mismatch is always fatal.
Requests are bounded (16 MiB, 512 subcarriers, 100 000 frames per call, 64
sessions) and validated before processing.
## Metaharness
`npx @ruvnet/ruview kernel` (MCP tool `ruview_kernel_selftest`) runs this
package's self-test from the RuView harness when it is installed, and reports
`kernel_not_installed` otherwise.
@@ -0,0 +1,42 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// Reproducer for the ADR-368 amendment's rejected WASM build options.
// Builds the kernel three ways into separate target dirs and reports size,
// median analyzeFlat time over 15 runs (2 400 SYNTHETIC frames), and whether
// outputs are byte-identical to the baseline build.
// node bench/wasm-variants.mjs (from harness/ruview-kernel; needs cargo + wasm32 target)
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { flattenFrames } from '../src/abi.js';
import { loadWasmKernelFromBytes } from '../src/wasm.js';
const V2 = resolve(import.meta.dirname, '..', '..', '..', 'v2');
const variants = {
baseline: {},
simd128: { CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS: '-C target-feature=+simd128' },
'opt-level-s': { CARGO_PROFILE_RELEASE_OPT_LEVEL: 's' },
};
const built = {};
for (const [name, env] of Object.entries(variants)) {
const targetDir = join(tmpdir(), `ruview-kernel-variant-${name}`);
const r = spawnSync('cargo', ['build', '-q', '-p', 'ruview-kernel', '--release', '--target', 'wasm32-unknown-unknown', '--target-dir', targetDir], { cwd: V2, env: { ...process.env, ...env }, stdio: 'inherit' });
if (r.status !== 0) process.exit(1);
built[name] = readFileSync(join(targetDir, 'wasm32-unknown-unknown', 'release', 'ruview_kernel.wasm'));
}
const base = loadWasmKernelFromBytes(built.baseline);
const { frames } = base.synthesize({ seconds: 120 });
const { data } = flattenFrames(frames, 56);
const reference = JSON.stringify(base.analyzeFlat(data, {}, { phases: true, includeReadings: true }));
const rows = [];
for (const [name, bytes] of Object.entries(built)) {
const k = loadWasmKernelFromBytes(bytes);
const identical = JSON.stringify(k.analyzeFlat(data, {}, { phases: true, includeReadings: true })) === reference;
for (let i = 0; i < 3; i++) k.analyzeFlat(data, {}, { phases: true });
const runs = [];
for (let i = 0; i < 15; i++) { const t = performance.now(); k.analyzeFlat(data, {}, { phases: true }); runs.push(performance.now() - t); }
runs.sort((a, b) => a - b);
rows.push({ variant: name, bytes: bytes.length, medianMs: Number(runs[7].toFixed(1)), identicalToBaseline: identical });
}
console.log(JSON.stringify({ evidence: 'MEASURED (this host only)', frames: frames.length, rows }, null, 2));
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// `npx @ruvnet/ruview-kernel` — run the RuView compute kernel from the shell
// (ADR-368). Zero runtime dependencies; WASM is the default backend.
import { closeSync, openSync, readFileSync, readSync, realpathSync, statSync, writeFileSync } from 'node:fs';
import { argv } from 'node:process';
import { fileURLToPath } from 'node:url';
import { MAX_INPUT_BYTES } from '../src/abi.js';
import { BACKENDS, kernelStatus, loadKernel, parity, selfTest } from '../src/index.js';
const pjson = (value) => console.log(JSON.stringify(value, null, 2));
function parseFlags(rest) {
const flags = {};
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (!a.startsWith('--')) continue;
const eq = a.indexOf('=');
if (eq !== -1) flags[a.slice(2, eq)] = a.slice(eq + 1);
else if (i + 1 < rest.length && !rest[i + 1].startsWith('--')) flags[a.slice(2)] = rest[++i];
else flags[a.slice(2)] = true;
}
return flags;
}
function number(flags, key, fallback) {
if (flags[key] === undefined) return fallback;
const n = Number(flags[key]);
if (!Number.isFinite(n)) throw new Error(`--${key} must be a number`);
return n;
}
/** Bounded read of a caller-named regular file (JSON array/object or JSONL frames). */
function readFrames(path) {
const stat = statSync(path);
if (!stat.isFile()) throw new Error(`${path} is not a regular file`);
if (stat.size > MAX_INPUT_BYTES) throw new Error(`${path} exceeds ${MAX_INPUT_BYTES} bytes`);
const fd = openSync(path, 'r');
let text;
try {
const buf = Buffer.alloc(stat.size);
readSync(fd, buf, 0, stat.size, 0);
text = buf.toString('utf8');
} finally {
closeSync(fd);
}
const trimmed = text.trim();
let parsed;
try {
parsed = JSON.parse(trimmed);
} catch {
// Not a single JSON document: treat as JSONL, one frame per line.
const frames = trimmed.split('\n').filter((l) => l.trim()).map((l, i) => {
try { return JSON.parse(l); } catch { throw new Error(`line ${i + 1} is not valid JSON`); }
});
return { frames };
}
if (Array.isArray(parsed)) return { frames: parsed };
if (Array.isArray(parsed?.frames)) return { frames: parsed.frames, config: parsed.config };
if (parsed && Array.isArray(parsed.amplitudes)) return { frames: [parsed] };
throw new Error('input must be a JSON array of frames, {frames, config}, or JSONL');
}
function help() {
console.log(`Usage: ruview-kernel <command> [--backend wasm|napi|auto]
doctor [--strict-napi] report both backends, ABI, and artifact integrity
info kernel build info and limits
selftest [--seconds 60] SYNTHETIC end-to-end pipeline check
parity diff wasm vs napi outputs on the same SYNTHETIC input
synth [--seconds 60] [--breathing 15] [--heart 72] [--subcarriers 56] [--seed 7] [--out file.json]
analyze --file frames.json|frames.jsonl [--config '{"n_subcarriers":56}'] [--readings]
bench [--seconds 120] time analyze on SYNTHETIC input (MEASURED on this host)
Default backend: RUVIEW_KERNEL_BACKEND or wasm (zero-import, least authority).
Outputs are signal-processing estimates, not clinical or camera-grade measurements.`);
return 0;
}
export async function run(args) {
const cmd = args[0] ?? '--help';
const flags = parseFlags(args.slice(1));
if (flags.backend !== undefined && !BACKENDS.includes(flags.backend)) {
console.error(`--backend must be one of ${BACKENDS.join(', ')}`);
return 2;
}
const load = () => loadKernel({ backend: flags.backend });
switch (cmd) {
case 'doctor': {
const status = kernelStatus();
const ok = status.wasm.available && (!flags['strict-napi'] || status.napi.available);
pjson({ ok, ...status, note: status.napi.available ? 'both backends available' : 'napi optional; wasm backend active by default' });
return ok ? 0 : 1;
}
case 'info': {
const k = load();
pjson({ backend: k.backend, requestedBackend: k.requestedBackend, fallbackReason: k.fallbackReason, integrity: k.integrity, ...k.info() });
return 0;
}
case 'selftest': {
const k = load();
const result = selfTest(k, { seconds: number(flags, 'seconds', 60) });
pjson({ ...result, requestedBackend: k.requestedBackend, fallbackReason: k.fallbackReason });
return result.ok ? 0 : 1;
}
case 'parity': {
const result = parity(loadKernel({ backend: 'wasm' }), loadKernel({ backend: 'napi' }));
pjson(result);
return result.ok ? 0 : 1;
}
case 'synth': {
const out = load().synthesize({
seconds: number(flags, 'seconds', 60),
breathing_bpm: number(flags, 'breathing', 15),
heart_bpm: number(flags, 'heart', 72),
n_subcarriers: number(flags, 'subcarriers', 56),
sample_rate_hz: number(flags, 'rate', 20),
seed: number(flags, 'seed', 7),
});
const body = JSON.stringify({ evidence: out.evidence, config: { n_subcarriers: out.request.n_subcarriers, sample_rate_hz: out.request.sample_rate_hz }, frames: out.frames });
if (typeof flags.out === 'string') {
writeFileSync(flags.out, `${body}\n`, { flag: 'wx' }); // never overwrite an existing file
pjson({ ok: true, evidence: out.evidence, frames: out.frames.length, out: flags.out });
} else console.log(body);
return 0;
}
case 'analyze': {
if (typeof flags.file !== 'string') { console.error('analyze: --file <frames.json|frames.jsonl> is required'); return 2; }
const input = readFrames(flags.file);
const config = typeof flags.config === 'string' ? JSON.parse(flags.config) : (input.config || {});
const k = load();
pjson({ ok: true, backend: k.backend, ...k.analyze(input.frames, config, { includeReadings: flags.readings === true }) });
return 0;
}
case 'bench': {
const k = load();
const seconds = number(flags, 'seconds', 120);
const { frames } = k.synthesize({ seconds });
k.analyze(frames.slice(0, 200)); // warm-up
const iterations = 5;
const time = (fn) => { const t0 = performance.now(); for (let i = 0; i < iterations; i++) fn(); return (performance.now() - t0) / iterations; };
const ms = time(() => k.analyze(frames));
const jsonMs = time(() => k.call('analyze', { config: {}, frames, include_readings: false }));
pjson({
evidence: 'MEASURED',
reproducer: `npx @ruvnet/ruview-kernel bench --backend ${k.backend} --seconds ${seconds}`,
host: { node: process.version, platform: process.platform, arch: process.arch },
backend: k.backend, frames: frames.length, transport: k.binary ? 'binary' : 'json',
msPerAnalyze: Number(ms.toFixed(2)), framesPerSecond: Math.round(frames.length / (ms / 1000)),
jsonTransportMs: Number(jsonMs.toFixed(2)), speedupVsJson: Number((jsonMs / ms).toFixed(2)),
note: 'Includes transfer across the ABI; single-host timing, not a cross-host claim.',
});
return 0;
}
case '--version': case '-v': {
console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
return 0;
}
case '--help': case '-h': case 'help': return help();
default:
console.error(`Unknown command: ${cmd}. Try \`ruview-kernel --help\`.`);
return 2;
}
}
const invokedDirectly = (() => {
if (!argv[1]) return false;
try {
const a = realpathSync(argv[1]);
const b = realpathSync(fileURLToPath(import.meta.url));
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
} catch { return false; }
})();
if (invokedDirectly) {
run(argv.slice(2)).then((code) => process.exit(code)).catch((error) => {
pjson({ ok: false, code: error.code || 'error', error: error.message });
process.exit(1);
});
}
Whitespace-only changes.
+67
View File
@@ -0,0 +1,67 @@
{
"name": "@ruvnet/ruview-kernel",
"version": "0.1.0",
"description": "RuView portable compute kernel for Node and browsers: the ADR-021 vitals pipeline in Rust, shipped as zero-import WebAssembly with an optional napi-rs native backend and a byte-identical JSON ABI.",
"type": "module",
"bin": {
"ruview-kernel": "bin/cli.js"
},
"exports": {
".": "./src/index.js",
"./cli": "./bin/cli.js",
"./wasm": "./src/wasm.js",
"./abi": "./src/abi.js",
"./package.json": "./package.json"
},
"files": [
"bin/",
"src/",
"wasm/ruview_kernel.wasm",
"wasm/SHA256SUMS",
"native/*.node",
"native/SHA256SUMS",
"scripts/",
"README.md",
"LICENSE"
],
"scripts": {
"build": "node ./scripts/build.mjs --wasm --napi",
"build:wasm": "node ./scripts/build.mjs --wasm",
"build:napi": "node ./scripts/build.mjs --napi",
"test": "node --test",
"test:security": "node --test test/abi.test.mjs test/security.test.mjs",
"test:e2e": "node --test test/e2e.test.mjs",
"doctor": "node ./bin/cli.js doctor",
"selftest": "node ./bin/cli.js selftest",
"parity": "node ./bin/cli.js parity",
"prepack": "node ./scripts/verify-artifacts.mjs --require-wasm"
},
"keywords": [
"ruview",
"wifi-sensing",
"csi",
"channel-state-information",
"vital-signs",
"webassembly",
"wasm",
"napi-rs",
"metaharness"
],
"engines": {
"node": ">=20.0.0"
},
"license": "MIT",
"author": "ruvnet",
"homepage": "https://github.com/ruvnet/RuView#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/ruvnet/RuView.git",
"directory": "harness/ruview-kernel"
},
"bugs": {
"url": "https://github.com/ruvnet/RuView/issues"
},
"publishConfig": {
"access": "public"
}
}
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// Build the ruview-kernel artifacts from the Rust source in ../../v2 (ADR-368).
// --wasm cargo build -p ruview-kernel --target wasm32-unknown-unknown --release
// --napi cargo build --manifest-path v2/crates/ruview-kernel-napi/Cargo.toml --release
// Artifacts are copied into wasm/ and native/ with SHA256SUMS. No shell is used.
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { platformTriple } from '../src/platform.js';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const V2 = resolve(ROOT, '..', '..', 'v2');
const args = new Set(process.argv.slice(2));
const wantWasm = args.has('--wasm');
const wantNapi = args.has('--napi');
if (!wantWasm && !wantNapi) {
console.error('Usage: node scripts/build.mjs [--wasm] [--napi]');
process.exit(2);
}
if (!existsSync(join(V2, 'Cargo.toml'))) {
console.error(`RuView Rust workspace not found at ${V2}; build from a RuView checkout.`);
process.exit(2);
}
function cargo(cargoArgs, cwd) {
console.error(`$ cargo ${cargoArgs.join(' ')}`);
const r = spawnSync('cargo', cargoArgs, { cwd, stdio: 'inherit' });
if (r.status !== 0) {
console.error(`cargo failed (${r.error?.message || `exit ${r.status}`})`);
const missing = uninitializedSubmodules();
if (missing.length) {
// The v2 workspace resolves path dependencies inside submodules; a fresh
// clone or `git worktree add` leaves them empty and cargo fails on a
// missing Cargo.toml.
console.error(`Uninitialized git submodules (likely cause): ${missing.join(', ')}`);
console.error('Fix: git submodule update --init --recursive (run at the repository root)');
}
process.exit(1);
}
}
function uninitializedSubmodules() {
const r = spawnSync('git', ['submodule', 'status'], { cwd: join(V2, '..'), encoding: 'utf8' });
if (r.status !== 0) return [];
return r.stdout.split('\n').filter((l) => l.startsWith('-')).map((l) => l.slice(1).trim().split(/\s+/)[1]).filter(Boolean);
}
const sha256 = (path) => createHash('sha256').update(readFileSync(path)).digest('hex');
function install(src, dir, name) {
mkdirSync(join(ROOT, dir), { recursive: true });
const dest = join(ROOT, dir, name);
copyFileSync(src, dest);
const digest = sha256(dest);
// Merge: one line per artifact, so several platform addons can share native/.
const sums = join(ROOT, dir, 'SHA256SUMS');
const kept = existsSync(sums)
? readFileSync(sums, 'utf8').split('\n').filter((l) => l.trim() && !l.trim().endsWith(` ${name}`))
: [];
writeFileSync(sums, [...kept, `${digest} ${name}`].sort((a, b) => a.slice(66).localeCompare(b.slice(66))).join('\n') + '\n');
return { artifact: `${dir}/${name}`, sha256: digest };
}
/** Fail fast when the toolchain v2/ pins lacks the wasm32 target. */
function requireWasmTarget() {
const r = spawnSync('rustup', ['target', 'list', '--installed'], { cwd: V2, encoding: 'utf8' });
if (r.status === 0 && !r.stdout.split(/\r?\n/).includes('wasm32-unknown-unknown')) {
console.error('wasm32-unknown-unknown is not installed for the toolchain pinned by v2/rust-toolchain.');
console.error('Fix: (cd v2 && rustup target add wasm32-unknown-unknown)');
process.exit(1);
}
}
const built = [];
if (wantWasm) {
requireWasmTarget();
cargo(['build', '-p', 'ruview-kernel', '--release', '--target', 'wasm32-unknown-unknown', '--locked'], V2);
const src = join(V2, 'target', 'wasm32-unknown-unknown', 'release', 'ruview_kernel.wasm');
built.push(install(src, 'wasm', 'ruview_kernel.wasm'));
}
if (wantNapi) {
const crate = join(V2, 'crates', 'ruview-kernel-napi');
cargo(['build', '--release', '--locked', '--manifest-path', join(crate, 'Cargo.toml')], V2);
const lib = { win32: 'ruview_kernel_napi.dll', darwin: 'libruview_kernel_napi.dylib' }[process.platform] || 'libruview_kernel_napi.so';
const src = join(crate, 'target', 'release', lib);
built.push(install(src, 'native', `ruview_kernel.${platformTriple()}.node`));
}
console.log(JSON.stringify({ ok: true, built }, null, 2));
@@ -0,0 +1,44 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// Verify packaged kernel artifacts before pack/publish (ADR-368):
// checksums match, the WASM module imports nothing, and ABI versions agree.
import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { ABI_VERSION } from '../src/abi.js';
import { DEFAULT_WASM_PATH, PACKAGE_ROOT, loadKernel, readVerifiedArtifact } from '../src/index.js';
import { compileKernelModule } from '../src/wasm.js';
const requireWasm = process.argv.includes('--require-wasm');
const quiet = process.argv.includes('--quiet');
const report = { ok: true, wasm: null, native: [] };
const fail = (message) => { report.ok = false; (report.errors ||= []).push(message); };
if (existsSync(DEFAULT_WASM_PATH)) {
try {
const artifact = readVerifiedArtifact(DEFAULT_WASM_PATH);
if (artifact.integrity !== 'verified') fail('wasm/ruview_kernel.wasm has no SHA256SUMS entry');
compileKernelModule(artifact.bytes);
const kernel = loadKernel({ backend: 'wasm' });
if (kernel.info().abi !== ABI_VERSION) fail('WASM ABI mismatch');
report.wasm = { sha256: artifact.sha256, bytes: artifact.bytes.length, imports: 0 };
} catch (error) {
fail(`wasm: ${error.message}`);
}
} else if (requireWasm) {
fail('wasm/ruview_kernel.wasm is missing; run `npm run build:wasm`');
}
const nativeDir = join(PACKAGE_ROOT, 'native');
for (const name of existsSync(nativeDir) ? readdirSync(nativeDir).filter((n) => n.endsWith('.node')).sort() : []) {
try {
const artifact = readVerifiedArtifact(join(nativeDir, name));
if (artifact.integrity !== 'verified') fail(`native/${name} has no SHA256SUMS entry`);
report.native.push({ name, sha256: artifact.sha256 });
} catch (error) {
fail(`native/${name}: ${error.message}`);
}
}
if (!quiet || !report.ok) console.log(JSON.stringify(report, null, 2));
process.exit(report.ok ? 0 : 1);
+131
View File
@@ -0,0 +1,131 @@
// SPDX-License-Identifier: MIT
// Shared JSON-ABI envelope handling for every ruview-kernel transport (ADR-368).
export const ABI_VERSION = 1;
export const MAX_INPUT_BYTES = 16 * 1024 * 1024;
export const OPERATIONS = Object.freeze([
'info', 'validate_config', 'analyze', 'synthesize',
'session_open', 'session_push', 'session_summary', 'session_close',
'analyze_flat', 'session_push_flat',
]);
const DEFAULT_SUBCARRIERS = 56;
const FRAME_KEYS = new Set(['amplitudes', 'phases']);
/** Error raised for a `{ok:false}` kernel envelope or a transport failure. */
export class KernelError extends Error {
constructor(code, message, details = {}) {
super(message);
this.name = 'KernelError';
this.code = code;
Object.assign(this, details);
}
}
/** Serialize a request, enforcing the ABI's operation allow-list and size bound. */
export function encodeRequest(op, request) {
if (!OPERATIONS.includes(op)) throw new KernelError('unknown_operation', `unknown operation: ${String(op).slice(0, 64)}`);
const json = typeof request === 'string' ? request : JSON.stringify(request ?? {});
// UTF-8 needs at most 3 bytes per UTF-16 unit; only encode when it could matter.
const bytes = json.length * 3 <= MAX_INPUT_BYTES ? json.length : new TextEncoder().encode(json).length;
if (bytes > MAX_INPUT_BYTES) {
throw new KernelError('limit_exceeded', `request exceeds ${MAX_INPUT_BYTES} bytes`);
}
return json;
}
/** Parse a kernel envelope; returns `result` or throws KernelError. */
export function decodeResponse(text) {
let envelope;
try {
envelope = JSON.parse(text);
} catch {
throw new KernelError('internal', 'kernel returned malformed JSON');
}
if (envelope?.ok === true) return envelope.result;
const error = envelope?.error || {};
throw new KernelError(String(error.code || 'internal'), String(error.message || 'kernel error'));
}
/**
* Pack uniform frames into one Float64Array: all amplitudes, then all phases.
* Returns null whenever the JSON path must handle the input instead, so that
* malformed frames still get the kernel's precise per-frame error messages.
*/
export function flattenFrames(frames, n) {
if (!Array.isArray(frames) || !Number.isInteger(n) || n < 1) return null;
const count = frames.length;
const withPhases = count > 0 && Array.isArray(frames[0]?.phases) && frames[0].phases.length > 0;
const data = new Float64Array(count * n * (withPhases ? 2 : 1));
const phaseBase = count * n;
for (let k = 0; k < count; k++) {
const f = frames[k];
if (!f || typeof f !== 'object' || !Array.isArray(f.amplitudes) || f.amplitudes.length !== n) return null;
for (const key of Object.keys(f)) if (!FRAME_KEYS.has(key)) return null;
const ph = f.phases ?? [];
if (!Array.isArray(ph) || ph.length !== (withPhases ? n : 0)) return null;
for (let i = 0; i < n; i++) {
const a = f.amplitudes[i];
if (typeof a !== 'number') return null;
data[k * n + i] = a;
if (withPhases) {
const p = ph[i];
if (typeof p !== 'number') return null;
data[phaseBase + k * n + i] = p;
}
}
}
return { data, withPhases };
}
/**
* Wrap a raw transport `(op, json) => json` into the high-level kernel API.
* Both backends go through here, so their behaviour differs only in transport.
* `transportF64(op, json, Float64Array)` is the optional binary fast path.
*/
export function createKernel(transport, meta, transportF64 = null) {
const call = (op, request) => decodeResponse(transport(op, encodeRequest(op, request)));
const callF64 = (op, request, data) => {
if (!(data instanceof Float64Array)) throw new KernelError('invalid_request', 'binary frame data must be a Float64Array');
if (data.byteLength > MAX_INPUT_BYTES) throw new KernelError('limit_exceeded', `binary data exceeds ${MAX_INPUT_BYTES} bytes`);
return decodeResponse(transportF64(op, encodeRequest(op, request), data));
};
const flat = (frames, config) => (transportF64 ? flattenFrames(frames, config?.n_subcarriers ?? DEFAULT_SUBCARRIERS) : null);
const kernel = {
...meta,
binary: Boolean(transportF64),
call,
info: () => call('info', {}),
validateConfig: (config = {}) => call('validate_config', config),
analyze(frames, config = {}, { includeReadings = false } = {}) {
const packed = flat(frames, config);
return packed
? callF64('analyze_flat', { config, phases: packed.withPhases, include_readings: includeReadings }, packed.data)
: call('analyze', { config, frames, include_readings: includeReadings });
},
/** Zero-copy entry: amplitudes for every frame, then phases if `phases`. */
analyzeFlat(data, config = {}, { phases = false, includeReadings = false } = {}) {
if (!transportF64) throw new KernelError('backend_unavailable', 'this kernel build has no binary transport');
return callF64('analyze_flat', { config, phases, include_readings: includeReadings }, data);
},
synthesize: (options = {}) => call('synthesize', options),
openSession(config = {}) {
const n = config?.n_subcarriers ?? DEFAULT_SUBCARRIERS;
const { session } = call('session_open', { config });
let open = true;
const ensureOpen = () => { if (!open) throw new KernelError('unknown_session', 'session is closed'); };
return {
id: session,
push(frames) {
ensureOpen();
const packed = transportF64 ? flattenFrames(frames, n) : null;
return packed
? callF64('session_push_flat', { session, phases: packed.withPhases }, packed.data).readings
: call('session_push', { session, frames }).readings;
},
summary() { ensureOpen(); return call('session_summary', { session }).summary; },
close() { if (!open) return false; open = false; return call('session_close', { session }).closed; },
};
},
};
return Object.freeze(kernel);
}
+218
View File
@@ -0,0 +1,218 @@
// SPDX-License-Identifier: MIT
// @ruvnet/ruview-kernel — Node loader with explicit backend selection (ADR-368).
//
// Backends:
// wasm (default) — zero-import WebAssembly: no host authority at all.
// napi — optional napi-rs native addon; full process authority,
// so it is only used when requested (`napi`) or allowed
// (`auto`, which prefers napi and falls back to wasm).
// The loader always reports which backend actually runs and why any
// fallback happened; it never silently substitutes a backend for `napi`.
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ABI_VERSION, KernelError, createKernel } from './abi.js';
import { loadWasmKernelFromBytes } from './wasm.js';
import { platformTriple } from './platform.js';
export { ABI_VERSION, KernelError, OPERATIONS } from './abi.js';
export { loadWasmKernelFromBytes } from './wasm.js';
export const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
export const DEFAULT_WASM_PATH = join(PACKAGE_ROOT, 'wasm', 'ruview_kernel.wasm');
export const BACKENDS = Object.freeze(['wasm', 'napi', 'auto']);
const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
const require = createRequire(import.meta.url);
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
/** Parse a `sha256 name` checksum file next to an artifact. */
function expectedDigest(artifactPath) {
const sums = join(dirname(artifactPath), 'SHA256SUMS');
if (!existsSync(sums)) return null;
for (const line of readFileSync(sums, 'utf8').split('\n')) {
const match = /^([0-9a-f]{64})\s+\*?(.+)$/.exec(line.trim());
if (match && match[2] === basename(artifactPath)) return match[1];
}
return null;
}
/**
* Read an artifact and check it against its SHA256SUMS entry. A missing
* checksum is reported (integrity `unverified`); a mismatch always fails.
*/
export function readVerifiedArtifact(path) {
const stat = statSync(path);
if (!stat.isFile()) throw new KernelError('artifact_invalid', `${path} is not a regular file`);
if (stat.size > MAX_ARTIFACT_BYTES) throw new KernelError('artifact_invalid', `${path} exceeds ${MAX_ARTIFACT_BYTES} bytes`);
const bytes = readFileSync(path);
const digest = sha256(bytes);
const expected = expectedDigest(path);
if (expected && expected !== digest) {
throw new KernelError('integrity_mismatch', `${basename(path)} sha256 ${digest} does not match SHA256SUMS ${expected}`);
}
return { bytes, sha256: digest, integrity: expected ? 'verified' : 'unverified' };
}
/** Candidate native addon locations, most specific first. */
export function nativeCandidates(triple = platformTriple()) {
const candidates = [join(PACKAGE_ROOT, 'native', `ruview_kernel.${triple}.node`)];
try {
candidates.push(require.resolve(`@ruvnet/ruview-kernel-${triple}`));
} catch { /* optional platform package not installed */ }
return candidates;
}
function loadWasm(options) {
const path = options.wasmPath || DEFAULT_WASM_PATH;
if (!existsSync(path)) {
throw new KernelError('backend_unavailable', `WASM kernel not found at ${path}; run \`npm run build:wasm\` in harness/ruview-kernel`);
}
const artifact = readVerifiedArtifact(path);
return loadWasmKernelFromBytes(artifact.bytes, { artifact: path, sha256: artifact.sha256, integrity: artifact.integrity });
}
function loadNapi(options) {
const triple = platformTriple();
const candidates = options.nativePath ? [options.nativePath] : nativeCandidates(triple);
const path = candidates.find((p) => existsSync(p));
if (!path) {
throw new KernelError('backend_unavailable', `no napi-rs addon for ${triple}; run \`npm run build:napi\` or use the wasm backend`);
}
const artifact = readVerifiedArtifact(path);
const addon = require(path);
if (typeof addon?.call !== 'function' || typeof addon?.abiVersion !== 'function') {
throw new KernelError('untrusted_module', `${basename(path)} does not expose the ruview-kernel ABI`);
}
const abi = addon.abiVersion();
if (abi !== ABI_VERSION) throw new KernelError('abi_mismatch', `napi ABI ${abi} != loader ABI ${ABI_VERSION}`);
return createKernel((op, json) => addon.call(op, json), {
backend: 'napi', abi, triple, artifact: path, sha256: artifact.sha256, integrity: artifact.integrity,
}, typeof addon.callF64 === 'function' ? (op, json, data) => addon.callF64(op, json, data) : null);
}
/**
* Load the kernel. `backend` defaults to RUVIEW_KERNEL_BACKEND, then `wasm`.
* Options: { backend, wasmPath, nativePath, requireIntegrity }.
*/
export function loadKernel(options = {}) {
const requested = options.backend || process.env.RUVIEW_KERNEL_BACKEND || 'wasm';
if (!BACKENDS.includes(requested)) {
throw new KernelError('invalid_backend', `backend must be one of ${BACKENDS.join(', ')}`);
}
let kernel;
let fallbackReason = null;
if (requested === 'wasm') kernel = loadWasm(options);
else if (requested === 'napi') kernel = loadNapi(options);
else {
try {
kernel = loadNapi(options);
} catch (error) {
if (error.code === 'integrity_mismatch') throw error; // tampering is never a fallback case
fallbackReason = error.message;
kernel = loadWasm(options);
}
}
if (options.requireIntegrity && kernel.integrity !== 'verified') {
throw new KernelError('integrity_unverified', `${kernel.backend} artifact has no SHA256SUMS entry`);
}
return Object.freeze({ ...kernel, requestedBackend: requested, fallbackReason });
}
/** Non-throwing availability report for both backends (used by `doctor`). */
export function kernelStatus(options = {}) {
const probe = (backend) => {
try {
const k = loadKernel({ ...options, backend });
const info = k.info();
return { available: true, version: info.version, abi: k.abi, target: info.target, artifact: k.artifact, sha256: k.sha256, integrity: k.integrity };
} catch (error) {
return { available: false, code: error.code || 'error', reason: error.message };
}
};
return { node: process.version, triple: platformTriple(), abi: ABI_VERSION, wasm: probe('wasm'), napi: probe('napi') };
}
const within = (value, target, tolerance) => typeof value === 'number' && Math.abs(value - target) <= tolerance;
/**
* Deterministic end-to-end self-test on SYNTHETIC CSI with known tones.
* Exercises synthesize → analyze → streaming session → validation errors.
*/
export function selfTest(kernel, { seconds = 60, breathingBpm = 15, heartBpm = 72 } = {}) {
const started = performance.now();
const checks = [];
const check = (name, pass, detail = undefined) => checks.push({ name, pass: Boolean(pass), ...(detail === undefined ? {} : { detail }) });
const info = kernel.info();
check('abi matches loader', info.abi === ABI_VERSION, { abi: info.abi });
const synth = kernel.synthesize({ seconds, breathing_bpm: breathingBpm, heart_bpm: heartBpm, seed: 7 });
check('synthetic data is labelled SYNTHETIC', synth.evidence === 'SYNTHETIC');
const report = kernel.analyze(synth.frames, {}, { includeReadings: true });
const last = report.summary.last;
check('one reading per second', report.readings.length === Math.round(seconds), { readings: report.readings.length });
check('respiratory rate recovered within 3 bpm', within(last?.respiratory.bpm, breathingBpm, 3), { bpm: last?.respiratory.bpm });
check('heart rate recovered within 8 bpm', within(last?.heart.bpm, heartBpm, 8), { bpm: last?.heart.bpm });
const session = kernel.openSession({});
for (let i = 0; i < synth.frames.length; i += 97) session.push(synth.frames.slice(i, i + 97));
const streamed = session.summary();
session.close();
check('streaming session equals batch analysis', JSON.stringify(streamed) === JSON.stringify(report.summary));
let rejected = null;
try { kernel.analyze([{ amplitudes: [1] }], { n_subcarriers: 2 }); } catch (error) { rejected = error.code; }
check('malformed frame rejected fail-closed', rejected === 'invalid_request', { code: rejected });
return {
ok: checks.every((c) => c.pass),
evidence: 'SYNTHETIC',
backend: kernel.backend,
kernel: { version: info.version, abi: info.abi, target: info.target },
summary: report.summary,
checks,
elapsedMs: Math.round(performance.now() - started),
};
}
/** Numeric deep comparison with a relative tolerance (libm may differ by ULPs across targets). */
export function deepClose(a, b, tolerance = 1e-9, path = '$') {
if (typeof a === 'number' && typeof b === 'number') {
const scale = Math.max(1, Math.abs(a), Math.abs(b));
return Math.abs(a - b) <= tolerance * scale ? [] : [`${path}: ${a} != ${b}`];
}
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return [`${path}: length ${a.length} != ${b.length}`];
return a.flatMap((v, i) => deepClose(v, b[i], tolerance, `${path}[${i}]`));
}
if (a && b && typeof a === 'object' && typeof b === 'object') {
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
return keys.flatMap((k) => deepClose(a[k], b[k], tolerance, `${path}.${k}`));
}
return Object.is(a, b) ? [] : [`${path}: ${JSON.stringify(a)} != ${JSON.stringify(b)}`];
}
/** Run the same SYNTHETIC workload on two kernels and diff every output. */
export function parity(left, right, { seconds = 45 } = {}) {
const request = { seconds, seed: 11, n_subcarriers: 32 };
const a = left.synthesize(request);
const b = right.synthesize(request);
const synthDiff = deepClose(a.frames, b.frames);
const ra = left.analyze(a.frames, { n_subcarriers: 32 }, { includeReadings: true });
const rb = right.analyze(a.frames, { n_subcarriers: 32 }, { includeReadings: true });
const analyzeDiff = deepClose(ra, rb);
const differences = [...synthDiff, ...analyzeDiff];
return {
ok: differences.length === 0,
evidence: 'SYNTHETIC',
backends: [left.backend, right.backend],
frames: a.frames.length,
readings: ra.readings.length,
exactJsonMatch: JSON.stringify(ra) === JSON.stringify(rb),
differences: differences.slice(0, 20),
};
}
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: MIT
// napi-rs platform triple resolution (matches @napi-rs/cli naming).
import { readFileSync } from 'node:fs';
function isMusl() {
if (process.platform !== 'linux') return false;
try {
const report = process.report?.getReport?.();
const header = typeof report === 'string' ? JSON.parse(report).header : report?.header;
if (header && 'glibcVersionRuntime' in header) return !header.glibcVersionRuntime;
} catch { /* fall through */ }
try {
return readFileSync('/usr/bin/ldd', 'utf8').includes('musl');
} catch {
return false;
}
}
/** e.g. `linux-x64-gnu`, `darwin-arm64`, `win32-x64-msvc`. */
export function platformTriple(platform = process.platform, arch = process.arch) {
if (platform === 'linux') return `linux-${arch}-${isMusl() ? 'musl' : 'gnu'}`;
if (platform === 'win32') return `win32-${arch}-msvc`;
return `${platform}-${arch}`;
}
+101
View File
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: MIT
// Browser- and Node-safe WASM transport (ADR-368). No filesystem access here:
// callers supply the module bytes, so this file can be bundled for the web.
//
// The module imports nothing from the host (no fs, network, clock, or
// randomness); instantiation fails closed if that ever changes.
import { ABI_VERSION, KernelError, createKernel } from './abi.js';
const REQUIRED_EXPORTS = ['memory', 'rvk_abi_version', 'rvk_alloc', 'rvk_free', 'rvk_call'];
const encoder = new TextEncoder();
const decoder = new TextDecoder('utf-8', { fatal: true });
/** Compile and validate a module; rejects any host import. */
export function compileKernelModule(bytes) {
const module = new WebAssembly.Module(bytes);
const imports = WebAssembly.Module.imports(module);
if (imports.length !== 0) {
throw new KernelError('untrusted_module', `kernel module must not import host functions (found ${imports.length})`);
}
const names = new Set(WebAssembly.Module.exports(module).map((e) => e.name));
const missing = REQUIRED_EXPORTS.filter((n) => !names.has(n));
if (missing.length) throw new KernelError('untrusted_module', `kernel module is missing exports: ${missing.join(', ')}`);
return module;
}
function instantiate(module) {
const { exports } = new WebAssembly.Instance(module, {});
const abi = exports.rvk_abi_version();
if (abi !== ABI_VERSION) throw new KernelError('abi_mismatch', `WASM ABI ${abi} != loader ABI ${ABI_VERSION}`);
return exports;
}
function write(exports, text) {
const bytes = encoder.encode(text);
const ptr = exports.rvk_alloc(bytes.length);
if (ptr === 0) throw new KernelError('out_of_memory', 'kernel allocation failed');
new Uint8Array(exports.memory.buffer, ptr, bytes.length).set(bytes);
return [ptr, bytes.length];
}
/**
* Create a raw `(op, json) => json` transport. A trap (Rust panic, OOM)
* poisons the instance; the next call gets a fresh one, and open sessions
* from the trapped instance are lost (reported as `unknown_session`).
*/
export function createWasmTransport(module) {
let exports = instantiate(module);
let generation = 0;
const transport = (op, json) => {
if (!exports) { exports = instantiate(module); generation += 1; }
try {
const [opPtr, opLen] = write(exports, op);
const [inPtr, inLen] = write(exports, json);
const packed = exports.rvk_call(opPtr, opLen, inPtr, inLen);
if (packed === 0n) throw new KernelError('out_of_memory', 'kernel response allocation failed');
const ptr = Number(packed >> 32n);
const len = Number(packed & 0xffffffffn);
const text = decoder.decode(new Uint8Array(exports.memory.buffer, ptr, len).slice());
exports.rvk_free(ptr, len);
return text;
} catch (error) {
if (error instanceof KernelError) throw error;
exports = null;
throw new KernelError('trap', `WASM kernel trapped: ${error?.message || error}`);
}
};
transport.generation = () => generation;
transport.f64 = (op, json, data) => {
if (!exports) { exports = instantiate(module); generation += 1; }
if (typeof exports.rvk_call_f64 !== 'function') throw new KernelError('backend_unavailable', 'WASM module predates the binary transport');
try {
const [opPtr, opLen] = write(exports, op);
const [inPtr, inLen] = write(exports, json);
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
const dataPtr = exports.rvk_alloc(bytes.length);
if (dataPtr === 0) throw new KernelError('out_of_memory', 'kernel allocation failed');
new Uint8Array(exports.memory.buffer, dataPtr, bytes.length).set(bytes);
const packed = exports.rvk_call_f64(opPtr, opLen, inPtr, inLen, dataPtr, bytes.length);
if (packed === 0n) throw new KernelError('out_of_memory', 'kernel response allocation failed');
const ptr = Number(packed >> 32n);
const len = Number(packed & 0xffffffffn);
const text = decoder.decode(new Uint8Array(exports.memory.buffer, ptr, len).slice());
exports.rvk_free(ptr, len);
return text;
} catch (error) {
if (error instanceof KernelError) throw error;
exports = null;
throw new KernelError('trap', `WASM kernel trapped: ${error?.message || error}`);
}
};
return transport;
}
/** Build the high-level kernel API from WASM bytes (browser or Node). */
export function loadWasmKernelFromBytes(bytes, meta = {}) {
const module = compileKernelModule(bytes);
const transport = createWasmTransport(module);
const hasF64 = WebAssembly.Module.exports(module).some((e) => e.name === 'rvk_call_f64');
return createKernel(transport, { backend: 'wasm', abi: ABI_VERSION, ...meta }, hasF64 ? transport.f64 : null);
}
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ABI_VERSION, KernelError, MAX_INPUT_BYTES, createKernel, decodeResponse, encodeRequest } from '../src/abi.js';
import { deepClose } from '../src/index.js';
test('encodeRequest enforces the operation allow-list and size bound', () => {
assert.equal(encodeRequest('info', {}), '{}');
assert.throws(() => encodeRequest('rm_rf', {}), (e) => e instanceof KernelError && e.code === 'unknown_operation');
assert.throws(() => encodeRequest('analyze', 'x'.repeat(MAX_INPUT_BYTES + 1)), (e) => e.code === 'limit_exceeded');
// Multi-byte characters count as UTF-8 bytes, not UTF-16 units.
assert.throws(() => encodeRequest('analyze', '€'.repeat(MAX_INPUT_BYTES / 3 + 1)), (e) => e.code === 'limit_exceeded');
});
test('decodeResponse unwraps ok envelopes and raises structured errors', () => {
assert.deepEqual(decodeResponse('{"ok":true,"result":{"a":1}}'), { a: 1 });
assert.throws(() => decodeResponse('{"ok":false,"error":{"code":"invalid_request","message":"bad"}}'),
(e) => e.code === 'invalid_request' && e.message === 'bad');
assert.throws(() => decodeResponse('not json'), (e) => e.code === 'internal');
assert.throws(() => decodeResponse('{"result":1}'), (e) => e.code === 'internal');
});
test('createKernel routes every helper through the single transport', () => {
const calls = [];
const transport = (op, json) => {
calls.push([op, JSON.parse(json)]);
if (op === 'session_open') return '{"ok":true,"result":{"session":9}}';
if (op === 'session_close') return '{"ok":true,"result":{"closed":true}}';
return `{"ok":true,"result":{"op":"${op}","abi":${ABI_VERSION}}}`;
};
const k = createKernel(transport, { backend: 'fake' });
assert.equal(k.info().op, 'info');
k.analyze([{ amplitudes: [1] }], { n_subcarriers: 1 }, { includeReadings: true });
assert.deepEqual(calls.at(-1), ['analyze', { config: { n_subcarriers: 1 }, frames: [{ amplitudes: [1] }], include_readings: true }]);
const s = k.openSession({});
assert.equal(s.close(), true);
assert.equal(s.close(), false, 'double close is a no-op');
assert.throws(() => s.push([]), (e) => e.code === 'unknown_session');
assert.ok(Object.isFrozen(k));
});
test('deepClose tolerates ULP noise but reports real differences', () => {
assert.deepEqual(deepClose({ a: [1, 2.0000000000001] }, { a: [1, 2] }), []);
assert.equal(deepClose({ a: 1 }, { a: 1.001 }).length, 1);
assert.equal(deepClose({ a: 'x' }, { a: 'y' }).length, 1);
assert.equal(deepClose([1], [1, 2]).length, 1);
assert.equal(deepClose({ a: 1 }, {}).length, 1);
});
@@ -0,0 +1,54 @@
// Binary (Float64Array) fast path (ADR-368 amendment). Evidence: SYNTHETIC.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { flattenFrames, MAX_INPUT_BYTES } from '../src/abi.js';
import { deepClose, loadKernel } from '../src/index.js';
import { napiSkip, requireWasm } from './helpers.mjs';
test('flattenFrames packs uniform frames and defers irregular ones to JSON', () => {
const f = (a, p) => (p ? { amplitudes: a, phases: p } : { amplitudes: a });
const packed = flattenFrames([f([1, 2], [5, 6]), f([3, 4], [7, 8])], 2);
assert.deepEqual([...packed.data], [1, 2, 3, 4, 5, 6, 7, 8]);
assert.equal(packed.withPhases, true);
assert.deepEqual([...flattenFrames([f([1, 2]), f([3, 4])], 2).data], [1, 2, 3, 4]);
assert.equal(flattenFrames([f([1, 2])], 3), null, 'width mismatch');
assert.equal(flattenFrames([f([1, 2], [1, 2]), f([3, 4])], 2), null, 'mixed phases');
assert.equal(flattenFrames([{ amplitudes: [1, 2], extra: 1 }], 2), null, 'unknown key');
assert.equal(flattenFrames([f([1, '2'])], 2), null, 'non-number');
assert.equal(flattenFrames([null], 2), null);
assert.equal(flattenFrames([], 2).data.length, 0);
});
function transportsAgree(k) {
assert.equal(k.binary, true, `${k.backend} exposes the binary transport`);
const { frames } = k.synthesize({ seconds: 30, n_subcarriers: 16 });
const viaBinary = k.analyze(frames, { n_subcarriers: 16 }, { includeReadings: true });
const viaJson = k.call('analyze', { config: { n_subcarriers: 16 }, frames, include_readings: true });
// JSON number parsing perturbs inputs by ULPs; outputs agree to 1e-9.
assert.deepEqual(deepClose(viaBinary, viaJson), []);
const s = k.openSession({ n_subcarriers: 16 });
for (let i = 0; i < frames.length; i += 50) s.push(frames.slice(i, i + 50));
assert.deepEqual(s.summary(), viaBinary.summary, 'streamed binary pushes equal batch binary analysis');
s.close();
}
test('wasm binary and JSON transports agree', () => {
requireWasm();
transportsAgree(loadKernel({ backend: 'wasm' }));
});
test('napi binary and JSON transports agree', { skip: napiSkip }, () => {
transportsAgree(loadKernel({ backend: 'napi' }));
});
test('binary entry points validate input and keep precise JSON errors for bad frames', () => {
requireWasm();
const k = loadKernel({ backend: 'wasm' });
assert.throws(() => k.analyzeFlat([1, 2, 3]), (e) => e.code === 'invalid_request');
assert.throws(() => k.analyzeFlat(new Float64Array(MAX_INPUT_BYTES / 8 + 1)), (e) => e.code === 'limit_exceeded');
assert.throws(() => k.analyzeFlat(new Float64Array(5), { n_subcarriers: 2 }), (e) => e.code === 'invalid_request');
assert.throws(() => k.analyzeFlat(Float64Array.of(1, NaN), { n_subcarriers: 2 }), (e) => e.code === 'invalid_request');
// A malformed frame falls back to JSON, whose error names the frame.
assert.throws(() => k.analyze([{ amplitudes: [1, 2] }, { amplitudes: [1] }], { n_subcarriers: 2 }), (e) => /frames\[1\]/.test(e.message));
assert.equal(k.info().abi, 1, 'instance healthy after rejected input');
});
+102
View File
@@ -0,0 +1,102 @@
// End-to-end: Rust core → WASM/napi transports → JS API → CLI (ADR-368).
// Evidence for every numeric assertion here: SYNTHETIC.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { kernelStatus, loadKernel, parity, selfTest } from '../src/index.js';
import { napiSkip, requireWasm } from './helpers.mjs';
const CLI = join(dirname(dirname(fileURLToPath(import.meta.url))), 'bin', 'cli.js');
const cli = (...args) => {
const r = spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', env: { ...process.env, RUVIEW_KERNEL_BACKEND: '' } });
return { status: r.status, stdout: r.stdout, stderr: r.stderr, json: () => JSON.parse(r.stdout) };
};
test('wasm backend passes the SYNTHETIC self-test', () => {
requireWasm();
const k = loadKernel({ backend: 'wasm' });
assert.equal(k.backend, 'wasm');
assert.equal(k.integrity, 'verified');
const result = selfTest(k);
assert.ok(result.ok, JSON.stringify(result.checks.filter((c) => !c.pass)));
assert.equal(result.evidence, 'SYNTHETIC');
assert.equal(result.kernel.target, 'wasm32');
});
test('napi backend passes the SYNTHETIC self-test', { skip: napiSkip }, () => {
const k = loadKernel({ backend: 'napi' });
assert.equal(k.backend, 'napi');
const result = selfTest(k);
assert.ok(result.ok, JSON.stringify(result.checks.filter((c) => !c.pass)));
assert.equal(result.kernel.target, 'native');
});
test('wasm and napi produce the same outputs for the same input', { skip: napiSkip }, () => {
const result = parity(loadKernel({ backend: 'wasm' }), loadKernel({ backend: 'napi' }));
assert.ok(result.ok, result.differences.join('\n'));
assert.equal(result.readings, 45);
});
test('auto prefers napi and reports an honest fallback to wasm', () => {
requireWasm();
const k = loadKernel({ backend: 'auto', nativePath: '/nonexistent/ruview_kernel.node' });
assert.equal(k.backend, 'wasm');
assert.equal(k.requestedBackend, 'auto');
assert.match(k.fallbackReason, /no napi-rs addon|not found|nonexistent/);
});
test('kernelStatus reports both backends without throwing', () => {
const status = kernelStatus();
assert.equal(status.abi, 1);
assert.equal(typeof status.wasm.available, 'boolean');
assert.equal(typeof status.napi.available, 'boolean');
});
test('CLI: doctor, selftest, synth → analyze round trip', () => {
requireWasm();
const doctor = cli('doctor');
assert.equal(doctor.status, 0, doctor.stderr);
assert.equal(doctor.json().wasm.available, true);
const st = cli('selftest', '--seconds', '45');
assert.equal(st.status, 0, st.stdout);
assert.equal(st.json().backend, 'wasm');
const dir = mkdtempSync(join(tmpdir(), 'ruview-kernel-e2e-'));
try {
const file = join(dir, 'frames.json');
const synth = cli('synth', '--seconds', '40', '--breathing', '12', '--subcarriers', '24', '--out', file);
assert.equal(synth.status, 0, synth.stderr);
assert.equal(cli('synth', '--seconds', '1', '--out', file).status, 1, 'synth must refuse to overwrite');
const analyzed = cli('analyze', '--file', file, '--readings');
assert.equal(analyzed.status, 0, analyzed.stderr);
const report = analyzed.json();
assert.equal(report.summary.frames, 800);
assert.equal(report.readings.length, 40);
assert.ok(Math.abs(report.summary.last.respiratory.bpm - 12) <= 3, `SYNTHETIC RR ${report.summary.last.respiratory.bpm}`);
// JSONL input takes the same path.
const frames = JSON.parse(cli('synth', '--seconds', '2', '--subcarriers', '4').stdout).frames;
const jsonl = join(dir, 'frames.jsonl');
writeFileSync(jsonl, frames.map((f) => JSON.stringify(f)).join('\n'));
const small = cli('analyze', '--file', jsonl, '--config', '{"n_subcarriers":4}');
assert.equal(small.status, 0, small.stdout);
assert.equal(small.json().summary.frames, 40);
const bad = cli('analyze', '--file', jsonl);
assert.equal(bad.status, 1, 'width mismatch against default 56 subcarriers must fail');
assert.equal(bad.json().code, 'invalid_request');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('CLI rejects unknown backends and commands', () => {
assert.equal(cli('info', '--backend', 'gpu').status, 2);
assert.equal(cli('frobnicate').status, 2);
});
+20
View File
@@ -0,0 +1,20 @@
import { existsSync } from 'node:fs';
import { DEFAULT_WASM_PATH, nativeCandidates } from '../src/index.js';
export const HAS_WASM = existsSync(DEFAULT_WASM_PATH);
export const HAS_NAPI = nativeCandidates().some((p) => existsSync(p));
// CI sets RUVIEW_KERNEL_REQUIRE_NAPI=1 after `npm run build:napi` so a missing
// addon is a failure there, while contributors without Rust can still run tests.
export const REQUIRE_NAPI = process.env.RUVIEW_KERNEL_REQUIRE_NAPI === '1';
export const napiSkip = !HAS_NAPI && !REQUIRE_NAPI ? 'napi addon not built (npm run build:napi)' : false;
export function requireWasm() {
if (!HAS_WASM) throw new Error(`WASM kernel missing at ${DEFAULT_WASM_PATH}; run \`npm run build:wasm\` first`);
}
/** Smallest valid module that imports one host function ("env"."f"). */
export const MODULE_WITH_IMPORT = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
0x01, 0x04, 0x01, 0x60, 0x00, 0x00,
0x02, 0x09, 0x01, 0x03, 0x65, 0x6e, 0x76, 0x01, 0x66, 0x00, 0x00,
]);
@@ -0,0 +1,84 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DEFAULT_WASM_PATH, loadKernel } from '../src/index.js';
import { compileKernelModule, loadWasmKernelFromBytes } from '../src/wasm.js';
import { HAS_WASM, MODULE_WITH_IMPORT, requireWasm } from './helpers.mjs';
test('a module that imports host functions is refused', () => {
assert.throws(() => compileKernelModule(MODULE_WITH_IMPORT), (e) => e.code === 'untrusted_module');
assert.throws(() => loadWasmKernelFromBytes(MODULE_WITH_IMPORT), (e) => e.code === 'untrusted_module');
});
test('a module without the kernel exports is refused', () => {
const empty = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]);
assert.throws(() => compileKernelModule(empty), (e) => e.code === 'untrusted_module' && /missing exports/.test(e.message));
});
test('invalid backend names are rejected', () => {
assert.throws(() => loadKernel({ backend: 'python' }), (e) => e.code === 'invalid_backend');
});
test('explicit napi never silently falls back to wasm', () => {
assert.throws(() => loadKernel({ backend: 'napi', nativePath: '/nonexistent/ruview.node' }), (e) => e.code === 'backend_unavailable');
});
test('checksum mismatch fails closed and is never an auto fallback', { skip: !HAS_WASM && 'wasm not built' }, () => {
const dir = mkdtempSync(join(tmpdir(), 'ruview-kernel-'));
try {
const wasmPath = join(dir, 'ruview_kernel.wasm');
copyFileSync(DEFAULT_WASM_PATH, wasmPath);
writeFileSync(join(dir, 'SHA256SUMS'), `${'0'.repeat(64)} ruview_kernel.wasm\n`);
assert.throws(() => loadKernel({ backend: 'wasm', wasmPath }), (e) => e.code === 'integrity_mismatch');
const fakeNative = join(dir, 'fake.node');
writeFileSync(fakeNative, 'not an addon');
writeFileSync(join(dir, 'SHA256SUMS'), `${'1'.repeat(64)} fake.node\n`);
assert.throws(() => loadKernel({ backend: 'auto', nativePath: fakeNative }), (e) => e.code === 'integrity_mismatch');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('requireIntegrity refuses an unchecksummed artifact', { skip: !HAS_WASM && 'wasm not built' }, () => {
const dir = mkdtempSync(join(tmpdir(), 'ruview-kernel-'));
try {
const wasmPath = join(dir, 'ruview_kernel.wasm');
copyFileSync(DEFAULT_WASM_PATH, wasmPath);
assert.equal(loadKernel({ backend: 'wasm', wasmPath }).integrity, 'unverified');
assert.throws(() => loadKernel({ backend: 'wasm', wasmPath, requireIntegrity: true }), (e) => e.code === 'integrity_unverified');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('kernel rejects hostile frames without crashing the instance', () => {
requireWasm();
const k = loadKernel({ backend: 'wasm' });
const cases = [
[() => k.analyze([{ amplitudes: [1, 2] }], { n_subcarriers: 3 }), 'invalid_request'],
[() => k.analyze([{ amplitudes: [1e300] }], { n_subcarriers: 1 }), 'invalid_request'],
[() => k.analyze([{ amplitudes: [1], evil: true }], { n_subcarriers: 1 }), 'invalid_request'],
[() => k.analyze([], { n_subcarriers: 100000 }), 'invalid_request'],
[() => k.analyze([], { sample_rate_hz: -1 }), 'invalid_request'],
[() => k.synthesize({ seconds: 3600, sample_rate_hz: 1000 }), 'limit_exceeded'],
[() => k.call('session_push', { session: 424242, frames: [] }), 'unknown_session'],
];
for (const [fn, code] of cases) assert.throws(fn, (e) => e.code === code, code);
assert.equal(k.info().abi, 1, 'instance still healthy after rejected input');
});
test('session registry is bounded per instance', () => {
requireWasm();
const k = loadKernel({ backend: 'wasm' });
const sessions = [];
let limited = false;
for (let i = 0; i < 70; i++) {
try { sessions.push(k.openSession({})); } catch (e) { assert.equal(e.code, 'limit_exceeded'); limited = true; break; }
}
assert.ok(limited, 'more than 64 concurrent sessions must be refused');
for (const s of sessions) s.close();
assert.ok(k.openSession({}).close());
});
View File
Whitespace-only changes.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ruvnet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+102
View File
@@ -0,0 +1,102 @@
# ruview
**RuView is an ambient intelligence platform, not a sensor.** It turns the radio
signals already in a room (WiFi channel state from ESP32 and Realtek nodes,
60/24 GHz mmWave radar, LiDAR) into presence, motion, breathing and pose. All
of it is camera-free and reachable from the tools people and agents already use.
The sensing stack is ahead of packaged commercial options. The bottleneck has
been packaging. This package is RuView in one install: one CLI, one MCP
server, and a live console that renders inside ChatGPT and other MCP Apps hosts.
```bash
npx ruview --help
npx ruview capabilities # every component, version and tool in this install
```
## What you get
| Component | Package | What it does |
|---|---|---|
| Operator harness | [`@ruvnet/ruview`](https://www.npmjs.com/package/@ruvnet/ruview) | Node capture and diagnostics for ESP32 and Realtek RTL8721Dx CSI, 60/24 GHz radar (raw UART or ESPHome kits such as the Seeed MR60BHA2), RPLIDAR and iPhone LiDAR, firmware flashing with boot evidence, training with a baseline/leakage gate, doctor, remote hosts over SSH |
| Homecore metaharness | [`homecore`](https://www.npmjs.com/package/homecore) | Source-cited guidance for the Homecore runtime, WASM kernel status, reviewed memory |
| Vitals kernel | [`@ruvnet/ruview-kernel`](https://www.npmjs.com/package/@ruvnet/ruview-kernel) | The Rust breathing/heart-rate pipeline as zero-import WebAssembly; `ruview esp32 --analyze` runs live CSI through it |
## Quick start
```bash
# What is plugged in? (USB VID:PID → ESP32, Realtek, radar, LiDAR)
npx ruview devices
# Live CSI from your nodes (UDP :5005), redrawn every few seconds
npx ruview esp32 --watch
# A 60 GHz radar kit running ESPHome, on your LAN
npx ruview mmwave --source esphome --host 192.168.1.50
# Diagnostics with fixes
npx ruview doctor
# Homecore and the kernel through the same CLI
npx ruview homecore guidance --topic plugins --query Wasmtime
npx ruview kernel selftest
```
A terminal gets a formatted view; pipes and `--json` get JSON.
In **Claude Code**, the bundled `ruview-live` mod puts a live sensing pane beside the conversation (`/ruview`). Run `npx ruview mod` to see how to load it (mods are early access).
## Agents: MCP, ChatGPT and MCP Apps
One server exposes the harness tools (`ruview_*`) and the Homecore tools
(`homecore_*`):
```bash
npx ruview mcp start # stdio (Claude Code, Codex, any MCP client)
```
Node captures, radar reads, device scans and doctor results render in the
**RuView console** (`ui://ruview/console-v1.html`) in ChatGPT and other MCP Apps
hosts. A Refresh button re-runs the tool in place.
For ChatGPT and other remote clients, serve MCP over HTTP:
```bash
export RUVIEW_MCP_TOKEN="$(node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))")"
RUVIEW_MCP_GRANTS=device-access npx ruview mcp start --http --port 8790
# then expose it over HTTPS (tunnel / reverse proxy) and add the connector URL
# https://<your-host>/mcp/<token>
```
**Least authority by default.**
- Hardware reads need the `device-access` grant.
- The HTTP transport requires a token.
- It binds to loopback.
- It refuses unknown browser origins.
- It **never** honours write grants: flashing and calibration stay on the
local CLI.
## Honesty rules
- **Device-reported vitals** (from radar firmware) are labelled as such and
are not validated against a reference.
- **Kernel estimates** carry "no reference measurement".
- **Synthetic data** (simulators, self-tests) is flagged `SYNTHETIC`.
- **Accuracy claims** must pass `ruview claim-check`, tagged MEASURED (with a
reproducer), CLAIMED or SYNTHETIC.
WiFi sensing is not camera-grade, and RuView never presents it that way.
## Privacy
RuView senses without cameras, and nothing leaves your network unless you send
it. The MCP HTTP transport binds to localhost unless you choose otherwise, and
the console widget makes no network requests.
## Links
- Source and architecture decisions: <https://github.com/ruvnet/RuView>
(ADR-373 device access, ADR-375 MCP Apps console/HTTP/terminal UI, ADR-376 this package)
- Issues: <https://github.com/ruvnet/RuView/issues>
MIT licensed.
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// `npx ruview` — one CLI for every RuView component (ADR-376).
// ruview homecore <cmd> → Homecore developer metaharness
// ruview kernel <subcmd> → WASM vitals kernel CLI (doctor|info|selftest|parity|synth|analyze|bench)
// ruview mcp start [--http] → one MCP server: harness + Homecore tools, ui:// console
// ruview capabilities → what this install can do
// ruview <anything else> → @ruvnet/ruview operator harness
import { realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { argv } from 'node:process';
import { KERNEL_COMMANDS, VERSION, capabilities, componentVersions, wireKernel } from '../src/index.js';
const HELP_HEADER = `ruview ${VERSION} — RuView in one install
Components:
ruview <command> operator harness (@ruvnet/ruview) — devices, radar, firmware, training, doctor
ruview homecore <command> Homecore developer metaharness (ruview homecore --help)
ruview kernel <subcommand> vitals kernel: ${KERNEL_COMMANDS.join('|')} (ruview kernel --help)
ruview mcp start [--http] one MCP server: harness + Homecore tools, ChatGPT/MCP Apps console
ruview capabilities list every component, version and tool
`;
async function mcp(rest) {
const { unifiedHandler } = await import('../src/mcp.js');
const flags = new Set(rest);
if (rest[0] !== undefined && rest[0] !== 'start') { console.error('Usage: ruview mcp start [--http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]]'); return 2; }
if (!flags.has('--http')) {
const { startMcpServer } = await import('@ruvnet/ruview/mcp');
startMcpServer({ handler: unifiedHandler, label: 'harness + homecore tools' });
return new Promise(() => {});
}
// Reuse the harness CLI's HTTP startup (flags, banner, token handling), with the unified handler.
const { run } = await import('@ruvnet/ruview/cli');
return run(['mcp', ...rest], { mcpHandler: unifiedHandler });
}
export async function run(args) {
const [cmd, ...rest] = args;
await wireKernel();
if (cmd === 'homecore') {
const { run: homecore } = await import('homecore/cli');
return homecore(rest);
}
if (cmd === 'kernel' && (KERNEL_COMMANDS.includes(rest[0]) || rest[0] === '--help' || rest[0] === '-h')) {
const { run: kernel } = await import('@ruvnet/ruview-kernel/cli');
return kernel(rest);
}
if (cmd === 'mcp') return mcp(rest);
if (cmd === 'capabilities') {
const res = await capabilities();
console.log(JSON.stringify(res, null, 2));
return 0;
}
if (cmd === '--version' || cmd === '-v') {
const versions = await componentVersions();
console.log(`ruview ${VERSION}`);
for (const [name, v] of Object.entries(versions)) console.log(` ${name} ${v ?? 'not installed'}`);
return 0;
}
const { run: harness } = await import('@ruvnet/ruview/cli');
if (cmd === '--help' || cmd === '-h') { console.log(HELP_HEADER); return harness(['--help']); }
return harness(args);
}
const invokedDirectly = (() => {
if (!argv[1]) return false;
try {
const a = realpathSync(argv[1]);
const b = realpathSync(fileURLToPath(import.meta.url));
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
} catch { return false; }
})();
if (invokedDirectly) {
run(argv.slice(2)).then((code) => process.exit(code)).catch((err) => { console.error(err); process.exit(1); });
}
+56
View File
@@ -0,0 +1,56 @@
{
"name": "ruview",
"version": "0.9.1",
"description": "RuView in one install: the WiFi-sensing operator harness (ESP32/Realtek CSI, 60/24 GHz radar incl. ESPHome kits, LiDAR, flashing, training, doctor, MCP + ChatGPT/MCP Apps console), the Homecore developer metaharness, and the WASM vitals kernel. One CLI and one MCP server.",
"type": "module",
"bin": {
"ruview": "bin/ruview.js"
},
"exports": {
".": "./src/index.js",
"./mcp": "./src/mcp.js",
"./package.json": "./package.json"
},
"files": [
"bin/",
"src/",
"README.md",
"LICENSE"
],
"scripts": {
"test": "node --test test/*.test.mjs"
},
"dependencies": {
"@ruvnet/ruview": "0.9.1",
"@ruvnet/ruview-kernel": "0.1.0",
"homecore": "0.1.0"
},
"engines": {
"node": ">=20.0.0"
},
"keywords": [
"ruview",
"wifi-sensing",
"csi",
"esp32",
"realtek",
"mmwave",
"esphome",
"mcp",
"mcp-apps",
"chatgpt",
"homecore",
"wasm"
],
"license": "MIT",
"author": "ruvnet",
"homepage": "https://github.com/ruvnet/RuView#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/ruvnet/RuView.git",
"directory": "harness/ruview-umbrella"
},
"bugs": {
"url": "https://github.com/ruvnet/RuView/issues"
}
}
+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: MIT
// `ruview` umbrella (ADR-376): one install for the RuView operator harness
// (@ruvnet/ruview), the Homecore developer metaharness (homecore) and the WASM
// vitals kernel (@ruvnet/ruview-kernel). Components load lazily so `--help`
// and the harness commands never pay for Homecore or the kernel.
import { readFileSync } from 'node:fs';
const PKG = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
export const VERSION = PKG.version;
/**
* Resolve the kernel from this package's dependency tree for every harness
* code path (esp32 --analyze, kernel self-test, doctor), including linked and
* pnpm-style installs where the harness cannot see its sibling.
*/
export async function wireKernel() {
const { setKernelImporter } = await import('@ruvnet/ruview/kernel');
setKernelImporter((specifier) => import(specifier));
}
/** Kernel CLI subcommands; `ruview kernel` with none of these stays the harness self-test. */
export const KERNEL_COMMANDS = Object.freeze(['doctor', 'info', 'selftest', 'parity', 'synth', 'analyze', 'bench']);
export const COMPONENTS = Object.freeze([
{ name: '@ruvnet/ruview', role: 'operator harness: devices, firmware, training, doctor, MCP, ChatGPT/MCP Apps console' },
{ name: 'homecore', role: 'Homecore developer metaharness: source-cited guidance, WASM kernel status, verification' },
{ name: '@ruvnet/ruview-kernel', role: 'portable vitals kernel (WASM, optional napi) used by `esp32 --analyze`' },
]);
/** Installed component versions, read from each package's own package.json. */
export async function componentVersions(importer = (s) => import(s, { with: { type: 'json' } })) {
const out = {};
for (const c of COMPONENTS) {
try {
const url = import.meta.resolve(c.name === '@ruvnet/ruview-kernel' ? `${c.name}/package.json` : c.name);
const pkgUrl = c.name === '@ruvnet/ruview-kernel' ? url : new URL('../package.json', url).href;
out[c.name] = (await importer(pkgUrl)).default.version;
} catch {
out[c.name] = null;
}
}
return out;
}
/** Everything this install can do, grouped by component (used by `ruview capabilities`). */
export async function capabilities() {
const [{ listTools: ruviewTools }, homecore, versions] = await Promise.all([
import('@ruvnet/ruview'),
import('homecore').catch(() => null),
componentVersions(),
]);
return {
ok: true,
package: 'ruview',
version: VERSION,
components: COMPONENTS.map((c) => ({ ...c, version: versions[c.name] })),
tools: {
harness: ruviewTools().map((t) => t.name),
homecore: homecore ? homecore.listTools().map((t) => t.name) : [],
kernelCli: [...KERNEL_COMMANDS],
},
entryPoints: {
cli: 'ruview <command> (ruview --help)',
homecore: 'ruview homecore <command>',
kernel: `ruview kernel ${KERNEL_COMMANDS.join('|')}`,
mcpStdio: 'ruview mcp start',
mcpHttp: 'ruview mcp start --http (ChatGPT / remote MCP; token: RUVIEW_MCP_TOKEN)',
},
};
}
+37
View File
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: MIT
// Unified MCP handler (ADR-376): the @ruvnet/ruview tool registry plus the
// Homecore tools in one server. Homecore tool names are already prefixed
// (`homecore_*`), so there is no collision. Everything that is not a Homecore
// tool call — handshake, resources (the ui:// console), ruview tools, policy —
// is answered by the harness handler unchanged.
import { handleRpc, toCallToolResult } from '@ruvnet/ruview/mcp';
import { VERSION, wireKernel } from './index.js';
await wireKernel();
let homecorePromise = null;
const loadHomecore = () => (homecorePromise ??= import('homecore').catch(() => null));
/** Merge a Homecore tool into the MCP tool shape (no UI metadata). */
function homecoreTool(t) {
return { name: t.name, ...(t.title ? { title: t.title } : {}), description: t.description, inputSchema: t.inputSchema, annotations: t.annotations };
}
export async function unifiedHandler(msg, context = {}) {
const homecore = await loadHomecore();
if (msg && msg.jsonrpc === '2.0' && msg.method === 'tools/call' && homecore && typeof msg.params?.name === 'string' && msg.params.name.startsWith('homecore_')) {
const out = await homecore.runTool(msg.params.name, msg.params.arguments || {}, { ...context, source: 'mcp' });
return { jsonrpc: '2.0', id: msg.id, result: toCallToolResult(msg.params.name, out) };
}
const reply = await handleRpc(msg, context);
if (!reply || !reply.result) return reply;
if (msg.method === 'initialize') {
reply.result.serverInfo = { ...reply.result.serverInfo, name: 'ruview', version: VERSION };
reply.result.instructions += ' Homecore developer tools (homecore_*) are included: source-cited guidance, WASM kernel status and reviewed memory.';
}
if (msg.method === 'tools/list' && homecore) {
reply.result.tools = [...reply.result.tools, ...homecore.listTools({ source: 'mcp' }).map(homecoreTool)];
}
return reply;
}
@@ -0,0 +1,97 @@
// ADR-376: the `ruview` umbrella — CLI dispatch to every component and one
// MCP server (harness + Homecore tools), over stdio and HTTP.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { unifiedHandler } from '../src/mcp.js';
import { capabilities, componentVersions, KERNEL_COMMANDS, VERSION } from '../src/index.js';
const BIN = fileURLToPath(new URL('../bin/ruview.js', import.meta.url));
const PKG = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
const cli = (args, env = {}) => spawnSync(process.execPath, [BIN, ...args], { encoding: 'utf8', env: { ...process.env, ...env }, timeout: 120000 });
const rpc = (method, params, id = 1) => unifiedHandler({ jsonrpc: '2.0', id, method, params });
test('version and capabilities cover every component', async () => {
assert.equal(VERSION, PKG.version);
const versions = await componentVersions();
for (const name of Object.keys(PKG.dependencies)) assert.ok(versions[name], `${name} resolves`);
const caps = await capabilities();
assert.ok(caps.tools.harness.includes('ruview_esp32_capture'));
assert.ok(caps.tools.harness.includes('ruview_mmwave_read'));
assert.ok(caps.tools.homecore.includes('homecore_guidance'));
assert.deepEqual(caps.tools.kernelCli, [...KERNEL_COMMANDS]);
const v = cli(['--version']);
assert.equal(v.status, 0);
assert.match(v.stdout, new RegExp(`^ruview ${VERSION.replace(/\./g, '\\.')}`));
assert.match(v.stdout, /homecore \d+\.\d+\.\d+/);
});
test('CLI dispatches to the harness, Homecore and the kernel', () => {
const help = cli(['--help']);
assert.equal(help.status, 0);
assert.match(help.stdout, /RuView in one install/);
assert.match(help.stdout, /esp32 \[--udp-port 5005\]/, 'harness help follows');
const claim = cli(['claim-check', '--text', 'presence accuracy 0.9', '--json']);
assert.equal(JSON.parse(claim.stdout).name ?? 'ruview_claim_check', 'ruview_claim_check');
const hc = cli(['homecore', 'guidance', '--topic', 'plugins', '--query', 'Wasmtime', '--limit', '1']);
assert.equal(hc.status, 0, hc.stderr);
assert.match(hc.stdout, /wasm-plugins/);
const kernel = cli(['kernel', 'info']);
assert.equal(kernel.status, 0, kernel.stderr);
assert.match(kernel.stdout, /"abi"|"version"/);
const selftest = cli(['kernel', '--backend', 'wasm', '--json']);
assert.ok([0, 1].includes(selftest.status), 'plain `kernel` stays the harness self-test');
assert.match(selftest.stdout, /SYNTHETIC/);
});
test('one MCP server lists harness + Homecore tools and routes calls', async () => {
const init = (await rpc('initialize', { protocolVersion: '2025-06-18' })).result;
assert.deepEqual([init.serverInfo.name, init.serverInfo.version], ['ruview', VERSION]);
assert.match(init.instructions, /homecore_/);
const tools = (await rpc('tools/list')).result.tools;
const names = tools.map((t) => t.name);
assert.ok(names.includes('ruview_esp32_capture') && names.includes('homecore_guidance'));
assert.equal(new Set(names).size, names.length, 'no duplicate tool names');
for (const t of tools) assert.match(t.name, /^[a-zA-Z0-9_-]{1,64}$/);
assert.ok(tools.find((t) => t.name === 'ruview_esp32_capture')._meta.ui.resourceUri.startsWith('ui://'));
const hc = (await rpc('tools/call', { name: 'homecore_guidance', arguments: { topic: 'plugins', query: 'Wasmtime', limit: 1 } }, 2)).result;
assert.equal(hc._meta['ruview/tool'], 'homecore_guidance');
assert.equal(typeof hc.structuredContent, 'object');
const rv = (await rpc('tools/call', { name: 'ruview_claim_check', arguments: { text: 'x MEASURED' } }, 3)).result;
assert.equal(rv._meta['ruview/tool'], 'ruview_claim_check');
const denied = (await unifiedHandler({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'ruview_devices_scan', arguments: {} } }, { source: 'mcp', grants: [] })).result;
assert.equal(denied.structuredContent.reason, 'authority_denied', 'harness authority model is preserved');
const res = (await rpc('resources/read', { uri: 'ui://ruview/console-v1.html' }, 5)).result;
assert.match(res.contents[0].text, /RuView console/);
});
test('stdio and HTTP transports serve the unified tool set', async (t) => {
const child = spawn(process.execPath, [BIN, 'mcp', 'start'], { stdio: ['pipe', 'pipe', 'pipe'] });
t.after(() => child.kill());
const lines = [];
child.stdout.on('data', (d) => lines.push(...String(d).trim().split('\n')));
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' })}\n`);
for (let i = 0; i < 100 && !lines.length; i++) await new Promise((r) => setTimeout(r, 50));
const stdioTools = JSON.parse(lines[0]).result.tools.map((x) => x.name);
assert.ok(stdioTools.includes('homecore_guidance') && stdioTools.includes('ruview_mmwave_read'));
const token = 'umbrella-test-token-0123456789';
const port = 20000 + Math.floor(Math.random() * 20000);
const http = spawn(process.execPath, [BIN, 'mcp', 'start', '--http', '--port', String(port)], { env: { ...process.env, RUVIEW_MCP_TOKEN: token }, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => http.kill());
let ok = false;
for (let i = 0; i < 100 && !ok; i++) {
try { ok = (await fetch(`http://127.0.0.1:${port}/healthz`)).ok; } catch { await new Promise((r) => setTimeout(r, 100)); }
}
assert.ok(ok, 'HTTP transport started');
const r = await fetch(`http://127.0.0.1:${port}/mcp/${token}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) });
const httpTools = (await r.json()).result.tools.map((x) => x.name);
assert.deepEqual(httpTools.sort(), [...stdioTools].sort());
assert.equal((await fetch(`http://127.0.0.1:${port}/mcp`, { method: 'POST', body: '{}' })).status, 401);
});
@@ -0,0 +1,53 @@
---
name: device-access
description: Reach RuView sensing hardware from the machine it is attached to — ESP32 CSI nodes (serial + UDP stream), 60 GHz MR60BHA2 / 24 GHz LD2410 mmWave radars, RPLIDAR and iPhone LiDAR — and from other machines over SSH, read-only.
---
# device-access
Hardware tools must run **on the host the device is plugged into**. A cloud
agent cannot see your USB ports. Run `npx @ruvnet/ruview` locally, start a
local Claude Code session there (`claude remote-control` in the repo if you
want to drive it from the Claude app), or add the machine as an SSH host.
## 1. Find devices
```
npx @ruvnet/ruview doctor --group python,serial,devices,remote
npx @ruvnet/ruview devices # USB VID:PID → likely ESP32 / mmWave / RPLIDAR + confirm command
```
## 2. Read each modality
| Device | Command | Confirms |
|---|---|---|
| ESP32 node (USB) | `ruview doctor --port <p> --probe`, `ruview monitor --port <p>` | chip id; CSI callbacks in the serial log (the monitor never resets the node; C6/S3 USB-Serial/JTAG builds log on the native USB port) |
| ESP32 / Realtek node (network) | `ruview esp32 --seconds 10 [--udp-port 5005]` | per-node CSI rate, sequence loss, RSSI, CSI shapes, device vitals; Realtek RAC1 CSI and heartbeat-only senders |
| Realtek RTL8721Dx (USB, PL2303) | `ruview monitor --port <p> --baud 1500000` | `RUVIEW_CSI: frame #` lines in the Ameba log |
| Live CSI → kernel | `ruview esp32 --seconds 45 --analyze [--node-id N] [--backend napi]` | kernel vitals summary at the measured frame rate (no reference measurement) |
| 60 GHz MR60BHA2 | `ruview mmwave --port <p> --model mr60bha2` | valid frames, checksum errors, presence/distance/device HR+BR |
| 24 GHz LD2410 | `ruview mmwave --port <p> --model ld2410` | target state and distances |
| RPLIDAR | `ruview lidar --source rplidar --port <p> [--baud 115200]` | points, revolutions, range, angular coverage |
| iPhone LiDAR | `export RUVIEW_LIDAR_TOKEN=…; ruview lidar --source iphone --url ws://HOST:8787/ws/lidar` | depth frame rate, confident fraction, median depth |
The UDP capture cannot share port 5005 with a running sensing server; stop
the server or have the nodes target another port.
## 3. Other machines (SSH, read-only)
```
npx @ruvnet/ruview hosts add --name lab-pi --ssh ruv@lab-pi.local
ssh ruv@lab-pi.local true # pin the host key once
npx @ruvnet/ruview hosts run --host lab-pi --tool ruview_devices_scan
npx @ruvnet/ruview hosts run --host lab-pi --tool ruview_mmwave_read --args-json '{"port":"/dev/ttyUSB0"}'
```
Remote hosts run a pinned `@ruvnet/ruview` and accept only read-only tools.
Flash, train, and calibrate on the host itself.
## Evidence
Device readouts are `MEASURED` *readings*, not accuracy results. mmWave
heart/breathing values are the radar's own estimates. Hardware validation
still means a captured log or stream from real silicon, recorded with the
command that produced it.
@@ -1,6 +1,6 @@
---
name: provision-node
description: Build, flash, and provision an ESP32-S3/C6 CSI node for RuView — firmware variant choice, ESP-IDF Windows-subprocess flow, NVS/WiFi/channel/MAC-filter overrides.
description: Build, flash, and provision an ESP32-S3/C6 CSI node for RuView — firmware variant choice, cross-platform checksum-verified esptool flashing, NVS/WiFi/channel/MAC-filter overrides, boot-log evidence.
---
# provision-node
@@ -19,17 +19,23 @@ Prebuilt binaries: GitHub release `v0.8.1-esp32` (hardware-validated on S3 QFN56
## 2. Flash
ESP-IDF v5.4 on Windows is **subprocess-only** (Git Bash/MSYS is unsupported — strip
`MSYSTEM*` env vars). Offsets for the S3 image:
Use a release flash bundle (or `release_bins/<variant>`, or an ESP-IDF `build/`).
The harness flashes on Windows, macOS, and Linux through `python -m esptool`:
```
esptool --chip esp32s3 -p <PORT> -b 460800 write_flash \
0x0 bootloader.bin 0x8000 partition-table.bin \
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node-s3-8mb.bin
npx @ruvnet/ruview doctor --group python,serial,firmware # esptool, pyserial, ports, bundle checksums
npx @ruvnet/ruview flash-plan --port <PORT> --bundle <dir> --variant s3-8mb
npx @ruvnet/ruview flash --port <PORT> --bundle <dir> --variant s3-8mb --confirm
```
(`ruview_node_flash` returns the exact pinned command rather than running an
unattended flash.)
`flash` verifies SHA256SUMS, refuses a chip that does not match the variant
(`esptool chip_id`), writes 0x0 / 0x8000 / 0xf000 / 0x20000 (NVS preserved),
then captures a boot log. `hardwareValidated: true` requires CSI callbacks in
that log. Over MCP, `ruview_node_flash` needs the `hardware-write` grant and
`confirm: true`.
Building from source still uses ESP-IDF v5.4 (Docker or the Windows subprocess
flow; Git Bash/MSYS is unsupported, so strip `MSYSTEM*` env vars).
## 3. Provision
@@ -24,6 +24,17 @@ or temporal leakage. Example honest result (ADR-181):
- **in-browser** (ADR-181) — WebGPU/WASM trainer; the active backend is shown as a badge
(honest about what's executing).
## Run it through the harness (ADR-371)
```
npx @ruvnet/ruview train-plan --mode pose-smoke # command, cwd, outputs; runs nothing
npx @ruvnet/ruview train --mode pose-smoke --confirm # SYNTHETIC pipeline smoke (libtorch 2.11 for tch 0.24)
npx @ruvnet/ruview train --mode pose --data-dir <in-repo MM-Fi dir> --confirm
npx @ruvnet/ruview train-gate --file eval-report.json # mean-pose baseline + leakage gate
```
The gate returns the only acceptable claim sentence. Quote nothing it fails.
## Before you publish a number
1. Run the mean-pose baseline on the same split.
+43 -6
View File
@@ -8,27 +8,64 @@
"ruview_verify",
"ruview_node_monitor",
"ruview_guidance",
"ruview_memory_search"
"ruview_memory_search",
"ruview_kernel_selftest",
"ruview_doctor",
"ruview_firmware_plan",
"ruview_firmware_ports",
"ruview_train_plan",
"ruview_train_gate",
"ruview_host_list"
],
"grants": {
"credential-use": {
"tools": ["ruview_spaces_list"],
"tools": [
"ruview_spaces_list"
],
"requiresConfirmation": false,
"notes": "Allows a tenant-scoped external read; OAuth refresh may rotate the local credential file."
},
"workspace-write": {
"tools": ["ruview_calibrate"],
"tools": [
"ruview_calibrate",
"ruview_train"
],
"requiresConfirmation": true
},
"hardware-write": {
"tools": ["ruview_node_flash"],
"tools": [
"ruview_node_flash"
],
"requiresConfirmation": true
},
"device-access": {
"tools": [
"ruview_devices_scan",
"ruview_esp32_capture",
"ruview_mmwave_read",
"ruview_lidar_read"
],
"requiresConfirmation": false,
"notes": "Read-only access to serial devices and the local ESP32 UDP stream; RPLIDAR scans spin the motor but write no configuration."
},
"remote-host": {
"tools": [
"ruview_host_run"
],
"requiresConfirmation": false,
"notes": "Runs one read-only tool on an operator-configured SSH host with pinned host keys; the remote tool's own grant is also required."
}
},
"agentHosts": {
"defaultMode": "read-only",
"writeRequires": ["allow-write", "confirm"],
"forbiddenFlags": ["dangerously-skip-permissions", "dangerously-bypass-approvals-and-sandbox"]
"writeRequires": [
"allow-write",
"confirm"
],
"forbiddenFlags": [
"dangerously-skip-permissions",
"dangerously-bypass-approvals-and-sandbox"
]
}
}
}
+36 -16
View File
@@ -3,7 +3,7 @@
"generator": "RuView metaharness provenance v2",
"template": "vertical:ruview",
"name": "@ruvnet/ruview",
"version": "0.5.1",
"version": "0.9.1",
"hosts": [
"claude-code",
"codex"
@@ -13,17 +13,18 @@
".claude/settings.json": "57d03e8995363bd120fb6d515702967afd0bd557797051301ff8f8156c845824",
".claude/skills/calibrate-room/SKILL.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
".claude/skills/cognitum-spaces/SKILL.md": "96ae42cc72ad31dbb2f34d59e874c4d15f2e55fc969cd1f610dc1b9a4138840e",
".claude/skills/device-access/SKILL.md": "cb6aef6bfe7283df0c2dfd08a24e856ad023cae76b1415118784b31505c63aef",
".claude/skills/onboard/SKILL.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
".claude/skills/provision-node/SKILL.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
".claude/skills/train-pose/SKILL.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
".claude/skills/provision-node/SKILL.md": "13dd6c35eac67b79116e370412b77df3509458da2c6f5d7a5938c2d008c5ed73",
".claude/skills/train-pose/SKILL.md": "8cb132e468392497377828f7974c744e4c877134ed9a87f960f77fa1221540ba",
".claude/skills/verify/SKILL.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
".harness/claims.json": "9544cee8012328eb26856a9fff38d80a73f09e48a2da7537f6c3695521b0fd54",
".harness/mcp-policy.json": "749e9f24bde85921a45b91bf6fa4ab5605675af769c04c53fe69129019662d3e",
".harness/claims.json": "b0fef8d5bb0f747f9c1c3ed952f9bf1bce0ae2a861ba93efb79fbd74500e2d21",
".harness/mcp-policy.json": "7ea639f763f31ead88d113368dd11aabe9c48a69de3314e0a337e472ac1f28e0",
".mcp/servers.json": "fec6075400f8350d8075beac8306690355c4b015425bfd0e5f52966234e9d66f",
"CLAUDE.md": "46d5514f4cbf4d94f683f76aa6d50a3dce2ec5a95fd87b9154ed4752f5ea0e16",
"CLAUDE.md": "25f9306b6caae22078cb9375108b6f6bb813d83e617739f6d9372f116b0ede5d",
"LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06",
"README.md": "ce716f07b4b93d5b86285a46cc7be1c6ff48d95ee12ac73518dbf2fb7b61d82e",
"bin/cli.js": "0c96bf65a189732a35760c88a3d441a5bd6ce53abbd3bfaa73141665825e1be1",
"README.md": "a0ee4d09ecef1b125c9d96a6ec8cdfce869bb727c986e650bb7941b21d23ddce",
"bin/cli.js": "8ebf6ca4b6d9529a5e338374c19329c1a2fc2f9e52f13b4341928b7168ee461b",
"brain/corpus/core.jsonl": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
"flywheel/evaluations.json": "ac4ff1f897a2444870cd2b8ae8aee8b1578e61467aeca4db57893f41be98a572",
"flywheel/fixture.mjs": "de71be88753d0da4695d91011b54380c994a018986fafba36cb13739307a9bce",
@@ -31,31 +32,50 @@
"flywheel/genome.json": "75db44a3cab70d9459fc8c07863f640ac1214bfaa243483939e1506d63f51214",
"flywheel/replay.mjs": "0670ca0b03701f4afe0b4bca8a3d58d481676b61a94a5b98c6a425aefb1159ab",
"flywheel/run.mjs": "6d4f97db16900c45367b6538848cbe1915af999e663720dfc51f2bb1698f1cd0",
"package.json": "5d29ef238f310c9ee5c57501ab651acc0f856f831b696ada187de71e4b5935a6",
"package.json": "01627b1b2c804c6dddfea1b366058c23c0e99111829dcd3e32967b76d0bce997",
"scripts/sync-skills.mjs": "43715dab61e204dc91bbd61755810e8fdb2f66e2b0c0bd791b4bf48a2e293565",
"scripts/update-manifest.mjs": "8f56764b8f70aed55da0c7e2417ae875b0d58d781d839b6db7f115f08af61e6b",
"scripts/verify-manifest.mjs": "6491a221762efcfeb3e749ecab243b204f17fd5bc871f3d4025597f31b8f0f10",
"skills/calibrate-room.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
"skills/cognitum-spaces.md": "96ae42cc72ad31dbb2f34d59e874c4d15f2e55fc969cd1f610dc1b9a4138840e",
"skills/device-access.md": "cb6aef6bfe7283df0c2dfd08a24e856ad023cae76b1415118784b31505c63aef",
"skills/onboard.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
"skills/provision-node.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
"skills/train-pose.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
"skills/provision-node.md": "13dd6c35eac67b79116e370412b77df3509458da2c6f5d7a5938c2d008c5ed73",
"skills/train-pose.md": "8cb132e468392497377828f7974c744e4c877134ed9a87f960f77fa1221540ba",
"skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
"src/brain.js": "0f16a75aea943acdacc430ff11d5df7ecdec9cca2ab497795ff6f33eaebdfab6",
"src/cli-ui.js": "507fbd4444c6639c69354790ed3aed2addcb826929a6b3ad7ddb752f058c1f2f",
"src/cli.js": "5e8467f0d0fb90d81347715143b3777e239165d707dd9d8c5d647294509a7fc3",
"src/devices/esp32.js": "4d7ff3d63d751222756e227a98de38c70a707e8be2a431d1b3ac604622f94225",
"src/devices/esphome.js": "83b0fbd8c9075e233a401fa9f9dad04fc911e04819e7a0c48eb32d7e35d226af",
"src/devices/lidar.js": "d753d88375358ced5174376753c276b5ca621f38ae48eac58dbb307b9004b629",
"src/devices/mmwave.js": "09d828e3b3e140926194ab83bf27e7c63cbda1cf438385c82c0e89b9d8e4b526",
"src/devices/registry.js": "26725e43adaa908b41462f492c5f6bf63e9193f3ee076e3d64ebaa012b66f6be",
"src/devices/serial-pump.js": "bb1351cd129fd00aa71a5d06104efcff5c0a56f3507e39e1df05395fe7fe7844",
"src/doctor.js": "f139c2446b469afe6f5b507685836c65ee318c8f16b1ef29819ba828bbf7828f",
"src/exec.js": "8889d13b8a8277f322856e743c79ac8f29b707251eaf9757c46d3f745743ae22",
"src/firmware.js": "1af024e8e1cd75d43f9e8ef6ff91d0413cf5f4cd84c368d1407e690b051cac4d",
"src/guardrails.js": "aacc8fa6088f7f1ccea3a0b02171a5c516b95d3416ee3ba87add3879a1d6aaad",
"src/guidance.js": "583904c854eb17e98cb7d959330989c01990a71cff091515777aba8f345de1bf",
"src/guidance.js": "1e0ccee808a736319b3f8257a3fc4fc0f8d9f8956970887d597870bb8d615949",
"src/hosts/claude-code.js": "2212bc39b49822018800dfe33a471e56bbb4c5233d716bfa7aa4fff77aa23edb",
"src/hosts/codex.js": "d41ecd132ce2db7b47aad9cebbc020d70e6810d48c3554858d099ff2e8f6608b",
"src/hosts/index.js": "ab276c41ab722bcdf72c2d1649cecbb760ae05c41c1372aae4c2447aa7c11539",
"src/mcp-server.js": "8b2ee4b939b25c1b1f507b295a43a2ebad852b8bac9d31af9bf7fb39b181c12e",
"src/policy.js": "169cc33793b91ee01a78e6403aeefff1ab5e92f33b73eb85912fe03666464975",
"src/kernel.js": "48dfd218a747dd158f82ae108e7e8ed89a82cd68649214d23266112dc9033f5d",
"src/mcp-http.js": "6eb36fa546f333e32ec6283e711d2c93914d4de48a6a3b47fce963c5ec711f5d",
"src/mcp-server.js": "5eb8c3431768932c3a85476df032d0608fc7a732ede3d6bdcc8467ca47552666",
"src/policy.js": "baad5eb8c3eb2e416e0a71b342ad850d7c8413d7d976571ebf596937f69da8e2",
"src/process-runner.js": "49533b038044dfb8bc76ed01c030d06a9856ead0836157fb693e2a7d40f786d6",
"src/redact.js": "ebf1afff46341078706b0401838c53db043603586e280d51ece5cf1feba35189",
"src/remote.js": "ca510804176205eca799b396c0f0bca72df4c08139547c66483885d38fed6815",
"src/repo-trust.js": "06e2a94d7113ed936f208a12b7fcc785801c215a3e2c5e7418f6238d991a289c",
"src/sdk.d.ts": "82e73721de433551a2b4339821ebf7b2c9d44133c766498ae91d15483ed8a073",
"src/sdk.js": "a07b5e5777ee897a50c12f6002065c3ca781e087d625541d54e5f5308ede2116",
"src/spaces.js": "45ef786537cb2a446db5e926e5a1c10b73639d2767dec84611f914f78d4325eb",
"src/tools.js": "55960c9a677661763e0317fd54ccc787c2edb39c87371c7fbc40cd55f0761c04"
"src/tools.js": "7c41347b4be063457d7759ddb8f1f5dd608cbe28baa1c80e4a27d6567af6bc98",
"src/training.js": "b8451c48db69c7d077d75f07500996213beaa52705116f64afafe084e68e3956",
"src/ui/console-widget.js": "0d2f707d57b5ec54968dee1dc80f31c7db9152dcadfc48700c18379dabb55462"
},
"filesDigest": "28a3bbd9bbcf966df9fae8ec6ea5be3441f6ab535c1636bb1ce33f67b827d423",
"filesDigest": "60935843295b67f3354b4b02cc31df62bac8b5093568b11f896dfc7fc3a91545",
"brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
"gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd",
"developmentPins": {
+1 -1
View File
@@ -1 +1 @@
478ccaff9aa249bc7ea6e20551ccc9ac88697a3cc91a7b55400337c5e939a19e manifest.json
0f73608c5acd0547ef850c0944ef76633a40e957c226ce555309832f87491a1c manifest.json
+35 -1
View File
@@ -12,13 +12,43 @@
"ruview_verify",
"ruview_node_monitor",
"ruview_guidance",
"ruview_memory_search"
"ruview_memory_search",
"ruview_kernel_selftest",
"ruview_doctor",
"ruview_firmware_plan",
"ruview_firmware_ports",
"ruview_train_plan",
"ruview_train_gate",
"ruview_host_list"
],
"guardedReadTools": {
"ruview_spaces_list": {
"grant": "credential-use",
"network": true,
"mayRefreshStoredCredential": true
},
"ruview_devices_scan": {
"grant": "device-access",
"hardware": true
},
"ruview_esp32_capture": {
"grant": "device-access",
"hardware": true
},
"ruview_mmwave_read": {
"grant": "device-access",
"hardware": true,
"network": true
},
"ruview_lidar_read": {
"grant": "device-access",
"hardware": true,
"network": true
},
"ruview_host_run": {
"grant": "remote-host",
"network": true,
"readOnlyRemoteTools": true
}
},
"dangerousTools": {
@@ -29,6 +59,10 @@
"ruview_node_flash": {
"grant": "hardware-write",
"confirm": true
},
"ruview_train": {
"grant": "workspace-write",
"confirm": true
}
}
}
+7 -1
View File
@@ -19,7 +19,13 @@ accuracy number:
`ruview_onboard`, `ruview_claim_check`, `ruview_verify`, `ruview_node_monitor`,
`ruview_calibrate`, `ruview_node_flash`, `ruview_guidance`,
`ruview_spaces_list`, `ruview_memory_search`. Start unfamiliar work with
`ruview_spaces_list`, `ruview_memory_search`, `ruview_kernel_selftest`,
`ruview_doctor`, `ruview_firmware_plan`, `ruview_firmware_ports`,
`ruview_train`, `ruview_train_plan`, `ruview_train_gate`,
`ruview_devices_scan`, `ruview_esp32_capture`, `ruview_mmwave_read`,
`ruview_lidar_read`, `ruview_host_list`, `ruview_host_run`. Device tools need
the `device-access` MCP grant; remote hosts need `remote-host`. Start
unfamiliar work with
`ruview_guidance`; its
capability status, source paths, validation commands, and limitations are
navigation evidence, not authority. All tools fail closed. Mutating/hardware
+207 -2
View File
@@ -15,7 +15,13 @@ against a baseline — that rule is enforced in code (`ruview_claim_check`).
npx @ruvnet/ruview # onboard — pick a setup path
npx @ruvnet/ruview claim-check --file REPORT.md # the honesty guardrail (non-zero exit on untagged claims)
npx @ruvnet/ruview verify # run the deterministic proof (VERDICT: PASS)
npx @ruvnet/ruview doctor # self-check (tools, adapters, local CLIs)
npx @ruvnet/ruview doctor # structured diagnostics with fixes (ADR-372)
npx @ruvnet/ruview ports # find the ESP32 serial port
npx @ruvnet/ruview flash --port COM7 --bundle ./esp32-csi-node-s3-8mb # plan only
npx @ruvnet/ruview train-plan --mode pose-smoke
npx @ruvnet/ruview train-gate --file eval-report.json
npx @ruvnet/ruview devices # ESP32 / mmWave / LiDAR on this machine
npx @ruvnet/ruview hosts run --host lab-pi --tool ruview_devices_scan
npx @ruvnet/ruview guidance --topic homecore --query "Wasmtime plugins"
npx @ruvnet/ruview spaces --resource spaces
npx @ruvnet/ruview spaces --resource events --limit 25
@@ -38,14 +44,141 @@ Exposed both as CLI verbs and as an MCP server (`npx @ruvnet/ruview mcp start`):
| `ruview_verify` | Run `verify.py` deterministic proof → VERDICT |
| `ruview_node_monitor` | Assert CSI is flowing on an ESP32 (read-only) |
| `ruview_calibrate` | ADR-151 room pipeline (baseline→enroll→train-room→room-watch) |
| `ruview_node_flash` | Build+flash firmware (Windows/ESP-IDF; mutating, guarded) |
| `ruview_node_flash` | Cross-platform esptool flash with checksum + chip preflight and boot-log evidence (mutating, guarded) |
| `ruview_firmware_plan` | Verify a firmware bundle and return the exact esptool command (read-only) |
| `ruview_firmware_ports` | Enumerate serial ports via pyserial (read-only) |
| `ruview_doctor` | Structured diagnostics: runtime, integrity, hosts, repo, Rust, Python/esptool, bundles, serial, kernel |
| `ruview_train` | Run pose-smoke / pose / room training from the checkout (writes checkpoints, guarded) |
| `ruview_train_plan` | Resolve a training command without running it (read-only) |
| `ruview_train_gate` | Mean-pose-baseline + leakage gate that returns the only publishable claim wording |
| `ruview_devices_scan` / `ruview_esp32_capture` / `ruview_mmwave_read` / `ruview_lidar_read` | Host device access: USB discovery, ESP32 UDP stream, 60/24 GHz mmWave, RPLIDAR/iPhone LiDAR (`device-access` grant) |
| `ruview_host_list` / `ruview_host_run` | SSH remote hosts, read-only tools only (`remote-host` grant) |
| `ruview_guidance` | Source-cited code map, capability maturity, validation commands, and limitations |
| `ruview_spaces_list` | OAuth-only paging for sites/buildings/floors/spaces/zones/entities/events/alerts (guarded over MCP) |
| `ruview_memory_search` | Search the reviewed, source-cited contributor brain |
| `ruview_kernel_selftest` | SYNTHETIC self-test of the optional `@ruvnet/ruview-kernel` WASM/napi-rs compute package (ADR-368) |
Every tool is **fail-closed**: missing repo / python / binary / port → an honest
negative, never a fabricated success.
### MCP authority model
Read-only tools need no grant. Over MCP, `ruview_calibrate` and `ruview_train`
need the `workspace-write` grant plus `confirm: true`; `ruview_node_flash`
needs `hardware-write` plus `confirm: true`; `ruview_spaces_list` needs
`credential-use`. The plan tools (`ruview_firmware_plan`, `ruview_train_plan`)
cannot take `confirm`, so a read-only client can review exactly what would run.
The MCP doctor cannot probe a board or contact a network host; those are
CLI/SDK options (`--probe`, `--url`).
## Device and host access (ADR-373, ADR-374)
Run these on the machine the hardware is plugged into. A cloud agent cannot
reach your USB ports; use a local session or an SSH host.
```bash
npx @ruvnet/ruview devices # classify USB serial devices by VID:PID
npx @ruvnet/ruview esp32 --seconds 10 # ESP32 / Realtek RAC1 node UDP stream (default :5005)
npx @ruvnet/ruview esp32 --seconds 45 --analyze # ...and run live CSI through @ruvnet/ruview-kernel
npx @ruvnet/ruview monitor --port COM10 --baud 1500000 # serial console without resetting the node
npx @ruvnet/ruview mmwave --port /dev/ttyUSB0 # MR60BHA2 60 GHz / LD2410 24 GHz, auto-detected
npx @ruvnet/ruview lidar --source rplidar --port /dev/ttyUSB1 # RPLIDAR scan summary
RUVIEW_LIDAR_TOKEN=… npx @ruvnet/ruview lidar --source iphone --url ws://HOST:8787/ws/lidar
```
| Tool | Access | MCP grant |
|---|---|---|
| `ruview_devices_scan` | pyserial port list + VID:PID roles (opens no port) | `device-access` |
| `ruview_esp32_capture` | receive-only UDP; CSI/vitals/feature packets per node, loss, RSSI | `device-access` |
| `ruview_mmwave_read` | serial; firmware-identical MR60BHA2/LD2410 parsers | `device-access` |
| `ruview_lidar_read` | RPLIDAR SCAN over serial, or iPhone relay WebSocket (stats only) | `device-access` |
| `ruview_host_list` | configured SSH hosts | none |
| `ruview_host_run` | one read-only tool on an SSH host | `remote-host` + the tool's grant |
Remote hosts live in `~/.config/ruview/hosts.json` (mode 0600) and are added
only from the CLI:
```bash
npx @ruvnet/ruview hosts add --name lab-pi --ssh ruv@lab-pi.local
ssh ruv@lab-pi.local true # pin the host key once; BatchMode + StrictHostKeyChecking are enforced
npx @ruvnet/ruview hosts run --host lab-pi --tool ruview_esp32_capture --args-json '{"seconds":10}'
```
The remote side runs `npx -y @ruvnet/ruview@<this version> call <tool>
--read-only`. Every argument is validated against the tool schema on both
ends and single-quoted for the remote shell. Flash, train, calibrate,
credentialed reads, and host hopping are never forwarded.
## Firmware flashing (ADR-370)
Works on Windows, macOS, and Linux through `python -m esptool` (`pip install
esptool pyserial`). The bundle directory is an extracted release flash bundle,
`firmware/esp32-csi-node/release_bins/<variant>`, or an ESP-IDF `build/`.
```bash
npx @ruvnet/ruview ports
npx @ruvnet/ruview flash-plan --port /dev/ttyUSB0 --bundle ./bundle --variant s3-8mb
npx @ruvnet/ruview flash --port /dev/ttyUSB0 --bundle ./bundle --variant s3-8mb --confirm
```
Before writing, every image is checked against `SHA256SUMS(.txt)` (a mismatch
is always refused), and `esptool chip_id` must report the chip the variant
targets. After writing, a 15 s serial capture is summarized. The result sets
`hardwareValidated: true` only when that log shows CSI callbacks without a
panic. A successful write alone is not hardware validation. WiFi provisioning
stays with `provision.py` (see the `provision-node` skill) so credentials never
pass through the harness.
## Training (ADR-371)
```bash
npx @ruvnet/ruview train-plan --mode pose-smoke # review the command
npx @ruvnet/ruview train --mode pose-smoke --confirm # SYNTHETIC smoke run (needs libtorch)
npx @ruvnet/ruview train --mode pose --data-dir data/mmfi --confirm
npx @ruvnet/ruview train --mode room --enrollment enrollment.json --confirm
npx @ruvnet/ruview train-gate --file eval-report.json # PASS/FAIL + publishable wording
```
Pose training builds `wifi-densepose-train` with `tch-backend`. It needs
libtorch: tch 0.24 expects torch 2.11.0, via `LIBTORCH` or
`LIBTORCH_USE_PYTORCH=1`. Checkpoints and trainer logs go under
`v2/target/ruview-train/<mode>` unless `--checkpoint-dir` names another path
inside the checkout. The gate refuses any score that has no mean-pose baseline,
uses a random-frame split, shares subjects across a grouped-subject split,
overlaps in time, or fails to beat the baseline.
## Debugging doctor (ADR-372)
```bash
npx @ruvnet/ruview doctor # all groups, human output
npx @ruvnet/ruview doctor --json --group python,serial,firmware
npx @ruvnet/ruview doctor --port COM7 --probe # esptool chip_id (resets the board, writes nothing)
npx @ruvnet/ruview doctor --url http://localhost:3000 # sensing-server /health
```
Each check is `pass`, `warn`, `fail`, or `skip`, and every non-passing check
carries a `fix:`. The exit code is non-zero only for failures.
## SDK (ADR-369)
```js
import { createRuView } from '@ruvnet/ruview/sdk'; // typed: src/sdk.d.ts
const ruview = createRuView(); // { strict: true } throws RuViewError on ok:false
const report = await ruview.doctor({ groups: ['python', 'serial'] });
const plan = await ruview.firmware.plan({ port: 'COM7', bundle: './bundle', variant: 'c6' });
const flashed = await ruview.firmware.flash({ port: 'COM7', bundle: './bundle', variant: 'c6', confirm: true });
const gate = await ruview.training.gate({ model_score: 0.595, baseline_score: 0.501, split: 'chronological',
train_end: '2026-03-01', test_start: '2026-03-02', n_test: 800, reproducer: 'python eval.py' });
const kernel = await ruview.kernel.load(); // optional @ruvnet/ruview-kernel
const radar = await ruview.devices.mmwave({ port: '/dev/ttyUSB0', model: 'auto' });
const nodes = await ruview.devices.esp32({ seconds: 10 });
const remote = await ruview.hosts.run('lab-pi', 'ruview_lidar_read', { source: 'rplidar', port: '/dev/ttyUSB1' });
```
The SDK calls the same policy-checked registry as the CLI and MCP server.
Mutating calls still need `confirm: true`.
### Cognitum Spaces OAuth
Activate the additional read scope through the Rust CLI, then use the same
@@ -109,6 +242,78 @@ The bundled `.claude/settings.json` registers the `ruview` MCP server
(`npx -y @ruvnet/ruview mcp start`). Drop this package's `.claude/` into a repo, or run
`npx @ruvnet/ruview install --host claude-code`.
## Claude Code mod: live sensing pane (ADR-377)
This package ships **`ruview-live`**, a Claude Code mod: a plugin whose behaviour is a function-hooks module. `/ruview` opens a pane beside the transcript, refreshed on a timer, with:
- your CSI nodes (ESP32, Realtek): rate, loss, RSSI and CSI shape;
- an optional ESPHome radar kit: presence, distance, and device-reported heart/breathing;
- alerts, such as a board that sends heartbeats but no CSI.
It also keeps a one-line status: `RuView · 2 nodes · radar present · 1 alert`.
**Showcase views (ADR-378).** The pane has three views, switched with keys `1`, `2` and `3`. The two live views are drawn in terminal cells (24-bit colour) and animated in place:
| Key | View | What you see |
|---|---|---|
| `1` | Overview | the cards above |
| `2` | CSI waterfall | per-subcarrier amplitude over time (`esp32 --spectrum`), replayed at the frames' arrival rate and labelled **MEASURED** or **SYNTHETIC** from the packets' own flag; `n` cycles nodes |
| `3` | Radar | a 120° range fan with a sonar ping out to the measured distance; heart and breathing charts, and a ♥ and breathing gauge that pulse at the device-reported rates (a metronome, not a waveform) |
The radar kit reports range, not bearing, so the arc covers every bearing at that range. Desktop and IDE surfaces show the overview, and a note in the live views.
```bash
npx @ruvnet/ruview mod # where the mod is in this install, and how to load it
claude --plugin-dir "<path printed above>" # try it for one session
# or from the RuView marketplace:
# /plugin marketplace add ruvnet/RuView
# /plugin install ruview-live@ruview
```
In Claude Code:
| Command | What it does |
|---|---|
| `/ruview` | Open or close the pane |
| `/ruview refresh` | Run one capture now; the result goes in the status line |
| `/ruview off` | Close the pane |
| `/ruview waterfall`, `/ruview radar` | Open the pane on that view |
**Settings** (`claude plugin configure ruview-live`):
- `udpPort` (default 5005);
- `radarHost`, an ESPHome kit on your private network;
- `refreshSeconds` (default 15, minimum 5);
- `liveRefreshSeconds` for the waterfall and radar views (default 4, range 2–60);
- `captureSeconds` (default 3).
**How it reads:**
- The mod only runs this package's CLI, for read-only `esp32` and `mmwave --source esphome` captures, with `--json`, so the pane shows exactly what those tools return.
- It never flashes, provisions or writes to a device.
- Like every mod, it runs inside Claude Code with Claude Code's access, so install it only from a source you trust.
Mods are early access in Claude Code. If `/ruview` is missing, start Claude Code with `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`.
## ChatGPT and MCP Apps (ADR-375)
`ruview_esp32_capture`, `ruview_devices_scan` and `ruview_doctor` render in a
self-contained console widget (`ui://ruview/console-v1.html`) in ChatGPT and
other MCP Apps hosts, with a Refresh button that re-runs the tool. Results also
carry `structuredContent`.
```bash
# MCP over HTTP: loopback, token-authenticated, read tools only
export RUVIEW_MCP_TOKEN="$(node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))")"
RUVIEW_MCP_GRANTS=device-access npx @ruvnet/ruview mcp start --http --port 8790
```
Clients that can send headers use `Authorization: Bearer <token>` on
`http://127.0.0.1:8790/mcp`. ChatGPT connectors cannot, so expose the port over
HTTPS (a tunnel or reverse proxy) and add `https://<host>/mcp/<token>` as the
connector URL. Write grants (`hardware-write`, `workspace-write`) are never
honoured over HTTP: flashing and calibration stay on the CLI and stdio.
In a terminal, commands print a formatted view; pipes and `--json` print JSON.
`ruview esp32 --watch` redraws live capture windows with per-node trends.
## Hosts
Claude Code and Codex are implemented directly and tested with the local,
+5 -216
View File
@@ -1,225 +1,14 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// `npx ruview` — the RuView WiFi-sensing operator harness (minted via metaharness,
// hardened per ADR-182). Plain ESM, no build step: ships and runs as-is.
//
// The `ruview.*` tools (onboard/verify/claim-check/…) and local host adapters are
// pure Node and run with zero runtime dependencies.
// `npx ruview` launcher. The logic lives in src/cli.js, which loads modules on
// demand (ADR-375); `run` is re-exported for tests and embedders.
import { realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { realpathSync, existsSync, readdirSync, readFileSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { argv } from 'node:process';
import { TOOLS, runTool, listTools, findRepoRoot, which } from '../src/tools.js';
import { claimCheck, summarize } from '../src/guardrails.js';
import { getHost } from '../src/hosts/index.js';
import { makeProposal, searchBrain, verifyBrain } from '../src/brain.js';
import { run } from '../src/cli.js';
const NAME = 'ruview';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const SKILLS_DIR = join(ROOT, 'skills');
// Map friendly CLI verbs → registry tool names (underscore-canonical, ADR-263).
const VERB_TO_TOOL = {
onboard: 'ruview_onboard',
verify: 'ruview_verify',
'claim-check': 'ruview_claim_check',
calibrate: 'ruview_calibrate',
monitor: 'ruview_node_monitor',
flash: 'ruview_node_flash',
guidance: 'ruview_guidance',
spaces: 'ruview_spaces_list',
};
function pjson(o) { console.log(JSON.stringify(o, null, 2)); }
function listSkills() {
if (!existsSync(SKILLS_DIR)) return [];
return readdirSync(SKILLS_DIR).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, ''));
}
async function doctor() {
const checks = [];
// Tools layer (always available, no deps).
checks.push(['tool registry loads', Object.keys(TOOLS).length > 0]);
checks.push(['claim_check flags a 100% claim',
!claimCheck('We hit 100% accuracy on poses.').ok]);
checks.push(['claim_check passes a tagged MEASURED claim',
claimCheck('Held-out PCK@20 59.5% (MEASURED vs mean-pose baseline, verify.py).').ok]);
checks.push(['skills present', listSkills().length > 0]);
checks.push(['Claude Code adapter resolves', getHost('claude-code').name === 'claude-code']);
checks.push(['Codex adapter resolves', getHost('codex').name === 'codex']);
const localHosts = [
which('claude') ? 'claude -p' : null,
which('codex') ? 'codex exec' : null,
].filter(Boolean);
const spacesBackend = which('wifi-densepose') ? 'wifi-densepose binary' : 'unavailable (install wifi-densepose)';
let ok = true;
for (const [label, pass] of checks) { console.log(`${pass ? 'PASS' : 'FAIL'} ${label}`); if (!pass) ok = false; }
console.log(`\n${NAME}: ${ok ? 'all checks passed' : 'doctor found problems'} — local hosts: ${localHosts.join(', ') || 'none on PATH (optional)'}; Spaces backend: ${spacesBackend}`);
return ok ? 0 : 1;
}
function help() {
console.log(`Usage: ${NAME} <command> [options]
Operator tools:
onboard [--path docker-demo|repo-build|live-esp32] pick a setup path
verify [--repo <dir>] run the deterministic proof (VERDICT: PASS)
claim-check --text "..." | --file <path> lint accuracy claims (the honesty guardrail)
calibrate --step baseline|enroll|train-room|room-watch
monitor --port COM8 [--seconds 12] assert CSI is flowing on a node
flash --port COM8 --variant s3-8mb [--confirm] build+flash firmware (Windows/ESP-IDF)
guidance [--topic homecore] [--query "Wasmtime"] source-cited code/capability map
spaces [--resource sites|...|alerts] [--limit 50] page OAuth-bound Cognitum spatial resources
Harness:
doctor verify tools, adapters, and local CLI discovery
skills list bundled skills
skill <name> print a skill playbook
mcp start run the ruview.* MCP server (stdio)
install --host <h> project the harness config into the current repo
agent run --host claude-code|codex --prompt "..." [--repo <dir>]
brain search --query "..." | verify | propose
--version | --help
Hosts implemented and tested locally: claude-code (-p), codex (exec)`);
return 0;
}
/** tiny flag parser: --k v / --k=v / --flag (boolean) */
function parseFlags(rest) {
const f = {};
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a.startsWith('--')) {
const eq = a.indexOf('=');
if (eq !== -1) { f[a.slice(2, eq)] = a.slice(eq + 1); }
else if (i + 1 < rest.length && !rest[i + 1].startsWith('--')) { f[a.slice(2)] = rest[++i]; }
else { f[a.slice(2)] = true; }
}
}
return f;
}
export async function run(args) {
const cmd = args[0] ?? 'onboard';
const rest = args.slice(1);
const flags = parseFlags(rest);
// Direct tool verbs.
if (VERB_TO_TOOL[cmd]) {
const toolArgs = { ...flags };
if (cmd === 'claim-check') {
if (flags.file) {
toolArgs.text = readFileSync(flags.file, 'utf8');
delete toolArgs.file;
}
// Fail closed (ADR-263 O1): an honesty gate must never PASS on no input.
if (typeof toolArgs.text !== 'string' || toolArgs.text.trim().length === 0) {
console.error('claim-check: no input — pass --text "..." or --file <path> (empty input is an error, not a PASS).');
return 2;
}
const res = await runTool('ruview_claim_check', toolArgs);
pjson(res);
return res.ok ? 0 : 1;
}
if (cmd === 'monitor' && flags.seconds) toolArgs.seconds = Number(flags.seconds);
if (cmd === 'guidance' && flags.limit) toolArgs.limit = Number(flags.limit);
if (cmd === 'calibrate' && typeof flags.args === 'string') toolArgs.args = flags.args.split(',');
if (cmd === 'spaces') {
if (flags['credentials-path'] !== undefined) toolArgs.credentials_path = flags['credentials-path'];
delete toolArgs['credentials-path'];
if (flags.limit !== undefined) toolArgs.limit = Number(flags.limit);
}
const res = await runTool(VERB_TO_TOOL[cmd], toolArgs, { source: 'cli' });
pjson(res);
return res.ok ? 0 : 1;
}
switch (cmd) {
case 'doctor': return doctor();
case 'skills': console.log(listSkills().join('\n') || '(none)'); return 0;
case 'skill': {
const n = rest[0];
const p = n && join(SKILLS_DIR, `${n}.md`);
if (!p || !existsSync(p)) { console.error(`No skill "${n}". Try: ${listSkills().join(', ')}`); return 2; }
console.log(readFileSync(p, 'utf8'));
return 0;
}
case 'mcp': {
if (rest[0] === 'start' || rest[0] === undefined) {
const { startMcpServer } = await import('../src/mcp-server.js');
startMcpServer();
return new Promise(() => {}); // run until stdin closes
}
console.error('Usage: ruview mcp start'); return 2;
}
case 'agent': {
if (rest[0] !== 'run') { console.error('Usage: ruview agent run --host claude-code|codex --prompt "..." [--repo <dir>]'); return 2; }
const hostName = String(flags.host || 'codex');
const prompt = String(flags.prompt || '');
const repo = flags.repo ? resolve(flags.repo) : findRepoRoot();
if (!repo) { console.error('agent run: trusted RuView repo not found; pass --repo <root>.'); return 2; }
if (!prompt.trim()) { console.error('agent run: --prompt is required.'); return 2; }
const allowWrite = flags['allow-write'] === true;
if (allowWrite && flags.confirm !== true) { console.error('agent run: --allow-write also requires --confirm.'); return 2; }
try {
const result = await getHost(hostName).run({
prompt, repoRoot: repo, trustedRoot: repo, allowWrite, confirm: flags.confirm === true,
});
pjson({ ok: true, host: hostName, mode: allowWrite ? 'workspace-write' : 'read-only', stdout: result.stdout, stderr: result.stderr });
return 0;
} catch (error) {
pjson({ ok: false, host: hostName, error: error.message });
return 1;
}
}
case 'brain': {
const action = rest[0] || 'search';
if (action === 'search') {
const query = String(flags.query || '');
if (!query.trim()) { console.error('brain search: --query is required.'); return 2; }
pjson({ ok: true, results: searchBrain(query, { limit: flags.limit }) }); return 0;
}
if (action === 'verify') {
const repo = flags.repo ? resolve(flags.repo) : findRepoRoot();
if (!repo) { console.error('brain verify: RuView repo not found.'); return 2; }
const result = verifyBrain({ repo }); pjson(result); return result.ok ? 0 : 1;
}
if (action === 'propose') {
const result = makeProposal(flags); pjson(result); return result.ok ? 0 : 1;
}
console.error('Usage: ruview brain search|verify|propose'); return 2;
}
case 'install': {
const host = flags.host || 'claude-code';
if (!['claude-code', 'codex'].includes(host)) {
console.error(`Host "${host}" is not implemented. Supported: claude-code, codex.`);
return 2;
}
try {
const adapter = getHost(host);
console.log(`Projecting RuView harness for host "${host}" via ${adapter.name}.`);
console.log('Add to your host config — MCP server command: npx -y ruview mcp start');
console.log('Skills:', listSkills().join(', '));
return 0;
} catch {
console.error(`Host adapter "${host}" is unavailable.`);
return 1;
}
}
case 'tools': pjson(listTools()); return 0;
case '--version': case '-v': {
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
console.log(pkg.version); return 0;
}
case '--help': case '-h': return help();
default:
console.error(`Unknown command: ${cmd}. Try \`${NAME} --help\`.`);
return 2;
}
}
export { run };
// CLI guard: run only when invoked directly (realpath both sides — npm/npx shims
// pass a non-normalized, possibly case-skewed argv[1] on Windows).
@@ -0,0 +1,43 @@
{
"name": "ruview-live",
"version": "0.1.0",
"description": "RuView live sensing in Claude Code: /ruview opens a pane beside the transcript with three views: an overview of your CSI nodes (ESP32, Realtek) and 60 GHz radar (rates, loss, RSSI, presence, device-reported vitals, alerts), a live CSI amplitude waterfall, and a radar range fan with vitals charts, all drawn in terminal cells. Reads only; runs the @ruvnet/ruview CLI it ships beside.",
"author": {
"name": "ruvnet",
"url": "https://github.com/ruvnet/RuView"
},
"homepage": "https://github.com/ruvnet/RuView",
"license": "MIT",
"userConfig": {
"udpPort": {
"type": "number",
"title": "UDP port",
"description": "Port your CSI nodes stream to (the sensing server's default is 5005). The pane listens briefly on each refresh; it reports port_in_use when the sensing server holds it.",
"default": 5005
},
"radarHost": {
"type": "string",
"title": "Radar kit host",
"description": "IP or hostname of an ESPHome radar kit (e.g. a Seeed MR60BHA2) on your private network. Empty skips the radar.",
"default": ""
},
"refreshSeconds": {
"type": "number",
"title": "Refresh interval (s)",
"description": "Seconds between refreshes while the pane is open (minimum 5).",
"default": 15
},
"captureSeconds": {
"type": "number",
"title": "Capture window (s)",
"description": "Seconds each refresh listens for node packets and radar states (1-10).",
"default": 3
},
"liveRefreshSeconds": {
"type": "number",
"title": "Live view refresh (s)",
"description": "Seconds between refreshes in the waterfall and radar views (2-60).",
"default": 4
}
}
}
+73
View File
@@ -0,0 +1,73 @@
// SPDX-License-Identifier: MIT
// ruview-live animation frames (ADR-378): pure functions of time that the
// hooks module repaints in place with `$.ui.blit` (no redraw). Decoration is
// labelled as decoration; anything driven by a reading says which reading.
import { Grid, rgb } from './raster.mjs';
const mix = (a, b, f) => rgb(
((a >> 16) & 255) + ((((b >> 16) & 255) - ((a >> 16) & 255)) * f),
((a >> 8) & 255) + ((((b >> 8) & 255) - ((a >> 8) & 255)) * f),
(a & 255) + (((b & 255) - (a & 255)) * f),
);
const CYAN = rgb(0, 190, 220);
const EMERALD = rgb(40, 210, 140);
const DARK = rgb(16, 22, 30);
/** A one-row cyan→emerald rule with a highlight sweeping across it (pure decoration). */
export function shimmer(columns, tMs) {
const g = new Grid(columns, 1);
const head = ((tMs / 2400) % 1) * (g.columns + 24) - 12;
for (let x = 0; x < g.columns; x++) {
const base = mix(CYAN, EMERALD, x / Math.max(1, g.columns - 1));
const glow = Math.max(0, 1 - Math.abs(x - head) / 10);
g.set(x, 0, 0x2501, mix(mix(DARK, base, 0.55), rgb(235, 255, 250), glow * glow));
}
return g;
}
/** Phase in [0, 1) of a rhythm at `perMinute` (0 when unknown). */
export function phaseOf(tMs, perMinute) {
if (!Number.isFinite(perMinute) || perMinute <= 0) return 0;
return ((tMs / 60000) * perMinute) % 1;
}
/**
* A heart icon that beats, and a breathing gauge that fills and empties, at
* the device-reported rates: a metronome of the reading, not a waveform.
* Unknown rates draw a still, dim strip.
*/
export function pulse(columns, tMs, { heartBpm = null, breathingBpm = null } = {}) {
const g = new Grid(columns, 1);
const beat = Number.isFinite(heartBpm) ? Math.exp(-phaseOf(tMs, heartBpm) * 7) : 0;
g.set(0, 0, 0x2665, Number.isFinite(heartBpm) ? mix(rgb(90, 20, 35), rgb(255, 90, 120), beat) : rgb(70, 76, 84));
const width = g.columns - 3;
const breath = Number.isFinite(breathingBpm) ? 0.5 - 0.5 * Math.cos(2 * Math.PI * phaseOf(tMs, breathingBpm)) : 0;
const fill = breath * width;
for (let x = 0; x < width; x++) {
const f = Math.max(0, Math.min(1, fill - x));
const on = mix(rgb(40, 120, 200), rgb(140, 220, 255), x / Math.max(1, width));
g.set(x + 3, 0, f > 0 ? 0x2588 : 0x2500, f > 0 ? mix(DARK, on, f) : rgb(40, 46, 54));
}
return g;
}
/**
* Waterfall replay: frames arrive a capture at a time, so the view shows them
* at their arrival rate instead of jumping. `lag` is how many received frames
* are not shown yet; it drains at `rateHz` and never exceeds what is held.
*/
export function drain(lag, rateHz, dtMs) {
if (!Number.isFinite(lag) || lag <= 0) return 0;
const rate = Number.isFinite(rateHz) && rateHz > 0 ? rateHz : 10;
return Math.max(0, lag - (rate * dtMs) / 1000);
}
/** Frames to show now: all but the `lag` newest. */
export function shownFrames(frames, lag) {
const hold = Math.min(frames.length, Math.max(0, Math.floor(lag || 0)));
return hold ? frames.slice(0, frames.length - hold) : frames;
}
/** Sonar ping phase for the radar fan: a ring leaving the sensor every 1.6 s. */
export const pingOf = (tMs) => (tMs % 1600) / 1600;
+4
View File
@@ -0,0 +1,4 @@
{
"description": "RuView live pane: /ruview and its Pane drawing, a timed refresh from the @ruvnet/ruview CLI (node capture and ESPHome radar, read-only), and a one-line status",
"modules": ["./register.mjs"]
}
+169
View File
@@ -0,0 +1,169 @@
// SPDX-License-Identifier: MIT
// ruview-live data: settings, CLI argv, result parsing, the pane model, trend
// history and the status line. Pure functions, tested under plain Node.
const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/;
export const HISTORY = 48;
export const MODES = Object.freeze(['overview', 'waterfall', 'radar']);
/**
* The harness CLI beside this mod: the plugin root is <pkg>/mod, so the CLI is
* <pkg>/bin/cli.js. Mods import nothing but their own files, so the engine's
* `$.plugin.root` locates it.
*/
export function cliPathOf(pluginRoot) {
return `${String(pluginRoot).replace(/[\\/]+$/, '')}/../bin/cli.js`;
}
/** Normalise plugin options (userConfig) into bounded settings. */
export function settingsOf(options = {}) {
const num = (v, d, lo, hi) => {
const n = Number(v);
return Number.isFinite(n) ? Math.min(Math.max(Math.round(n), lo), hi) : d;
};
const host = typeof options.radarHost === 'string' ? options.radarHost.trim() : '';
return {
udpPort: num(options.udpPort, 5005, 1024, 65535),
radarHost: HOST_RE.test(host) ? host : '',
refreshMs: num(options.refreshSeconds, 15, 5, 3600) * 1000,
captureSeconds: num(options.captureSeconds, 3, 1, 10),
liveRefreshMs: num(options.liveRefreshSeconds, 4, 2, 60) * 1000,
};
}
/**
* argv for one capture and (optionally) one radar read. In a live view the
* capture is shorter, and the waterfall asks for binned amplitude frames.
*/
export function commandsOf(settings, { live = false, spectrum = null } = {}) {
const s = String(live ? Math.min(settings.captureSeconds, 2) : settings.captureSeconds);
const capture = ['esp32', '--seconds', s, '--udp-port', String(settings.udpPort), '--json'];
if (spectrum) capture.push('--spectrum', '--spectrum-bins', String(spectrum.bins), '--spectrum-frames', String(spectrum.frames));
return {
capture,
radar: settings.radarHost ? ['mmwave', '--source', 'esphome', '--host', settings.radarHost, '--seconds', s, '--json'] : null,
};
}
/** Parse a CLI run into its JSON result, or an honest failure. */
export function resultOf(run) {
if (!run) return null;
try {
const parsed = JSON.parse(run.stdout);
if (parsed && typeof parsed === 'object') return parsed;
} catch { /* fall through */ }
return { ok: false, reason: 'cli_error', detail: String(run.stderr || run.stdout || `exit ${run.exitCode}`).trim().slice(0, 300) };
}
const fixed = (v, d = 1) => (typeof v === 'number' && Number.isFinite(v) ? v.toFixed(d) : '—');
const pct = (v) => (typeof v === 'number' ? `${(v * 100).toFixed(1)}%` : '—');
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
/** Resting ranges outside which a device-reported vital is flagged, not trusted. */
export const RANGES = Object.freeze({ heart: [40, 180], breathing: [4, 40] });
/** Why a device-reported vital looks implausible, or null. */
export function plausibilityOf(kind, value) {
if (value == null || !RANGES[kind]) return null;
const [lo, hi] = RANGES[kind];
return value < lo || value > hi ? `outside ${lo}–${hi} bpm` : null;
}
const SPARK = '▁▂▃▄▅▆▇█';
/** Unicode sparkline of the last `width` finite values ('' below two points). */
export function sparkline(values, width = 16) {
const v = (values || []).filter((x) => typeof x === 'number' && Number.isFinite(x)).slice(-width);
if (v.length < 2) return '';
const lo = Math.min(...v);
const hi = Math.max(...v);
return v.map((x) => SPARK[hi === lo ? 3 : Math.round(((x - lo) / (hi - lo)) * 7)]).join('');
}
/** "12s ago" / "3m ago" from a millisecond age, or '' when unknown. */
export function ageOf(ms) {
if (!Number.isFinite(ms) || ms < 0) return '';
const s = Math.round(ms / 1000);
return s < 60 ? `${s}s ago` : `${Math.round(s / 60)}m ago`;
}
/** The pane's model: plain data, drawn by the views and summarised by statusOf. */
export function modelOf(capture, radar, at) {
const alerts = [];
// "No packets" is shown inside the nodes card; only real faults become alerts.
if (capture && capture.ok === false && capture.reason !== 'no_packets') {
alerts.push({ level: 'bad', text: `nodes: ${String(capture.reason).replace(/_/g, ' ')}${capture.remedy ? ` — ${capture.remedy}` : ''}` });
}
for (const hb of capture?.heartbeatOnlySenders || []) alerts.push({ level: 'warn', text: `${hb.address} sends heartbeats but no CSI (run \`ruview monitor --baud 1500000\` on it: "lack of csi buf" = buffer starvation)` });
if (radar && radar.ok === false) alerts.push({ level: 'bad', text: `radar: ${String(radar.reason).replace(/_/g, ' ')}${radar.detail ? ` — ${radar.detail}` : ''}` });
const nodes = (capture?.nodes || []).map((n) => ({
key: `${n.source}:${n.nodeId}`,
label: `${n.source === 'realtek' ? 'realtek' : 'esp32'} ${n.nodeId}`,
rateHz: num(n.csiRateHz),
rate: `${fixed(n.csiRateHz)} Hz`,
loss: pct(n.csiLossFraction),
rssi: `${fixed(n.rssiMean)} dBm`,
shape: n.csi?.shape || '—',
lossy: typeof n.csiLossFraction === 'number' && n.csiLossFraction > 0.05,
synthetic: Boolean(n.csi?.synthetic),
}));
const spectrum = (capture?.spectrum || []).map((s) => ({
key: `${s.source}:${s.nodeId}`,
label: `${s.source === 'realtek' ? 'realtek' : 'esp32'} ${s.nodeId}`,
shape: s.shape ?? null, subcarriers: s.subcarriers, bins: s.bins, frames: Array.isArray(s.frames) ? s.frames : [],
rateHz: num(s.rateHz), synthetic: Boolean(s.synthetic),
}));
const radarRow = radar && radar.ok !== false ? {
name: radar.device?.name || radar.host || 'radar',
present: radar.presentNow ?? (radar.presentFraction == null ? null : radar.presentFraction >= 0.5),
targets: num(radar.targetsMax),
distanceCm: num(radar.distanceCmMean),
heartBpm: num(radar.heartBpmMean),
breathingBpm: num(radar.breathingBpmMean),
distance: radar.distanceCmMean == null ? '—' : `${fixed(radar.distanceCmMean)} cm`,
heart: radar.heartBpmMean == null ? '—' : `${fixed(radar.heartBpmMean)} bpm`,
breathing: radar.breathingBpmMean == null ? '—' : `${fixed(radar.breathingBpmMean)} bpm`,
} : null;
return {
at, packets: capture?.packets ?? 0, decoded: capture?.decodedPackets ?? 0,
noPackets: capture?.reason === 'no_packets', nodes, spectrum, radar: radarRow, alerts,
};
}
/** Append one model to a bounded history of trend values. */
export function historyWith(history, model, max = HISTORY) {
const h = history || { heart: [], breathing: [], distance: [], rates: {} };
const cap = (arr, v) => [...arr, v].slice(-max);
const rates = { ...h.rates };
for (const n of model?.nodes || []) rates[n.key] = cap(rates[n.key] || [], n.rateHz);
return {
heart: cap(h.heart, model?.radar?.heartBpm ?? null),
breathing: cap(h.breathing, model?.radar?.breathingBpm ?? null),
distance: cap(h.distance, model?.radar?.distanceCm ?? null),
rates,
};
}
/**
* Carry each node's waterfall frames across captures, so the picture scrolls
* instead of restarting every refresh. Only nodes in this capture remain: a
* node that stopped streaming (or a failed capture) leaves the waterfall, so
* old frames are never shown as live. A node whose CSI shape or bin count
* changed starts over rather than joining two subcarrier layouts.
*/
export function spectrumWith(previous, spectrum, max = 128) {
const byKey = new Map((previous || []).map((s) => [s.key, s]));
return (spectrum || []).map((s) => {
const old = byKey.get(s.key);
const same = old && old.bins === s.bins && old.shape === s.shape;
return { ...s, frames: (same ? [...old.frames, ...s.frames] : [...s.frames]).slice(-max) };
});
}
/** One line for the status bar. */
export function statusOf(model) {
if (!model) return 'RuView · starting';
const parts = [`RuView · ${model.nodes.length} node${model.nodes.length === 1 ? '' : 's'}`];
if (model.radar) parts.push(`radar ${model.radar.present == null ? '?' : model.radar.present ? 'present' : 'clear'}`);
if (model.alerts.length) parts.push(`${model.alerts.length} alert${model.alerts.length === 1 ? '' : 's'}`);
return parts.join(' · ');
}
+257
View File
@@ -0,0 +1,257 @@
// SPDX-License-Identifier: MIT
// Cell graphics for the ruview-live showcase (ADR-378): every picture is a
// terminal `Raster`, a grid of width-1 characters with 24-bit colours, so it
// draws in any terminal (Windows Terminal included), not only pixel-capable
// ones. Pure functions: no engine calls, so Node tests can check them.
/** The terminal's own colour (bit 24 alone), per the Raster contract. */
export const DEFAULT = 0x01000000;
/** 0xRRGGBB from components. */
export const rgb = (r, g, b) => (((r & 255) << 16) | ((g & 255) << 8) | (b & 255)) >>> 0;
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
/** Standard padded base64 of a byte array (the mod environment has no Node Buffer). */
export function toBase64(bytes) {
let out = '';
let i = 0;
for (; i + 2 < bytes.length; i += 3) {
const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + B64[n & 63];
}
const rest = bytes.length - i;
if (rest === 1) {
const n = bytes[i] << 16;
out += `${B64[(n >> 18) & 63]}${B64[(n >> 12) & 63]}==`;
} else if (rest === 2) {
const n = (bytes[i] << 16) | (bytes[i + 1] << 8);
out += `${B64[(n >> 18) & 63]}${B64[(n >> 12) & 63]}${B64[(n >> 6) & 63]}=`;
}
return out;
}
/** A grid of cells: code point, foreground, background. */
export class Grid {
constructor(columns, rows) {
this.columns = Math.max(1, Math.min(512, Math.floor(columns)));
this.rows = Math.max(1, Math.min(256, Math.floor(rows)));
this.cells = new Uint32Array(this.columns * this.rows * 3);
for (let i = 0; i < this.columns * this.rows; i++) {
this.cells[i * 3] = 0x20;
this.cells[i * 3 + 1] = DEFAULT;
this.cells[i * 3 + 2] = DEFAULT;
}
}
set(x, y, ch, fg = DEFAULT, bg = DEFAULT) {
if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) return;
const i = (Math.floor(y) * this.columns + Math.floor(x)) * 3;
this.cells[i] = typeof ch === 'number' ? ch : ch.codePointAt(0);
this.cells[i + 1] = fg >>> 0;
this.cells[i + 2] = bg >>> 0;
}
get(x, y) {
const i = (y * this.columns + x) * 3;
return [this.cells[i], this.cells[i + 1], this.cells[i + 2]];
}
/** Write a string left to right (labels over a picture). */
text(x, y, str, fg = DEFAULT, bg = DEFAULT) {
[...str].forEach((ch, k) => this.set(x + k, y, ch, fg, bg));
}
/** Props for the surface's `Raster` element. */
toRaster(key) {
const bytes = new Uint8Array(this.cells.buffer, this.cells.byteOffset, this.cells.byteLength);
return { key, columns: this.columns, rows: this.rows, cells: toBase64(bytes) };
}
}
// Perceptual-ish ramp on near-black: indigo → cyan → emerald → amber → white.
const STOPS = [
[0.0, [10, 12, 22]], [0.22, [36, 38, 120]], [0.45, [0, 150, 200]],
[0.65, [30, 205, 140]], [0.85, [235, 205, 70]], [1.0, [255, 250, 235]],
];
/** Colour for t in [0, 1]. */
export function colormap(t) {
const x = Math.max(0, Math.min(1, Number.isFinite(t) ? t : 0));
for (let k = 1; k < STOPS.length; k++) {
const [t1, c1] = STOPS[k];
if (x <= t1) {
const [t0, c0] = STOPS[k - 1];
const f = (x - t0) / (t1 - t0 || 1);
return rgb(c0[0] + (c1[0] - c0[0]) * f, c0[1] + (c1[1] - c0[1]) * f, c0[2] + (c1[2] - c0[2]) * f);
}
}
return rgb(255, 250, 235);
}
/** The values at fractions p of a sample (robust colour limits). */
export function percentiles(values, ps) {
const v = values.filter((x) => Number.isFinite(x)).sort((a, b) => a - b);
if (!v.length) return ps.map(() => 0);
return ps.map((p) => v[Math.min(v.length - 1, Math.max(0, Math.round(p * (v.length - 1))))]);
}
/**
* Waterfall of amplitude frames (newest last): subcarrier bins across, time
* down, two frames per text row through the upper-half block (fg = upper
* frame, bg = lower frame). Colour limits are the 5th–95th percentile of the
* shown frames. Returns { grid, lo, hi, shown }.
*/
export function waterfall(frames, columns, rows) {
const g = new Grid(columns, rows);
const shown = (frames || []).slice(-(g.rows * 2));
const [lo, hi] = percentiles(shown.flat(), [0.05, 0.95]);
const span = hi - lo || 1;
const offset = g.rows * 2 - shown.length; // empty frame slots at the top
const colourAt = (slot, x) => {
const f = shown[slot - offset];
if (!f || !f.length) return rgb(10, 12, 22);
const bin = Math.min(f.length - 1, Math.floor((x * f.length) / g.columns));
return colormap((f[bin] - lo) / span);
};
for (let y = 0; y < g.rows; y++) {
for (let x = 0; x < g.columns; x++) g.set(x, y, 0x2580, colourAt(y * 2, x), colourAt(y * 2 + 1, x));
}
return { grid: g, lo, hi, shown: shown.length };
}
/** A one-row colour bar for a legend. */
export function colourbar(columns) {
const g = new Grid(columns, 1);
for (let x = 0; x < g.columns; x++) g.set(x, 0, 0x2588, colormap(x / Math.max(1, g.columns - 1)));
return g;
}
const BRAILLE_BITS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
/** A braille canvas: 2×4 dots per cell, one colour per cell (highest priority wins). */
export class Braille {
constructor(columns, rows) {
this.columns = Math.max(1, Math.min(512, Math.floor(columns)));
this.rows = Math.max(1, Math.min(256, Math.floor(rows)));
this.width = this.columns * 2;
this.height = this.rows * 4;
this.bits = new Uint8Array(this.columns * this.rows);
this.colour = new Uint32Array(this.columns * this.rows).fill(DEFAULT);
this.prio = new Int8Array(this.columns * this.rows).fill(-1);
this.bg = new Uint32Array(this.columns * this.rows).fill(DEFAULT);
}
plot(px, py, colour = DEFAULT, prio = 0) {
const x = Math.round(px);
const y = Math.round(py);
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
const c = (y >> 2) * this.columns + (x >> 1);
this.bits[c] |= BRAILLE_BITS[y & 3][x & 1];
if (prio >= this.prio[c]) { this.prio[c] = prio; this.colour[c] = colour; }
}
/** A straight segment between two dot positions. */
line(x0, y0, x1, y1, colour, prio = 0) {
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))));
for (let s = 0; s <= steps; s++) this.plot(x0 + ((x1 - x0) * s) / steps, y0 + ((y1 - y0) * s) / steps, colour, prio);
}
/** Shade a cell's background (bands, glows). */
shade(cx, cy, colour) {
if (cx < 0 || cy < 0 || cx >= this.columns || cy >= this.rows) return;
this.bg[cy * this.columns + cx] = colour;
}
toGrid() {
const g = new Grid(this.columns, this.rows);
for (let y = 0; y < this.rows; y++) {
for (let x = 0; x < this.columns; x++) {
const c = y * this.columns + x;
g.set(x, y, this.bits[c] ? 0x2800 + this.bits[c] : 0x20, this.colour[c], this.bg[c]);
}
}
return g;
}
}
const GREY = rgb(70, 76, 84);
const DIM = rgb(110, 118, 128);
/**
* Top-down radar fan: the sensor at the bottom centre, range rings, the field
* of view as a wedge, and the measured distance as an arc across it (this kit
* reports range, not bearing), with older distances as fading arcs.
* history: distances in cm, oldest first; present: boolean|null.
*/
export function radarFan(columns, rows, { distances = [], present = null, fovDeg = 120, ping = null } = {}) {
const c = new Braille(columns, rows);
const ox = c.width / 2;
const oy = c.height - 1;
const known = distances.filter((d) => Number.isFinite(d) && d > 0);
const latest = known.length ? known[known.length - 1] : null;
const maxCm = Math.max(150, Math.min(600, Math.ceil(((latest ?? 100) * 1.4) / 50) * 50));
const half = (fovDeg * Math.PI) / 360;
// Dots per cm (dots are about square): the wedge's half-width is r·sin(half).
const scale = Math.min(oy, ox / Math.sin(Math.min(half, Math.PI / 2))) / maxCm;
const arc = (rCm, colour, prio, density = 1) => {
const r = rCm * scale;
const steps = Math.max(8, Math.ceil(r * 2 * half * density));
for (let s = 0; s <= steps; s++) {
const a = -half + (2 * half * s) / steps;
c.plot(ox + r * Math.sin(a), oy - r * Math.cos(a), colour, prio);
}
};
// Wedge edges and rings every 50 cm.
for (const a of [-half, half]) c.line(ox, oy, ox + maxCm * scale * Math.sin(a), oy - maxCm * scale * Math.cos(a), GREY, 0);
for (let r = 50; r <= maxCm; r += 50) arc(r, GREY, 0, 0.35);
// Trail: older distances fade from teal to near-background.
const trail = known.slice(-8, -1);
trail.forEach((d, k) => {
const f = (k + 1) / (trail.length + 1);
arc(d, rgb(20 + 20 * f, 70 + 90 * f, 80 + 80 * f), 1);
});
// Ping (animation): a ring leaving the sensor and reaching the measured range.
if (ping != null && latest != null) {
const f = Math.max(0, Math.min(1, ping));
const fade = 0.35 + 0.65 * f;
arc(latest * f, rgb(40 * fade, 160 * fade, 200 * fade), 2, 1);
}
const hit = ping != null && ping > 0.92;
if (latest != null) arc(latest, hit ? rgb(220, 255, 235) : present ? rgb(80, 255, 150) : rgb(230, 235, 240), 3, 1.6);
// Sensor marker.
c.plot(ox, oy, rgb(255, 220, 90), 4);
c.plot(ox - 1, oy, rgb(255, 220, 90), 4);
const g = c.toGrid();
// Ring labels along the right edge of the wedge.
for (let r = 100; r <= maxCm; r += 100) {
const px = ox + r * scale * Math.sin(half);
const py = oy - r * scale * Math.cos(half);
g.text(Math.min(g.columns - 3, Math.round(px / 2) + 1), Math.max(0, Math.round(py / 4)), `${r / 100}m`, DIM);
}
return { grid: g, maxCm };
}
/**
* Braille line chart of a series (oldest first, nulls skipped). The scale fits
* the data (at least `minSpan` wide), so a rhythm stays visible; a plausible
* band is shaded where it overlaps the scale. Returns { grid, lo, hi }.
*/
export function lineChart(series, columns, rows, { band = null, colour = rgb(0, 200, 220), minSpan = 2 } = {}) {
const c = new Braille(columns, rows);
const pts = (series || []).map((v, i) => [i, v]).filter(([, v]) => Number.isFinite(v)).slice(-c.width);
const vals = pts.map(([, v]) => v);
let lo = vals.length ? Math.min(...vals) : (band ? band[0] : 0);
let hi = vals.length ? Math.max(...vals) : (band ? band[1] : 1);
const pad = Math.max(0, minSpan - (hi - lo)) / 2 + (hi - lo) * 0.1;
lo -= pad;
hi += pad;
if (hi - lo < 1e-9) { lo -= 1; hi += 1; }
const yOf = (v) => (c.height - 1) - ((v - lo) / (hi - lo)) * (c.height - 1);
if (band && band[1] >= lo && band[0] <= hi) {
const top = Math.max(0, Math.floor(yOf(Math.min(band[1], hi)) / 4));
const bottom = Math.min(c.rows - 1, Math.floor(yOf(Math.max(band[0], lo)) / 4));
for (let y = top; y <= bottom; y++) for (let x = 0; x < c.columns; x++) c.shade(x, y, rgb(14, 34, 30));
}
const n = pts.length;
const xOf = (k) => (n <= 1 ? c.width - 1 : (k * (c.width - 1)) / (n - 1));
for (let k = 0; k < n; k++) {
const y = yOf(pts[k][1]);
if (k > 0) c.line(xOf(k - 1), yOf(pts[k - 1][1]), xOf(k), y, colour, 1);
else c.plot(xOf(k), y, colour, 1);
}
if (n) c.plot(xOf(n - 1), yOf(pts[n - 1][1]), rgb(255, 255, 255), 2);
return { grid: c.toGrid(), lo, hi };
}
+242
View File
@@ -0,0 +1,242 @@
// SPDX-License-Identifier: MIT
// ruview-live — a Claude Code mod (function hooks) shipped inside @ruvnet/ruview.
//
// `/ruview` opens a pane beside the transcript with three views: an overview of
// the CSI nodes streaming to this machine and an optional ESPHome radar kit; a
// live CSI amplitude waterfall; and a radar fan with vitals charts (ADR-377,
// ADR-378). Every reading comes from the @ruvnet/ruview CLI this mod ships
// beside (`--json`), so the pane shows exactly what the tested tools return.
// Read-only: it never flashes, provisions or writes to a device.
import { commandsOf, cliPathOf, historyWith, MODES, modelOf, resultOf, settingsOf, spectrumWith, statusOf } from './model.mjs';
import { drain } from './anim.mjs';
import { ANIMATED, picturesOf, sizesOf, viewOf } from './views.mjs';
export * from './anim.mjs';
export * from './model.mjs';
export * from './raster.mjs';
export { ANIMATED, picturesOf, sizesOf, viewOf } from './views.mjs';
export const PANE_ID = 'ruview-live';
export const COMMAND = 'ruview';
const TICK_MS = 5000;
/** Body rows each view asks for when the pane sits inline above the prompt. */
export const VIEW_ROWS = Object.freeze({ overview: 16, waterfall: 30, radar: 26 });
/**
* The open request: a dialog (focus + closeOnEscape + holdToasts) takes the
* keyboard, so the view keys work at once; `rows` sizes the inline pane to
* the view instead of a third of the screen (the dock ignores it).
*/
export const openArgsOf = (mode) => ({
id: PANE_ID, title: 'RuView', focus: true, closeOnEscape: true, holdToasts: true, rows: VIEW_ROWS[mode] ?? VIEW_ROWS.overview,
});
/** Animation frame period: ~12 fps, well inside blit's 60 shown a second. */
export const FRAME_MS = 80;
/** Spectrum request for the waterfall: one bin per picture column (at most 128). */
export function spectrumOf(columns, rows) {
const { width, height } = sizesOf(columns, rows);
return { bins: Math.max(8, Math.min(128, width)), frames: Math.max(8, Math.min(256, height * 2)) };
}
/**
* The mod entry: `/ruview [off|refresh|overview|waterfall|radar]`, the pane,
* the timers, the status.
* @param on the engine's registrar
* @param options this plugin's userConfig values
*/
export function register(on, options = {}) {
const settings = settingsOf(options);
let host = null;
let isOpen = false;
let busy = false;
let model = null;
let history = null;
let spectrum = [];
let mode = 'overview';
let nodeIndex = 0;
let size = { columns: 100, rows: 30 };
let stopTimer = null;
let stopTick = null;
let stopAnim = null;
let animating = false;
let lastFrame = null;
let lag = {};
let cliPath = null;
const intervalOf = () => (mode === 'overview' ? settings.refreshMs : settings.liveRefreshMs);
const stop = () => {
if (stopTimer) { stopTimer(); stopTimer = null; }
if (stopTick) { stopTick(); stopTick = null; }
if (stopAnim) { stopAnim(); stopAnim = null; }
};
const cancelOf = (t) => (typeof t === 'function' ? t : () => t?.cancel?.());
async function runCli(args) {
if (!args) return null;
try {
return await host.run(['node', cliPath, ...args], { timeoutMs: (settings.captureSeconds + 30) * 1000 });
} catch (error) {
return { exitCode: -1, stdout: '', stderr: String(error?.message || error) };
}
}
async function refresh() {
if (!host || busy) return;
busy = true;
host.invalidate();
try {
const commands = commandsOf(settings, {
live: mode !== 'overview',
spectrum: mode === 'waterfall' ? spectrumOf(size.columns, size.rows) : null,
});
const [capture, radar] = await Promise.all([runCli(commands.capture), runCli(commands.radar)]);
// $.clock.now() resolves a promise of epoch milliseconds.
model = modelOf(resultOf(capture), resultOf(radar), await host.now());
history = historyWith(history, model);
spectrum = spectrumWith(spectrum, model.spectrum);
lag = Object.fromEntries(Object.entries(lag).filter(([key]) => spectrum.some((s) => s.key === key)));
// New frames join the replay queue: shown at their arrival rate, not at once.
for (const s of model.spectrum) {
const held = spectrum.find((x) => x.key === s.key)?.frames.length ?? 0;
lag[s.key] = Math.min(held, (lag[s.key] ?? 0) + s.frames.length);
}
host.status(statusOf(model));
} finally {
busy = false;
host.invalidate();
}
}
/** Options shared by the full drawing and each animation frame. */
const drawOpts = (t) => ({ mode, nodeIndex, lag, history, t, columns: size.columns, rows: size.rows });
const shownModel = () => (model ? { ...model, spectrum } : null);
/** One animation frame: repaint each mounted animated Raster in place. */
async function animate() {
if (!host || animating) return;
animating = true;
try {
// Real time, the clock the full render reads too: pulses keep the
// reported rates and the replay keeps up even when a frame fires late.
const t = await host.now();
if (!Number.isFinite(t)) return;
const dt = Number.isFinite(lastFrame) ? Math.max(0, Math.min(1000, t - lastFrame)) : 0;
lastFrame = t;
for (const s of spectrum) lag[s.key] = drain(lag[s.key], s.rateHz, dt);
const pics = picturesOf(shownModel(), drawOpts(t));
// Fire and forget: a blit resolves only once a frame is painted, and the
// surface folds blits between frames anyway, so nothing waits on one.
for (const key of ANIMATED) {
if (!pics[key]) continue;
const { columns, rows, cells } = pics[key].grid.toRaster(key);
Promise.resolve(host.blit({ requestId: PANE_ID, key, cells, columns, rows })).catch(() => undefined);
}
} finally {
animating = false;
}
}
/** Start the refresh timer, the age tick and the animation, and fetch now, unless already polling. */
function startPolling() {
isOpen = true;
if (stopTimer) return;
stopTimer = cancelOf(host.every(intervalOf(), () => { void refresh(); }));
stopTick = cancelOf(host.every(TICK_MS, () => host.invalidate()));
stopAnim = cancelOf(host.every(FRAME_MS, () => { void animate(); }));
void refresh();
}
/** Switch view; the live views poll faster, so the timer restarts. */
function setMode(next) {
if (!MODES.includes(next) || next === mode) return;
mode = next;
if (isOpen && host) {
// Each open sets the size anew: re-request rows for this view, keeping the keys.
void host.open(openArgsOf(mode)).catch(() => undefined);
stop();
startPolling();
} else host?.invalidate();
}
async function open(initialMode) {
if (MODES.includes(initialMode)) mode = initialMode;
await host.open(openArgsOf(mode));
stop();
startPolling();
}
async function close() {
stop();
isOpen = false;
await host.close({ id: PANE_ID }).catch(() => undefined);
}
on('session.start', async ($, e, next) => {
cliPath = cliPathOf($.plugin.root);
host = {
run: (argv, init) => $.process.run(argv, init),
every: (ms, fn) => $.clock.every(ms, fn),
now: () => $.clock.now(),
open: (pane) => $.ui.open(pane),
close: (pane) => $.ui.close(pane),
status: (text) => $.ui.status(text),
invalidate: () => $.ui.invalidate('ui.render'),
blit: (args) => $.ui.blit(args),
};
await $.command.register({ name: COMMAND, description: 'RuView live sensing pane: CSI nodes, CSI waterfall and radar', argumentHint: '[waterfall|radar|refresh|off]' }).catch(() => undefined);
return next(e);
});
on('command.run', { command: COMMAND }, async ($, e, next) => {
if (!host) return next(e);
const arg = String(e.args || '').trim().toLowerCase();
if (arg === 'off') { await close(); host.status(undefined); return { text: 'RuView pane closed.' }; }
if (arg === 'refresh') { await refresh(); return { text: statusOf(model) }; }
if (MODES.includes(arg)) {
if (isOpen) {
if (arg === mode) await host.open(openArgsOf(mode)).catch(() => undefined);
else setMode(arg);
} else await open(arg);
return { text: `RuView pane: ${arg} view.` };
}
if (isOpen) { await close(); return { text: 'RuView pane closed.' }; }
await open();
return { text: `RuView pane open: nodes on UDP ${settings.udpPort}${settings.radarHost ? `, radar ${settings.radarHost}` : ''}; keys 1 overview · 2 CSI waterfall · 3 radar.` };
});
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
if (e.requestId !== PANE_ID) return next(e);
// A reload (hot reload, or a resumed session) re-runs register with fresh
// variables while the engine keeps the pane open: drawing it means it is
// open, so resume polling instead of waiting for the first capture forever.
if (host && !stopTimer) startPolling();
size = {
columns: e.props?.bodyColumns ?? e.viewport?.columns ?? 100,
rows: e.props?.scroll?.bodyRows ?? e.viewport?.rows ?? 30,
};
const ui = await $.ui.resolve(e);
const now = await $.clock.now();
return viewOf(ui, shownModel(), {
...drawOpts(now), refreshMs: settings.refreshMs, liveRefreshMs: settings.liveRefreshMs, busy, now,
focused: e.props?.isFocused !== false,
udpPort: settings.udpPort, radarConfigured: Boolean(settings.radarHost),
onRefresh: () => { void refresh(); },
onClose: () => { void close(); },
onMode: (m) => setMode(m),
onNextNode: () => { nodeIndex += 1; host?.invalidate(); },
});
});
on('ui.close', { id: PANE_ID }, async ($, e, next) => {
stop();
isOpen = false;
return next(e);
});
on('session.end', async ($, e, next) => {
stop();
return next(e);
});
}
+231
View File
@@ -0,0 +1,231 @@
// SPDX-License-Identifier: MIT
// ruview-live drawing (ADR-377, ADR-378): the overview cards and the two
// showcase views (CSI waterfall, radar fan + vitals charts), built from the
// surface's own elements. Pictures are `Raster`s (terminal); other surfaces
// get an honest note instead. `picturesOf` builds every animated picture, so
// the first paint and each `$.ui.blit` frame share one size and one source.
import { pingOf, pulse, shimmer, shownFrames } from './anim.mjs';
import { ageOf, plausibilityOf, RANGES, sparkline } from './model.mjs';
import { colourbar, lineChart, radarFan, rgb, waterfall } from './raster.mjs';
const TABS = [['overview', '1', 'Overview'], ['waterfall', '2', 'CSI waterfall'], ['radar', '3', 'Radar']];
/** Keys of the pictures the hooks module repaints with `$.ui.blit`. */
export const ANIMATED = Object.freeze(['shimmer', 'waterfall', 'fan', 'pulse']);
/** Picture sizes that fit the pane body. */
export function sizesOf(columns = 80, rows = 30) {
const width = Math.max(24, Math.min(128, columns - 6));
const height = Math.max(6, Math.min(26, rows - 15)); // the header, tabs, card chrome and footer take ~15 rows
return { width, height };
}
const wrapIndex = (i, n) => ((i % n) + n) % n;
const radarLayout = (columns, rows) => {
const { width, height } = sizesOf(columns, rows);
const wide = (columns ?? 80) >= 110;
const fanW = wide ? Math.floor(width * 0.55) : width;
// A 120° wedge is about width / (4·sin 60°) rows tall.
const fanH = Math.max(6, Math.min(height, Math.round(fanW / 3.4) + 1));
return { width, wide, fanW, fanH, chartW: wide ? Math.max(20, width - fanW - 6) : width };
};
/**
* Every animated picture for this mode at time `t` (ms): `{ shimmer, waterfall,
* fan, pulse }`, each `{ grid, ... }` or null. opts: { mode, columns, rows, t,
* nodeIndex, lag (per node key), history }.
*/
export function picturesOf(model, opts) {
const mode = opts.mode || 'overview';
const t = Number.isFinite(opts.t) ? opts.t : 0;
const columns = opts.columns ?? 80;
const out = { shimmer: { grid: shimmer(Math.max(8, Math.min(160, columns - 4)), t) }, waterfall: null, fan: null, pulse: null };
if (!model) return out;
if (mode === 'waterfall' && model.spectrum?.length) {
const nodes = model.spectrum;
const node = nodes[wrapIndex(opts.nodeIndex ?? 0, nodes.length)];
const { width, height } = sizesOf(columns, opts.rows);
out.waterfall = { ...waterfall(shownFrames(node.frames, opts.lag?.[node.key]), width, height), node, nodes };
}
if (mode === 'radar' && model.radar) {
const history = opts.history || { distance: [] };
const { fanW, fanH, chartW } = radarLayout(columns, opts.rows);
out.fan = radarFan(fanW, fanH, { distances: history.distance, present: model.radar.present, ping: pingOf(t) });
out.pulse = { grid: pulse(Math.min(chartW, 40), t, { heartBpm: model.radar.heartBpm, breathingBpm: model.radar.breathingBpm }) };
}
return out;
}
/**
* Draw the pane. opts: { mode, nodeIndex, refreshMs, liveRefreshMs, busy, now, t, lag, columns, rows,
* history, udpPort, radarConfigured, onRefresh, onClose, onMode(mode), onNextNode }.
*/
export function viewOf(ui, model, opts) {
const { Box, Text, Button } = ui;
const mode = opts.mode || 'overview';
const live = mode !== 'overview';
const interval = live ? (opts.liveRefreshMs ?? opts.refreshMs) : opts.refreshMs;
const t = (children, props = {}) => Text({ wrap: 'truncate-end', ...props, children });
const row = (children, props = {}) => Box({ flexDirection: 'row', gap: 1, ...props, children: children.filter(Boolean) });
const pics = ui.Raster ? picturesOf(model, opts) : {};
// Header: freshness badge and age, then the signal rule.
const finiteAt = model && Number.isFinite(model.at);
const age = finiteAt && Number.isFinite(opts.now) ? opts.now - model.at : NaN;
const fresh = Number.isFinite(age) && age <= interval * 2 + 5000;
const when = finiteAt ? new Date(model.at).toLocaleTimeString() : '—';
const badge = !model ? t('○ STARTING', { dimColor: true, bold: true })
: fresh ? t('● LIVE', { color: 'green', bold: true }) : t('○ STALE', { color: 'yellow', bold: true });
const header = row([
t('RuView', { color: 'cyan', bold: true }),
badge,
t(model ? `updated ${when}${ageOf(age) ? ` (${ageOf(age)})` : ''} · every ${Math.round(interval / 1000)}s` : `waiting for the first capture… · every ${Math.round(interval / 1000)}s`, { dimColor: true }),
opts.busy ? t('⟳ refreshing', { color: 'cyan' }) : null,
]);
const rule = pics.shimmer ? ui.Raster(pics.shimmer.grid.toRaster('shimmer')) : null;
// Hotkeys reach a Pane only while it holds the keyboard; say how to give it.
const tabs = opts.onMode ? row([
...TABS.map(([m, key, label]) => Button({
key: `tab-${m}`, hotkey: key, label: `${m === mode ? '▸ ' : ''}${label} (${key})`, onPress: () => opts.onMode(m),
})),
opts.focused === false ? t('click the pane or press ctrl+x tab to use the keys', { dimColor: true, italic: true }) : null,
], { gap: 2 }) : null;
const alerts = (model?.alerts || []).map((a) => Text({ color: a.level === 'bad' ? 'red' : 'yellow', wrap: 'wrap', children: `! ${a.text}` }));
let body;
if (mode === 'waterfall') body = waterfallView(ui, model, opts, pics, t, row);
else if (mode === 'radar') body = radarView(ui, model, opts, pics, t, row);
else body = overview(ui, model, opts, t, row);
const footer = row([
Button({ key: 'refresh', label: opts.busy ? 'Refreshing…' : 'Refresh (r)', hotkey: 'r', onPress: opts.onRefresh }),
mode === 'waterfall' && opts.onNextNode && (model?.spectrum?.length ?? 0) > 1 ? Button({ key: 'next-node', label: 'Next node (n)', hotkey: 'n', onPress: opts.onNextNode }) : null,
Button({ key: 'close', label: 'Close (c)', hotkey: 'c', onPress: opts.onClose }),
], { gap: 2 });
return Box({ flexDirection: 'column', gap: 1, paddingX: 1, children: [Box({ flexDirection: 'column', children: [header, rule].filter(Boolean) }), tabs, ...alerts, body, footer].filter(Boolean) });
}
function card(ui, title, subtitle, borderColor, children, t, row) {
return ui.Box({
flexDirection: 'column', borderStyle: 'round', borderColor, paddingX: 1, flexGrow: 1,
children: [row([t(title, { bold: true }), subtitle ? t(subtitle, { dimColor: true }) : null]), ...children.filter(Boolean)],
});
}
function needsTerminal(ui, t) {
return ui.Raster ? null : t('This view draws with terminal cells; open it in the terminal. The overview works everywhere.', { dimColor: true });
}
function overview(ui, model, opts, t, row) {
if (!model) return null;
const history = opts.history || { heart: [], breathing: [], distance: [], rates: {} };
const vital = (label, value, raw, kind, series) => {
const warn = kind ? plausibilityOf(kind, raw) : null;
return row([
t(label.padEnd(10), { dimColor: true }),
t(value.padStart(9), { bold: raw != null && !warn, color: warn ? 'yellow' : undefined }),
t(sparkline(series), { color: 'cyan' }),
warn ? t(`⚠ ${warn}`, { color: 'yellow' }) : null,
]);
};
let radarCard = null;
if (model.radar) {
const r = model.radar;
const presence = r.present == null ? t('? presence unknown', { dimColor: true })
: r.present ? t(`● PRESENCE DETECTED${r.targets ? ` · ${r.targets} target${r.targets === 1 ? '' : 's'}` : ''}`, { color: 'green', bold: true })
: t('○ no presence', { dimColor: true });
radarCard = card(ui, '60 GHz RADAR', r.name, r.present ? 'green' : 'gray', [
presence,
vital('distance', r.distance, r.distanceCm, null, history.distance),
vital('heart', r.heart, r.heartBpm, 'heart', history.heart),
vital('breathing', r.breathing, r.breathingBpm, 'breathing', history.breathing),
t('device-reported values, not validated against a reference', { dimColor: true, italic: true }),
], t, row);
} else if (!opts.radarConfigured) {
radarCard = card(ui, '60 GHz RADAR', 'not configured', 'gray', [
t('Set radarHost to an ESPHome radar kit:', { dimColor: true }),
t('claude plugin configure ruview-live', { color: 'cyan' }),
], t, row);
}
const lines = model.nodes.length
? model.nodes.map((n) => row([
t(n.label.padEnd(10), { bold: true }),
t(n.rate.padStart(9)),
t(sparkline(history.rates[n.key]), { color: 'cyan' }),
t(`loss ${n.loss}`, n.lossy ? { color: 'yellow' } : { dimColor: true }),
t(n.rssi, { dimColor: true }),
t(n.shape, { dimColor: true }),
n.synthetic ? t('SYNTHETIC', { color: 'yellow', bold: true }) : null,
]))
: [
t('none streaming to this machine', { dimColor: true }),
model.noPackets ? t(`check node target IP/port · firewall UDP ${opts.udpPort ?? 5005}`, { dimColor: true }) : null,
];
const nodesCard = card(ui, 'CSI NODES', `UDP ${opts.udpPort ?? 5005} · ${model.decoded}/${model.packets} decoded`, model.nodes.length ? 'cyan' : 'gray', [
...lines,
model.nodes.length ? t('press 2 for the live CSI waterfall', { dimColor: true, italic: true }) : null,
], t, row);
const cards = [radarCard, nodesCard].filter(Boolean);
return ui.Box({ flexDirection: (opts.columns ?? 80) >= 100 ? 'row' : 'column', gap: 1, children: cards });
}
function waterfallView(ui, model, opts, pics, t, row) {
const note = needsTerminal(ui, t);
if (note) return note;
if (!model) return t('waiting for the first capture…', { dimColor: true });
if (!pics.waterfall) {
return card(ui, 'CSI WATERFALL', 'no CSI frames', 'gray', [
t('No CSI node is streaming to this machine, so there is nothing to draw.', { dimColor: true }),
t(`Point a node at this host's UDP ${opts.udpPort ?? 5005} (provision.py --target-ip), then this view fills by itself.`, { dimColor: true }),
], t, row);
}
const { grid, lo, hi, node: n, nodes } = pics.waterfall;
const legend = colourbar(Math.min(32, grid.columns - 20));
return card(ui, 'CSI WATERFALL', `${n.label} · ${n.subcarriers} subcarriers → ${n.bins} bins · ${n.rateHz ?? '—'} Hz${nodes.length > 1 ? ` · node ${nodes.indexOf(n) + 1}/${nodes.length}` : ''}`, n.synthetic ? 'yellow' : 'cyan', [
n.synthetic ? t('SYNTHETIC — simulator frames, not a live measurement', { color: 'yellow', bold: true }) : t('MEASURED — live CSI amplitude received on this host', { color: 'green' }),
ui.Raster(grid.toRaster('waterfall')),
row([t('subcarrier →', { dimColor: true }), t('newest at the bottom · replayed at the arrival rate', { dimColor: true })], { justifyContent: 'space-between' }),
row([t(lo.toFixed(0), { dimColor: true }), ui.Raster(legend.toRaster('legend')), t(`${hi.toFixed(0)} amplitude (5th–95th pct)`, { dimColor: true })]),
], t, row);
}
function radarView(ui, model, opts, pics, t, row) {
const note = needsTerminal(ui, t);
if (note) return note;
if (!model) return t('waiting for the first capture…', { dimColor: true });
if (!opts.radarConfigured) {
return card(ui, 'RADAR', 'not configured', 'gray', [t('Set radarHost: claude plugin configure ruview-live', { color: 'cyan' })], t, row);
}
const r = model.radar;
if (!r || !pics.fan) return card(ui, '60 GHz RADAR', 'unreachable', 'red', [t('No reading this refresh; see the alert above.', { dimColor: true })], t, row);
const history = opts.history || { heart: [], breathing: [], distance: [] };
const { wide, chartW } = radarLayout(opts.columns, opts.rows);
const presence = r.present ? t(`● PRESENCE DETECTED · ${r.distance}${r.targets ? ` · ${r.targets} target${r.targets === 1 ? '' : 's'}` : ''}`, { color: 'green', bold: true })
: t('○ no presence', { dimColor: true });
const fan = card(ui, '60 GHz RADAR', `${r.name} · range ${pics.fan.maxCm / 100} m`, r.present ? 'green' : 'gray', [
presence,
ui.Raster(pics.fan.grid.toRaster('fan')),
t('range only: this kit reports distance, not bearing (arc = every bearing at that range)', { dimColor: true, italic: true }),
], t, row);
const chart = (label, series, kind, unit, colour, minSpan) => {
const { grid: g, lo, hi } = lineChart(series, chartW, 3, { band: kind ? RANGES[kind] : null, colour, minSpan });
const last = [...series].reverse().find((v) => Number.isFinite(v));
const warn = kind ? plausibilityOf(kind, last ?? null) : null;
return [
row([t(label, { bold: true }), t(last == null ? '—' : `${last.toFixed(1)} ${unit}`, { color: warn ? 'yellow' : undefined }), warn ? t(`⚠ ${warn}`, { color: 'yellow' }) : null, t(`${lo.toFixed(0)}–${hi.toFixed(0)}`, { dimColor: true })]),
ui.Raster(g.toRaster(`chart-${label}`)),
];
};
const charts = card(ui, 'VITALS', 'device-reported, not validated', 'gray', [
pics.pulse ? ui.Raster(pics.pulse.grid.toRaster('pulse')) : null,
pics.pulse ? t('♥ beats and the gauge breathes at the reported rates (a metronome, not a waveform)', { dimColor: true, italic: true }) : null,
...chart('heart', history.heart, 'heart', 'bpm', rgb(255, 110, 130), 10),
...chart('breathing', history.breathing, 'breathing', 'bpm', rgb(110, 200, 255), 4),
...chart('distance', history.distance, null, 'cm', rgb(120, 255, 170), 20),
t('shaded = inside the plausible resting range', { dimColor: true, italic: true }),
], t, row);
return ui.Box({ flexDirection: wide ? 'row' : 'column', gap: 1, children: [fan, charts] });
}
+189
View File
@@ -0,0 +1,189 @@
import { describe, expect, mock, test, tier } from 'claude-code/testing'
tier('user')
const CAPTURE = {
ok: true,
packets: 120,
decodedPackets: 120,
heartbeatOnlySenders: [{ address: '192.168.1.67', heartbeats: 30 }],
nodes: [
{ source: 'esp32', nodeId: 42, csiRateHz: 4.7, csiLossFraction: 0, rssiMean: -63, csi: { shape: '1x64' } },
{ source: 'realtek', nodeId: 3, csiRateHz: 348, csiLossFraction: 0.08, rssiMean: -39, csi: { shape: '1x52' } },
],
}
describe('register', () => {
test('/ruview opens the pane, refreshes from the harness CLI, and stops on close', async ($, on) => {
const runs: (readonly string[])[] = []
const opened: string[] = []
const closed: string[] = []
const statuses: (string | undefined)[] = []
const clock = mock.clock(on)
on('session.start', ($, e) => ({ cwd: e.cwd }))
on('command.register', ($, e) => ({ value: { command: e.name } }))
on('process.run', ($, e) => {
runs.push(e.argv)
return { value: { exitCode: 0, stdout: JSON.stringify(CAPTURE), stderr: '' } }
})
const focusAsked: (true | undefined)[] = []
on('ui.open', ($, e) => {
opened.push(e.id)
focusAsked.push(e.focus)
return { value: undefined }
})
on('ui.close', ($, e) => {
closed.push(e.id)
return { value: undefined }
})
on('ui.status', ($, e) => {
statuses.push(e.text)
return { value: undefined }
})
on('ui.invalidate', () => ({ value: undefined }))
const settle = async () => {
for (let i = 0; i < 5; i++) await clock.settle()
}
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
const { text } = await $.command.run({ command: 'ruview', args: '', origin: { kind: 'composer' } })
await settle()
expect(text).toContain('RuView pane open')
expect(opened).toEqual(['ruview-live'])
expect(focusAsked).toEqual([true]) // hotkeys reach a Pane only while it holds the keyboard
expect(runs.length).toBeGreaterThanOrEqual(1)
const argv = runs[0] ?? []
expect(argv[0]).toBe('node')
expect(String(argv[1])).toContain('bin/cli.js')
expect(argv.slice(2)).toEqual(['esp32', '--seconds', '3', '--udp-port', '5005', '--json'])
expect(statuses.at(-1)).toBe('RuView · 2 nodes · 1 alert')
const beforeTicks = runs.length
for (let i = 0; i < 3; i++) {
await clock.advance(15_000)
await settle()
}
expect(runs.length).toBeGreaterThan(beforeTicks)
await $.command.run({ command: 'ruview', args: '', origin: { kind: 'composer' } })
await settle()
expect(closed).toEqual(['ruview-live'])
const afterClose = runs.length
for (let i = 0; i < 4; i++) {
await clock.advance(15_000)
await settle()
}
expect(runs.length).toBe(afterClose)
})
test('a pane drawn after a reload resumes polling instead of waiting forever', async ($, on) => {
// Seen live: a hot reload re-runs register with fresh variables while the
// engine keeps the pane open, so the drawing sat at "waiting for the first
// capture…" with no timer running.
const runs: (readonly string[])[] = []
const clock = mock.clock(on)
on('session.start', ($, e) => ({ cwd: e.cwd }))
on('command.register', ($, e) => ({ value: { command: e.name } }))
on('process.run', ($, e) => {
runs.push(e.argv)
return { value: { exitCode: 0, stdout: JSON.stringify(CAPTURE), stderr: '' } }
})
on('ui.status', () => ({ value: undefined }))
on('ui.invalidate', () => ({ value: undefined }))
on('ui.blit', () => ({ value: {} }))
const PANE = {
title: 'RuView',
isFocused: false,
bodyColumns: 100,
placement: 'dock' as const,
scroll: { offset: 0, bodyRows: 30 },
view: {},
}
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
// No /ruview in this module instance: the engine draws the pane it kept open.
const first = await $.ui.mount({ plugin: 'ruview-live', surface: 'terminal', component: 'Pane', requestId: 'ruview-live', props: PANE })
for (let i = 0; i < 5; i++) await clock.settle()
expect(runs.length).toBeGreaterThanOrEqual(1)
await first.unmount()
const again = await $.ui.mount({ plugin: 'ruview-live', surface: 'terminal', component: 'Pane', requestId: 'ruview-live', props: PANE })
expect(await again.find({ type: 'Text', text: /NODES/ })).toBeDefined()
expect(await again.find({ type: 'Text', text: /waiting for the first capture/ })).toBeUndefined()
expect(await again.find({ type: 'Text', text: /ctrl\+x tab/ })).toBeDefined() // unfocused: says how to focus
const polled = runs.length
// Step time as a live session does (the animation timer runs every 80 ms).
for (let s = 0; s < 16; s++) {
await clock.advance(1_000)
for (let i = 0; i < 5; i++) await clock.settle()
}
expect(runs.length).toBeGreaterThan(polled)
await again.unmount()
})
test('the waterfall tab asks for spectrum frames, draws a Raster, and animates it with blits', async ($, on) => {
const runs: (readonly string[])[] = []
const blits: string[] = []
const clock = mock.clock(on)
const frames = Array.from({ length: 40 }, (_, k) => [k, 2 * k, 3 * k, 4 * k])
on('session.start', ($, e) => ({ cwd: e.cwd }))
on('command.register', ($, e) => ({ value: { command: e.name } }))
on('process.run', ($, e) => {
runs.push(e.argv)
const spectrum = e.argv.includes('--spectrum')
? [{ source: 'esp32', nodeId: 42, subcarriers: 64, bins: 4, rateHz: 20, synthetic: false, frames }]
: undefined
return { value: { exitCode: 0, stdout: JSON.stringify({ ...CAPTURE, spectrum }), stderr: '' } }
})
on('ui.status', () => ({ value: undefined }))
// No ui.invalidate stub here: the mounted drawing follows the mod's invalidations.
on('ui.blit', ($, e) => {
blits.push(e.key)
return { value: {} }
})
const PANE = { title: 'RuView', isFocused: true, bodyColumns: 120, placement: 'dock' as const, scroll: { offset: 0, bodyRows: 34 }, view: {} }
const settle = async () => {
for (let i = 0; i < 5; i++) await clock.settle()
}
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
const pane = await $.ui.mount({ plugin: 'ruview-live', surface: 'terminal', component: 'Pane', requestId: 'ruview-live', props: PANE })
await settle()
expect(await pane.find({ key: 'tab-waterfall' })).toBeDefined()
await pane.press({ key: 'tab-waterfall' })
await settle()
expect(runs.at(-1)).toContain('--spectrum')
expect(runs.at(-1)).toContain('--spectrum-bins')
expect(await pane.find({ type: 'Text', text: /MEASURED/ })).toBeDefined()
expect(await pane.find({ type: 'Raster', key: 'waterfall' })).toBeDefined()
for (let i = 0; i < 4; i++) {
await clock.advance(100)
await settle()
}
expect(blits).toContain('waterfall')
expect(blits).toContain('shimmer')
await pane.unmount()
})
test('/ruview refresh with no nodes reports the honest failure in the status', async ($, on) => {
const statuses: (string | undefined)[] = []
mock.clock(on)
on('session.start', ($, e) => ({ cwd: e.cwd }))
on('command.register', ($, e) => ({ value: { command: e.name } }))
on('process.run', () => ({ value: { exitCode: 1, stdout: JSON.stringify({ ok: false, reason: 'no_packets', packets: 0 }), stderr: '' } }))
on('ui.status', ($, e) => {
statuses.push(e.text)
return { value: undefined }
})
on('ui.invalidate', () => ({ value: undefined }))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
const { text } = await $.command.run({ command: 'ruview', args: 'refresh', origin: { kind: 'composer' } })
expect(text).toBe('RuView · 0 nodes')
expect(statuses.at(-1)).toBe('RuView · 0 nodes')
})
})
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.claude-plugin/types/tsconfig.json"
}
+11 -3
View File
@@ -1,15 +1,15 @@
{
"name": "@ruvnet/ruview",
"version": "0.5.1",
"version": "0.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ruvnet/ruview",
"version": "0.5.1",
"version": "0.9.1",
"license": "MIT",
"bin": {
"ruview": "bin/cli.js"
"ruview-harness": "bin/cli.js"
},
"devDependencies": {
"@metaharness/darwin": "0.8.0",
@@ -18,6 +18,14 @@
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@ruvnet/ruview-kernel": ">=0.1.0"
},
"peerDependenciesMeta": {
"@ruvnet/ruview-kernel": {
"optional": true
}
}
},
"node_modules/@metaharness/darwin": {
+46 -8
View File
@@ -1,17 +1,33 @@
{
"name": "@ruvnet/ruview",
"version": "0.5.1",
"description": "RuView WiFi-sensing operator harness — onboard, calibrate, verify, enforce evidence guardrails, and read Cognitum Spaces through explicitly granted OAuth.",
"version": "0.9.1",
"description": "RuView WiFi-sensing operator harness \u2014 CLI, MCP server, and SDK for host device access (ESP32 CSI nodes, 60/24 GHz mmWave, LiDAR), SSH remote hosts, diagnostics, verified ESP32 flashing, evidence-gated training, calibration, and Cognitum Spaces.",
"type": "module",
"bin": {
"ruview": "bin/cli.js"
"ruview-harness": "bin/cli.js"
},
"exports": {
".": "./src/tools.js",
"./sdk": {
"types": "./src/sdk.d.ts",
"default": "./src/sdk.js"
},
"./guardrails": "./src/guardrails.js",
"./brain": "./src/brain.js",
"./guidance": "./src/guidance.js",
"./hosts": "./src/hosts/index.js"
"./hosts": "./src/hosts/index.js",
"./doctor": "./src/doctor.js",
"./firmware": "./src/firmware.js",
"./training": "./src/training.js",
"./devices": "./src/devices/registry.js",
"./remote": "./src/remote.js",
"./cli": "./src/cli.js",
"./cli-ui": "./src/cli-ui.js",
"./mcp": "./src/mcp-server.js",
"./mcp-http": "./src/mcp-http.js",
"./ui": "./src/ui/console-widget.js",
"./esphome": "./src/devices/esphome.js",
"./kernel": "./src/kernel.js"
},
"files": [
"bin/",
@@ -23,13 +39,15 @@
"brain/",
"flywheel/",
"scripts/",
"mod/.claude-plugin/",
"mod/hooks/",
"CLAUDE.md",
"README.md",
"LICENSE"
],
"scripts": {
"test": "node --test test/*.test.mjs",
"test:security": "node --test test/hosts.test.mjs test/brain.test.mjs test/policy.test.mjs test/spaces.test.mjs",
"test:security": "node --test test/hosts.test.mjs test/brain.test.mjs test/policy.test.mjs test/spaces.test.mjs test/kernel.test.mjs test/firmware.test.mjs test/training.test.mjs test/devices.test.mjs test/csi-nodes.test.mjs test/remote.test.mjs test/mcp-apps.test.mjs test/cli-ui.test.mjs test/esphome.test.mjs",
"doctor": "node ./bin/cli.js doctor",
"mcp": "node ./bin/cli.js mcp start",
"brain:verify": "node ./bin/cli.js brain verify",
@@ -39,7 +57,8 @@
"manifest:update": "node ./scripts/update-manifest.mjs",
"manifest:verify": "node ./scripts/verify-manifest.mjs",
"prepack": "node ./scripts/sync-skills.mjs && node ./scripts/update-manifest.mjs --quiet && node ./scripts/verify-manifest.mjs --quiet",
"prepublishOnly": "npm test && node ./scripts/verify-manifest.mjs"
"prepublishOnly": "npm test && node ./scripts/verify-manifest.mjs",
"test:e2e:devices": "node --test test/e2e/devices.e2e.mjs"
},
"keywords": [
"wifi-sensing",
@@ -57,8 +76,26 @@
"claude-code",
"ambient-intelligence",
"cognitum-spaces",
"oauth"
"oauth",
"firmware-flashing",
"esptool",
"sdk",
"model-training",
"mmwave",
"lidar",
"rplidar",
"mr60bha2",
"ld2410",
"ssh"
],
"peerDependencies": {
"@ruvnet/ruview-kernel": ">=0.1.0"
},
"peerDependenciesMeta": {
"@ruvnet/ruview-kernel": {
"optional": true
}
},
"engines": {
"node": ">=20.0.0"
},
@@ -80,5 +117,6 @@
},
"publishConfig": {
"access": "public"
}
},
"types": "./src/sdk.d.ts"
}
+53
View File
@@ -0,0 +1,53 @@
---
name: device-access
description: Reach RuView sensing hardware from the machine it is attached to — ESP32 CSI nodes (serial + UDP stream), 60 GHz MR60BHA2 / 24 GHz LD2410 mmWave radars, RPLIDAR and iPhone LiDAR — and from other machines over SSH, read-only.
---
# device-access
Hardware tools must run **on the host the device is plugged into**. A cloud
agent cannot see your USB ports. Run `npx @ruvnet/ruview` locally, start a
local Claude Code session there (`claude remote-control` in the repo if you
want to drive it from the Claude app), or add the machine as an SSH host.
## 1. Find devices
```
npx @ruvnet/ruview doctor --group python,serial,devices,remote
npx @ruvnet/ruview devices # USB VID:PID → likely ESP32 / mmWave / RPLIDAR + confirm command
```
## 2. Read each modality
| Device | Command | Confirms |
|---|---|---|
| ESP32 node (USB) | `ruview doctor --port <p> --probe`, `ruview monitor --port <p>` | chip id; CSI callbacks in the serial log (the monitor never resets the node; C6/S3 USB-Serial/JTAG builds log on the native USB port) |
| ESP32 / Realtek node (network) | `ruview esp32 --seconds 10 [--udp-port 5005]` | per-node CSI rate, sequence loss, RSSI, CSI shapes, device vitals; Realtek RAC1 CSI and heartbeat-only senders |
| Realtek RTL8721Dx (USB, PL2303) | `ruview monitor --port <p> --baud 1500000` | `RUVIEW_CSI: frame #` lines in the Ameba log |
| Live CSI → kernel | `ruview esp32 --seconds 45 --analyze [--node-id N] [--backend napi]` | kernel vitals summary at the measured frame rate (no reference measurement) |
| 60 GHz MR60BHA2 | `ruview mmwave --port <p> --model mr60bha2` | valid frames, checksum errors, presence/distance/device HR+BR |
| 24 GHz LD2410 | `ruview mmwave --port <p> --model ld2410` | target state and distances |
| RPLIDAR | `ruview lidar --source rplidar --port <p> [--baud 115200]` | points, revolutions, range, angular coverage |
| iPhone LiDAR | `export RUVIEW_LIDAR_TOKEN=…; ruview lidar --source iphone --url ws://HOST:8787/ws/lidar` | depth frame rate, confident fraction, median depth |
The UDP capture cannot share port 5005 with a running sensing server; stop
the server or have the nodes target another port.
## 3. Other machines (SSH, read-only)
```
npx @ruvnet/ruview hosts add --name lab-pi --ssh ruv@lab-pi.local
ssh ruv@lab-pi.local true # pin the host key once
npx @ruvnet/ruview hosts run --host lab-pi --tool ruview_devices_scan
npx @ruvnet/ruview hosts run --host lab-pi --tool ruview_mmwave_read --args-json '{"port":"/dev/ttyUSB0"}'
```
Remote hosts run a pinned `@ruvnet/ruview` and accept only read-only tools.
Flash, train, and calibrate on the host itself.
## Evidence
Device readouts are `MEASURED` *readings*, not accuracy results. mmWave
heart/breathing values are the radar's own estimates. Hardware validation
still means a captured log or stream from real silicon, recorded with the
command that produced it.
+14 -8
View File
@@ -1,6 +1,6 @@
---
name: provision-node
description: Build, flash, and provision an ESP32-S3/C6 CSI node for RuView — firmware variant choice, ESP-IDF Windows-subprocess flow, NVS/WiFi/channel/MAC-filter overrides.
description: Build, flash, and provision an ESP32-S3/C6 CSI node for RuView — firmware variant choice, cross-platform checksum-verified esptool flashing, NVS/WiFi/channel/MAC-filter overrides, boot-log evidence.
---
# provision-node
@@ -19,17 +19,23 @@ Prebuilt binaries: GitHub release `v0.8.1-esp32` (hardware-validated on S3 QFN56
## 2. Flash
ESP-IDF v5.4 on Windows is **subprocess-only** (Git Bash/MSYS is unsupported — strip
`MSYSTEM*` env vars). Offsets for the S3 image:
Use a release flash bundle (or `release_bins/<variant>`, or an ESP-IDF `build/`).
The harness flashes on Windows, macOS, and Linux through `python -m esptool`:
```
esptool --chip esp32s3 -p <PORT> -b 460800 write_flash \
0x0 bootloader.bin 0x8000 partition-table.bin \
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node-s3-8mb.bin
npx @ruvnet/ruview doctor --group python,serial,firmware # esptool, pyserial, ports, bundle checksums
npx @ruvnet/ruview flash-plan --port <PORT> --bundle <dir> --variant s3-8mb
npx @ruvnet/ruview flash --port <PORT> --bundle <dir> --variant s3-8mb --confirm
```
(`ruview_node_flash` returns the exact pinned command rather than running an
unattended flash.)
`flash` verifies SHA256SUMS, refuses a chip that does not match the variant
(`esptool chip_id`), writes 0x0 / 0x8000 / 0xf000 / 0x20000 (NVS preserved),
then captures a boot log. `hardwareValidated: true` requires CSI callbacks in
that log. Over MCP, `ruview_node_flash` needs the `hardware-write` grant and
`confirm: true`.
Building from source still uses ESP-IDF v5.4 (Docker or the Windows subprocess
flow; Git Bash/MSYS is unsupported, so strip `MSYSTEM*` env vars).
## 3. Provision
+11
View File
@@ -24,6 +24,17 @@ or temporal leakage. Example honest result (ADR-181):
- **in-browser** (ADR-181) — WebGPU/WASM trainer; the active backend is shown as a badge
(honest about what's executing).
## Run it through the harness (ADR-371)
```
npx @ruvnet/ruview train-plan --mode pose-smoke # command, cwd, outputs; runs nothing
npx @ruvnet/ruview train --mode pose-smoke --confirm # SYNTHETIC pipeline smoke (libtorch 2.11 for tch 0.24)
npx @ruvnet/ruview train --mode pose --data-dir <in-repo MM-Fi dir> --confirm
npx @ruvnet/ruview train-gate --file eval-report.json # mean-pose baseline + leakage gate
```
The gate returns the only acceptable claim sentence. Quote nothing it fails.
## Before you publish a number
1. Run the mean-pose baseline on the same split.
+194
View File
@@ -0,0 +1,194 @@
// SPDX-License-Identifier: MIT
// Terminal rendering for the CLI (ADR-375). Used only when stdout is a TTY and
// `--json` is absent; pipes and scripts keep receiving exact JSON. Respects
// NO_COLOR and TERM=dumb. Pure functions of (tool, result) so tests can pin
// the output byte-for-byte.
const ESC = '\x1b[';
const ANSI_RE = /\x1b\[[0-9;]*m/g;
export function colorEnabled(stream = process.stdout, env = process.env) {
return Boolean(stream && stream.isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb';
}
export function createStyle(on) {
const wrap = (code) => (s) => (on ? `${ESC}${code}m${s}${ESC}0m` : String(s));
return { acid: wrap('38;5;155'), muted: wrap('38;5;245'), warn: wrap('38;5;221'), bad: wrap('38;5;203'), bold: wrap('1') };
}
const visible = (s) => String(s).replace(ANSI_RE, '').length;
const pad = (s, n) => String(s) + ' '.repeat(Math.max(0, n - visible(s)));
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v.toFixed(d) : '—');
const pct = (v) => (typeof v === 'number' ? `${(v * 100).toFixed(v > 0 && v < 0.01 ? 2 : 1)}%` : '—');
const BARS = '▁▂▃▄▅▆▇█';
/** Unicode bar of `width` cells for a 0..1 fraction. */
export function bar(frac, width = 10) {
const f = Math.max(0, Math.min(1, Number(frac) || 0));
const full = Math.floor(f * width);
return '█'.repeat(full) + (full < width ? (f * width - full >= 0.5 ? '▌' : ' ') : '') + ' '.repeat(Math.max(0, width - full - 1));
}
/** Sparkline of a numeric series (min..max scaled). */
export function sparkline(values) {
const v = values.filter((x) => typeof x === 'number' && Number.isFinite(x));
if (!v.length) return '';
const lo = Math.min(...v); const hi = Math.max(...v);
return v.map((x) => BARS[hi === lo ? 3 : Math.round(((x - lo) / (hi - lo)) * 7)]).join('');
}
/** Align rows (arrays of cells) into columns. */
export function table(rows, style, gap = 2) {
const widths = [];
for (const row of rows) row.forEach((c, i) => { widths[i] = Math.max(widths[i] || 0, visible(c)); });
return rows.map((row, r) => {
const line = row.map((c, i) => (i === row.length - 1 ? String(c) : pad(c, widths[i] + gap))).join('').trimEnd();
return r === 0 ? style.muted(line) : line;
}).join('\n');
}
function head(view, r, s) {
const measured = r.evidence && /^MEASURED/.test(r.evidence);
const status = r.ok === false
? s.bad(`✖ ${String(r.reason || 'failed').replace(/_/g, ' ')}`)
: measured ? s.acid('● MEASURED') : s.acid('✔ ok');
return `${s.muted(`RUVIEW / ${view}`)} ${status}`;
}
function failureLines(r, s) {
if (r.ok !== false) return [];
const out = [];
for (const k of ['detail', 'remedy', 'hint']) if (r[k]) out.push(`${s.muted(k === 'remedy' ? 'fix' : k)} ${r[k]}`);
return out;
}
function renderCapture(r, s, history) {
const lines = [head('NODE STREAM', r, s), ...failureLines(r, s)];
lines.push(s.muted(`packets ${r.packets ?? '—'} · decoded ${r.decodedPackets ?? '—'} · csi nodes ${r.csiNodes ?? '—'} · ${r.seconds ?? '—'} s · ${r.listen ?? ''}`));
for (const hb of r.heartbeatOnlySenders || []) lines.push(s.warn(`! heartbeat-only: ${hb.address} (${hb.heartbeats} heartbeats, no CSI)`));
const nodes = r.nodes || [];
if (nodes.length) {
const maxHz = Math.max(...nodes.map((n) => n.csiRateHz || 0), 1);
const rows = [['SOURCE', 'NODE', 'CSI RATE', '', 'LOSS', 'RSSI', 'SHAPE', 'SEQ', history ? 'TREND' : '']];
for (const n of nodes) {
const key = `${n.source}:${n.nodeId}`;
const csi = n.csi || {};
const seq = [n.seqReordered && `${n.seqReordered}r`, n.seqStrays && `${n.seqStrays}s`, n.seqResyncs && `${n.seqResyncs}R`].filter(Boolean).join(' ') || '—';
const loss = n.csiLossFraction;
rows.push([
n.source === 'realtek' ? 'realtek' : 'esp32', String(n.nodeId),
`${num(n.csiRateHz, 1)} Hz`, s.acid(bar((n.csiRateHz || 0) / maxHz, 8)),
loss == null ? '—' : loss > 0.05 ? s.warn(pct(loss)) : pct(loss),
`${num(n.rssiMean, 1)} dBm`,
`${csi.shape || '—'}${n.csiShapes ? ` +${n.csiShapes.length - 1}` : ''}${csi.synthetic ? s.warn(' SYNTHETIC') : ''}`,
seq, history ? s.acid(sparkline(history.get(key) || [])) : '',
]);
}
lines.push('', table(rows, s));
}
const a = r.analysis;
if (a) {
lines.push('');
if (a.ok === false) lines.push(s.warn(`kernel analysis: ${a.reason}${a.detail ? ` — ${a.detail}` : ''}`));
else {
const last = a.summary?.last || {}; const hr = last.heart || {}; const rr = last.respiratory || {};
lines.push(`${s.bold('kernel analysis')} ${s.muted(`${a.backend} · ${a.integrity} · ${a.input?.frames} frames @ ${num(a.input?.sampleRateHz, 1)} Hz`)}`);
lines.push(` heart ${num(hr.bpm, 1)} bpm (${hr.status || '—'}) breathing ${rr.status === 'unavailable' ? 'unavailable' : `${num(rr.bpm, 1)} bpm (${rr.status || '—'})`}`);
lines.push(s.muted(` ${a.note || 'estimates without a reference measurement'}`));
}
}
if (r.unknownPackets) lines.push(s.muted(`unknown packets ${r.unknownPackets}: ${Object.keys(r.unknownMagics || {}).join(', ')}`));
return lines.join('\n');
}
function renderDevices(r, s) {
const lines = [head('DEVICES', r, s), ...failureLines(r, s)];
const rows = [['PORT', 'BRIDGE', 'LIKELY', 'CONFIRM WITH']];
for (const d of (r.devices || []).filter((x) => !x.builtin)) {
rows.push([d.port, d.bridge || d.usb || 'unknown', (d.likelyRoles || []).join('/') || '—', (d.confirmWith || [])[0] || '—']);
}
if (rows.length > 1) lines.push('', table(rows, s));
if (r.note) lines.push('', s.muted(r.note));
return lines.join('\n');
}
function renderMonitor(r, s) {
const lines = [head('SERIAL MONITOR', r, s), ...failureLines(r, s)];
if (r.ok !== false || r.lines !== undefined) lines.push(`csi log lines ${r.csi_callbacks ?? 0} · console lines ${r.lines ?? '—'} · ${r.baud ?? '—'} baud · reset on open: ${r.reset_on_open === false ? 'no' : 'unknown'}`);
return lines.join('\n');
}
function renderFlash(r, s) {
const lines = [head(r.dryRun ? 'FLASH PLAN' : 'FLASH', r, s), ...failureLines(r, s)];
const p = r.plan;
if (p) {
lines.push(s.muted(`${p.chip} · ${p.flashSize} · ${p.port} · checksums ${p.checksums || 'none'}`));
lines.push('', table([['OFFSET', 'ROLE', 'BYTES', 'INTEGRITY'], ...(p.images || []).map((i) => [i.offset, i.role, String(i.bytes), i.integrity === 'verified' ? s.acid(i.integrity) : s.warn(i.integrity)])], s));
}
if (r.bootLog) lines.push('', `boot log: ${r.bootLog.lines ?? 0} lines · CSI callbacks ${r.bootLog.csiCallbacks ?? 0} · panic ${r.bootLog.panic ? s.bad('yes') : 'no'}`);
if (r.evidence) lines.push(s.acid(r.evidence));
return lines.join('\n');
}
function renderMmwave(r, s) {
const lines = [head('RADAR', r, s), ...failureLines(r, s)];
if (r.ok === false) {
for (const a of r.attempts || []) lines.push(s.muted(`tried ${a.model}: ${a.bytes} bytes, ${a.frames} frames`));
return lines.join('\n');
}
const d = r.device || {};
lines.push(s.muted(r.source === 'esphome'
? `ESPHome ${d.esphomeVersion || '?'} · ${d.name || r.host} · ${d.project || ''}${d.projectVersion ? ` ${d.projectVersion}` : ''} · ${r.stateUpdates} updates in ${r.seconds} s`
: `${r.model} (${r.band}) · ${r.frames} frames · ${r.checksumErrors} checksum errors · ${r.frameRateHz} Hz`));
const present = r.presentNow ?? (r.presentFraction == null ? null : r.presentFraction >= 0.5);
lines.push('', `${s.bold('presence')} ${present == null ? '—' : present ? s.acid('● detected') : s.muted('○ none')}${r.presentFraction == null ? '' : s.muted(` (${(r.presentFraction * 100).toFixed(0)}% of reports)`)}${r.targetsMax != null ? s.muted(` targets ${r.targetsMax}`) : ''}`);
lines.push(`${s.bold('distance')} ${r.distanceCmMean == null ? '—' : `${num(r.distanceCmMean, 1)} cm`}`);
lines.push(`${s.bold('heart')} ${r.heartBpmMean == null ? '—' : `${num(r.heartBpmMean, 1)} bpm`} ${s.bold('breathing')} ${r.breathingBpmMean == null ? '—' : `${num(r.breathingBpmMean, 1)} bpm`}`);
if (r.entities) {
lines.push('', table([['ENTITY', 'LAST', 'UPDATES'], ...r.entities.map((e) => [e.name, e.last == null ? '—' : `${typeof e.last === 'number' ? num(e.last, 2) : e.last}${e.unit ? ` ${e.unit}` : ''}`, String(e.updates)])], s));
}
lines.push('', s.muted('device-reported values (computed by the radar firmware), not validated against a reference'));
return lines.join('\n');
}
function renderGeneric(tool, r, s) {
const view = String(tool || 'result').replace(/^ruview_/, '').replace(/_/g, ' ').toUpperCase();
const lines = [head(view, r, s), ...failureLines(r, s)];
const scalars = Object.entries(r).filter(([k, v]) => !['ok', 'reason', 'detail', 'remedy', 'hint'].includes(k) && (v === null || ['string', 'number', 'boolean'].includes(typeof v)));
for (const [k, v] of scalars.slice(0, 16)) lines.push(`${s.muted(pad(k, 18))}${String(v).length > 140 ? `${String(v).slice(0, 137)}…` : v}`);
const nested = Object.keys(r).filter((k) => r[k] && typeof r[k] === 'object');
if (nested.length) lines.push(s.muted(`… ${nested.join(', ')} (use --json for the full result)`));
return lines.join('\n');
}
/**
* Render a tool result for a terminal. `history` (Map of node key → rates)
* adds trend sparklines in watch mode.
*/
export function renderResult(tool, r, { color = false, history } = {}) {
const s = createStyle(color);
if (!r || typeof r !== 'object') return String(r);
if (tool === 'ruview_esp32_capture' || r.nodes) return renderCapture(r, s, history);
if (tool === 'ruview_devices_scan') return renderDevices(r, s);
if (tool === 'ruview_node_monitor') return renderMonitor(r, s);
if (tool === 'ruview_mmwave_read') return renderMmwave(r, s);
if (tool === 'ruview_node_flash' || tool === 'ruview_firmware_plan') return renderFlash(r, s);
return renderGeneric(tool, r, s);
}
/**
* Show a countdown on stderr while `fn` runs (TTY only). Returns fn's result.
*/
export async function withProgress(label, seconds, fn, stream = process.stderr) {
if (!stream.isTTY || !seconds) return fn();
const frames = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
const start = Date.now();
let i = 0;
const draw = () => {
const left = Math.max(0, seconds - (Date.now() - start) / 1000);
stream.write(`\r${frames[i++ % frames.length]} ${label} · ${left.toFixed(0)}s left `);
};
draw();
const timer = setInterval(draw, 120);
try { return await fn(); } finally { clearInterval(timer); stream.write('\r\x1b[2K'); }
}
+421
View File
@@ -0,0 +1,421 @@
// SPDX-License-Identifier: MIT
// `npx ruview` — the RuView WiFi-sensing operator harness (minted via metaharness,
// hardened per ADR-182). Plain ESM, no build step: ships and runs as-is.
//
// The `ruview.*` tools (onboard/verify/claim-check/…) and local host adapters are
// pure Node and run with zero runtime dependencies. Modules load on demand so
// `--help`/`--version`/`skills` never pay for the tool registry (ADR-375).
import { fileURLToPath } from 'node:url';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { colorEnabled, renderResult, withProgress } from './cli-ui.js';
const NAME = 'ruview';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const SKILLS_DIR = join(ROOT, 'skills');
const tools = () => import('./tools.js');
// Map friendly CLI verbs → registry tool names (underscore-canonical, ADR-263).
const VERB_TO_TOOL = {
onboard: 'ruview_onboard',
verify: 'ruview_verify',
'claim-check': 'ruview_claim_check',
calibrate: 'ruview_calibrate',
monitor: 'ruview_node_monitor',
flash: 'ruview_node_flash',
guidance: 'ruview_guidance',
spaces: 'ruview_spaces_list',
kernel: 'ruview_kernel_selftest',
'flash-plan': 'ruview_firmware_plan',
ports: 'ruview_firmware_ports',
train: 'ruview_train',
'train-plan': 'ruview_train_plan',
'train-gate': 'ruview_train_gate',
devices: 'ruview_devices_scan',
esp32: 'ruview_esp32_capture',
mmwave: 'ruview_mmwave_read',
lidar: 'ruview_lidar_read',
};
// Verbs whose kebab-case flags map 1:1 onto snake_case schema fields (ADR-369).
const SNAKE_VERBS = new Set(['flash', 'flash-plan', 'train', 'train-plan', 'train-gate', 'devices', 'esp32', 'mmwave', 'lidar']);
const NUMERIC_FLAGS = new Set(['baud', 'boot_log_seconds', 'samples', 'model_score', 'baseline_score', 'n_test', 'seconds', 'udp_port', 'max_packets', 'max_frames', 'node_id', 'analyze_max_frames', 'api_port', 'spectrum_bins', 'spectrum_frames']);
const BOOLEAN_FLAGS = new Set(['confirm', 'cuda', 'allow_unverified', 'analyze', 'spectrum']);
// Presentation-only flags never reach a tool schema.
const UI_FLAGS = new Set(['json', 'watch', 'interval']);
function toSchemaArgs(flags) {
const out = {};
for (const [key, value] of Object.entries(flags)) {
const k = key.replaceAll('-', '_');
if (UI_FLAGS.has(k)) continue;
if (NUMERIC_FLAGS.has(k)) out[k] = Number(value);
else if (BOOLEAN_FLAGS.has(k)) out[k] = value === true || value === 'true';
else if (k === 'train_subjects' || k === 'test_subjects') out[k] = String(value).split(',').filter(Boolean);
else out[k] = value;
}
return out;
}
function pjson(o) { console.log(JSON.stringify(o, null, 2)); }
/** JSON for pipes and --json; the terminal UI for a human at a TTY. */
function emit(tool, res, flags) {
if (flags.json === true || !process.stdout.isTTY) pjson(res);
else console.log(renderResult(tool, res, { color: colorEnabled() }));
}
/** Tools that hold the terminal for a known window get a countdown (TTY only). */
function progressLabel(tool, args) {
if (tool === 'ruview_esp32_capture') return [`Listening on UDP ${args.bind || '0.0.0.0'}:${args.udp_port || 5005}`, args.seconds ?? 10];
if (tool === 'ruview_node_monitor') return [`Reading ${args.port} at ${args.baud || 115200} baud`, args.seconds ?? 12];
if (tool === 'ruview_mmwave_read' || tool === 'ruview_lidar_read') return [`Reading ${args.host ? `ESPHome ${args.host}` : args.port || args.url || 'device'}`, args.seconds ?? 10];
return [null, 0];
}
async function callTool(tool, args) {
const { runTool } = await tools();
const [label, seconds] = progressLabel(tool, args);
return label ? withProgress(label, seconds, () => runTool(tool, args, { source: 'cli' })) : runTool(tool, args, { source: 'cli' });
}
/** `esp32 --watch`: repeated short captures, redrawn in place, with per-node trend lines. */
async function watchCapture(args, flags) {
const { runTool } = await tools();
const window = { ...args, seconds: args.seconds ?? 3 };
const history = new Map();
let stop = false;
process.once('SIGINT', () => { stop = true; });
const tty = process.stdout.isTTY && flags.json !== true;
while (!stop) {
const res = await runTool('ruview_esp32_capture', window, { source: 'cli' });
for (const n of res.nodes || []) {
const key = `${n.source}:${n.nodeId}`;
history.set(key, [...(history.get(key) || []), n.csiRateHz].slice(-30));
}
if (!tty) { console.log(JSON.stringify(res)); continue; }
process.stdout.write('\x1b[2J\x1b[H');
console.log(renderResult('ruview_esp32_capture', res, { color: colorEnabled(), history }));
console.log(`\n${new Date().toLocaleTimeString()} · ${window.seconds}s windows · Ctrl+C to stop`);
if (res.reason === 'port_in_use' || res.reason === 'invalid_port' || res.reason === 'invalid_bind') return 1;
}
return 0;
}
function listSkills() {
if (!existsSync(SKILLS_DIR)) return [];
return readdirSync(SKILLS_DIR).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, ''));
}
async function doctor(flags) {
const [{ formatDoctor, runDoctor, DOCTOR_GROUPS }, { OPERATOR_DEPS }] = await Promise.all([import('./doctor.js'), tools()]);
const groups = typeof flags.group === 'string' ? flags.group.split(',').map((g) => g.trim()).filter(Boolean) : undefined;
const unknown = (groups || []).filter((g) => !DOCTOR_GROUPS.includes(g));
if (unknown.length) { console.error(`doctor: unknown group(s) ${unknown.join(', ')}; valid: ${DOCTOR_GROUPS.join(', ')}`); return 2; }
const report = await runDoctor({
groups,
port: typeof flags.port === 'string' ? flags.port : undefined,
probe_port: flags.probe === true,
sensing_url: typeof flags.url === 'string' ? flags.url : undefined,
}, OPERATOR_DEPS);
if (flags.json === true) pjson(report);
else console.log(formatDoctor(report));
return report.ok ? 0 : 1;
}
async function mcp(rest, flags, handler) {
if (rest[0] !== undefined && rest[0] !== 'start') { console.error('Usage: ruview mcp start [--http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]]'); return 2; }
if (flags.http !== true) {
const { startMcpServer } = await import('./mcp-server.js');
startMcpServer(handler ? { handler } : {});
return new Promise(() => {}); // run until stdin closes
}
const { startMcpHttp, DEFAULT_HTTP_PORT } = await import('./mcp-http.js');
const host = typeof flags.host === 'string' ? flags.host : '127.0.0.1';
const port = flags.port === undefined ? DEFAULT_HTTP_PORT : Number(flags.port);
if (!Number.isInteger(port) || port < 0 || port > 65535) { console.error('mcp start: --port must be 0..65535'); return 2; }
const allowOrigins = typeof flags['allow-origin'] === 'string' ? flags['allow-origin'].split(',').map((o) => o.trim()).filter(Boolean) : [];
// The token is read from the environment, never from argv (process lists are visible to other users).
const srv = await startMcpHttp({ host, port, token: process.env.RUVIEW_MCP_TOKEN, allowOrigins, ...(handler ? { handler } : {}) });
const generated = !process.env.RUVIEW_MCP_TOKEN;
process.stderr.write([
`ruview MCP over HTTP on ${srv.url}${srv.loopback ? '' : ' (non-loopback bind: anyone who can reach this port and holds the token can call read tools)'}`,
` grants: ${srv.grants.join(', ') || 'none (set RUVIEW_MCP_GRANTS=device-access for hardware reads)'}`,
` token: ${generated ? `${srv.token} (generated; set RUVIEW_MCP_TOKEN to keep it stable)` : 'from RUVIEW_MCP_TOKEN'}`,
' clients with headers: Authorization: Bearer <token>',
' ChatGPT (no custom headers): expose the port over HTTPS (e.g. a tunnel) and add the connector URL',
` https://<public-host>/mcp/${generated ? srv.token : '<token>'}`,
'',
].join('\n'));
const shutdown = () => srv.close().then(() => process.exit(0));
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
return new Promise(() => {});
}
function help() {
console.log(`Usage: ${NAME} <command> [options]
Operator tools:
onboard [--path docker-demo|repo-build|live-esp32] pick a setup path
verify [--repo <dir>] run the deterministic proof (VERDICT: PASS)
claim-check --text "..." | --file <path> lint accuracy claims (the honesty guardrail)
calibrate --step baseline|enroll|train-room|room-watch
monitor --port COM8 [--seconds 12] [--baud 1500000] assert CSI is flowing on a node (no reset on open)
flash --port COM8 --variant s3-8mb [--confirm] build+flash firmware (Windows/ESP-IDF)
guidance [--topic homecore] [--query "Wasmtime"] source-cited code/capability map
spaces [--resource sites|...|alerts] [--limit 50] page OAuth-bound Cognitum spatial resources
kernel [--backend wasm|napi|auto] [--seconds 60] SYNTHETIC self-test of @ruvnet/ruview-kernel (optional)
Firmware (ADR-370, cross-platform esptool):
ports list serial ports (pyserial)
flash-plan --port <p> --bundle <dir> [--variant s3-8mb|s3-4mb|c6] verify + print the plan
flash --port <p> --bundle <dir> [--variant ...] [--baud 460800] --confirm
[--boot-log-seconds 15] [--allow-unverified] write flash, then capture boot evidence
Devices (ADR-373) — run on the machine the hardware is attached to:
devices classify USB serial devices (ESP32, Realtek, mmWave, RPLIDAR)
esp32 [--udp-port 5005] [--seconds 10] [--bind 0.0.0.0] receive + summarize ESP32 / Realtek RAC1 node UDP streams
[--analyze [--node-id N] [--backend wasm|napi|auto]] run live CSI through @ruvnet/ruview-kernel
[--watch [--seconds 3]] live view: repeated windows with per-node trends
mmwave --port <p> [--model auto|mr60bha2|ld2410] [--seconds 10] 60/24 GHz radar readout
mmwave --source esphome --host <ip> [--api-port 6053] radar kit running ESPHome (e.g. Seeed MR60BHA2 kit)
lidar --source rplidar --port <p> [--baud 115200] RPLIDAR scan summary
lidar --source iphone --url ws://HOST:8787/ws/lidar iPhone LiDAR relay (token: RUVIEW_LIDAR_TOKEN)
Remote hosts (ADR-374) — SSH, read-only tools only:
hosts add --name pi --ssh user@pi.local [--port 22] [--version X.Y.Z]
hosts list
hosts run --host pi --tool ruview_devices_scan [--args-json '{"seconds":10}']
call <tool> [--read-only] [--args-json '{...}'] generic schema-validated tool call
Training (ADR-371):
train-plan [--mode pose-smoke|pose|room] [...] resolve the command, run nothing
train --mode pose-smoke|pose|room [--config f] [--data-dir d] [--checkpoint-dir d]
[--enrollment f] [--output f] [--samples 64] [--cuda] --confirm
train-gate --file report.json | --model-score .59 --baseline-score .50 --split chronological ...
Harness:
doctor [--json] [--group a,b] [--port P [--probe]] [--url http://host:port]
structured diagnostics with fixes (ADR-372)
skills list bundled skills
skill <name> print a skill playbook
mod [--json] where the ruview-live Claude Code mod is, and how to load it (ADR-377)
mcp start run the ruview.* MCP server (stdio)
mcp start --http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]
MCP over HTTP for ChatGPT and remote clients (ADR-375);
token: RUVIEW_MCP_TOKEN (generated if unset), grants: RUVIEW_MCP_GRANTS
install --host <h> project the harness config into the current repo
agent run --host claude-code|codex --prompt "..." [--repo <dir>]
brain search --query "..." | verify | propose
--version | --help
Output: a terminal gets a formatted view; pipes and --json get JSON.
Hosts implemented and tested locally: claude-code (-p), codex (exec)`);
return 0;
}
/** tiny flag parser: --k v / --k=v / --flag (boolean) */
function parseFlags(rest) {
const f = {};
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a.startsWith('--')) {
const eq = a.indexOf('=');
if (eq !== -1) { f[a.slice(2, eq)] = a.slice(eq + 1); }
else if (i + 1 < rest.length && !rest[i + 1].startsWith('--')) { f[a.slice(2)] = rest[++i]; }
else { f[a.slice(2)] = true; }
}
}
return f;
}
/** Run the CLI. `opts.mcpHandler` lets an embedding package serve a merged MCP tool set. */
export async function run(args, opts = {}) {
const cmd = args[0] ?? 'onboard';
const rest = args.slice(1);
const flags = parseFlags(rest);
// Direct tool verbs.
if (VERB_TO_TOOL[cmd]) {
const tool = VERB_TO_TOOL[cmd];
const toolArgs = { ...flags };
for (const k of UI_FLAGS) delete toolArgs[k];
if (cmd === 'claim-check') {
if (flags.file) {
toolArgs.text = readFileSync(flags.file, 'utf8');
delete toolArgs.file;
}
// Fail closed (ADR-263 O1): an honesty gate must never PASS on no input.
if (typeof toolArgs.text !== 'string' || toolArgs.text.trim().length === 0) {
console.error('claim-check: no input — pass --text "..." or --file <path> (empty input is an error, not a PASS).');
return 2;
}
const res = await callTool(tool, toolArgs);
emit(tool, res, flags);
return res.ok ? 0 : 1;
}
if (cmd === 'monitor' && flags.seconds) toolArgs.seconds = Number(flags.seconds);
if (cmd === 'monitor' && flags.baud) toolArgs.baud = Number(flags.baud);
if (cmd === 'guidance' && flags.limit) toolArgs.limit = Number(flags.limit);
if (cmd === 'calibrate' && typeof flags.args === 'string') toolArgs.args = flags.args.split(',');
if (cmd === 'kernel' && flags.seconds !== undefined) toolArgs.seconds = Number(flags.seconds);
if (SNAKE_VERBS.has(cmd)) {
let snake = toSchemaArgs(flags);
if (cmd === 'train-gate' && typeof flags.file === 'string') {
snake = JSON.parse(readFileSync(flags.file, 'utf8'));
}
if (cmd === 'esp32' && flags.watch === true) return watchCapture(snake, flags);
const res = await callTool(tool, snake);
emit(tool, res, flags);
return res.ok ? 0 : 1;
}
if (cmd === 'spaces') {
if (flags['credentials-path'] !== undefined) toolArgs.credentials_path = flags['credentials-path'];
delete toolArgs['credentials-path'];
if (flags.limit !== undefined) toolArgs.limit = Number(flags.limit);
}
const res = await callTool(tool, toolArgs);
emit(tool, res, flags);
return res.ok ? 0 : 1;
}
switch (cmd) {
case 'doctor': return doctor(flags);
case 'skills': console.log(listSkills().join('\n') || '(none)'); return 0;
case 'skill': {
const n = rest[0];
const p = n && join(SKILLS_DIR, `${n}.md`);
if (!p || !existsSync(p)) { console.error(`No skill "${n}". Try: ${listSkills().join(', ')}`); return 2; }
console.log(readFileSync(p, 'utf8'));
return 0;
}
case 'mcp': return mcp(rest, flags, opts.mcpHandler);
case 'mod': {
// The ruview-live Claude Code mod ships inside this package (ADR-377).
const dir = join(ROOT, 'mod');
const info = {
ok: existsSync(join(dir, '.claude-plugin', 'plugin.json')),
plugin: 'ruview-live',
path: dir,
run: `claude --plugin-dir "${dir}"`,
marketplace: ['/plugin marketplace add ruvnet/RuView', '/plugin install ruview-live@ruview'],
usage: ['/ruview open or close the live pane', '/ruview refresh one capture now, result in the status line', '/ruview off close the pane'],
configure: 'claude plugin configure ruview-live (udpPort, radarHost, refreshSeconds, captureSeconds)',
note: 'Mods are early access in Claude Code. If /ruview is missing, start Claude Code with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.',
};
if (flags.json === true || !process.stdout.isTTY) pjson(info);
else console.log([`ruview-live mod: ${info.path}`, ` try it: ${info.run}`, ` install: ${info.marketplace.join(' then ')}`, ...info.usage.map((u) => ` ${u}`), ` ${info.configure}`, ` ${info.note}`].join('\n'));
return info.ok ? 0 : 1;
}
case 'agent': {
if (rest[0] !== 'run') { console.error('Usage: ruview agent run --host claude-code|codex --prompt "..." [--repo <dir>]'); return 2; }
const [{ findRepoRoot }, { getHost }] = await Promise.all([tools(), import('./hosts/index.js')]);
const hostName = String(flags.host || 'codex');
const prompt = String(flags.prompt || '');
const repo = flags.repo ? resolve(flags.repo) : findRepoRoot();
if (!repo) { console.error('agent run: trusted RuView repo not found; pass --repo <root>.'); return 2; }
if (!prompt.trim()) { console.error('agent run: --prompt is required.'); return 2; }
const allowWrite = flags['allow-write'] === true;
if (allowWrite && flags.confirm !== true) { console.error('agent run: --allow-write also requires --confirm.'); return 2; }
try {
const result = await getHost(hostName).run({
prompt, repoRoot: repo, trustedRoot: repo, allowWrite, confirm: flags.confirm === true,
});
pjson({ ok: true, host: hostName, mode: allowWrite ? 'workspace-write' : 'read-only', stdout: result.stdout, stderr: result.stderr });
return 0;
} catch (error) {
pjson({ ok: false, host: hostName, error: error.message });
return 1;
}
}
case 'brain': {
const { makeProposal, searchBrain, verifyBrain } = await import('./brain.js');
const action = rest[0] || 'search';
if (action === 'search') {
const query = String(flags.query || '');
if (!query.trim()) { console.error('brain search: --query is required.'); return 2; }
pjson({ ok: true, results: searchBrain(query, { limit: flags.limit }) }); return 0;
}
if (action === 'verify') {
const { findRepoRoot } = await tools();
const repo = flags.repo ? resolve(flags.repo) : findRepoRoot();
if (!repo) { console.error('brain verify: RuView repo not found.'); return 2; }
const result = verifyBrain({ repo }); pjson(result); return result.ok ? 0 : 1;
}
if (action === 'propose') {
const result = makeProposal(flags); pjson(result); return result.ok ? 0 : 1;
}
console.error('Usage: ruview brain search|verify|propose'); return 2;
}
case 'install': {
const host = flags.host || 'claude-code';
if (!['claude-code', 'codex'].includes(host)) {
console.error(`Host "${host}" is not implemented. Supported: claude-code, codex.`);
return 2;
}
try {
const { getHost } = await import('./hosts/index.js');
const adapter = getHost(host);
console.log(`Projecting RuView harness for host "${host}" via ${adapter.name}.`);
console.log('Add to your host config — MCP server command: npx -y ruview mcp start');
console.log('Skills:', listSkills().join(', '));
return 0;
} catch {
console.error(`Host adapter "${host}" is unavailable.`);
return 1;
}
}
case 'tools': { const { listTools } = await tools(); pjson(listTools()); return 0; }
case 'call': {
// Generic, schema-validated tool call (ADR-374). `--read-only` is what
// remote hosts receive: it refuses anything but read-only tools.
const { isRemoteReadOnlyTool, resolveToolName } = await tools();
const name = resolveToolName(rest[0] || '');
if (!name) { console.error('Usage: ruview call <tool> [--read-only] [--args-json \'{...}\']'); return 2; }
if (flags['read-only'] === true && !isRemoteReadOnlyTool(name)) {
pjson({ ok: false, reason: 'remote_tool_not_allowed', name }); return 1;
}
let toolArgs = {};
if (typeof flags['args-json'] === 'string') {
try { toolArgs = JSON.parse(flags['args-json']); } catch { pjson({ ok: false, reason: 'invalid_arguments', errors: ['--args-json is not valid JSON'] }); return 2; }
}
const res = await callTool(name, toolArgs);
// Remote hosts parse this stream: `call` always prints JSON.
pjson(res);
return res.ok ? 0 : 1;
}
case 'hosts': {
const action = rest[0] || 'list';
if (action === 'list') { const res = await callTool('ruview_host_list', {}); pjson(res); return res.ok ? 0 : 1; }
if (action === 'add') {
const { saveHost } = await import('./remote.js');
try {
const out = saveHost({ name: flags.name, ssh: flags.ssh, port: flags.port ? Number(flags.port) : undefined, version: flags.version, description: flags.description });
pjson({ ok: true, ...out, next: `Verify the host key once: ssh ${out.host.ssh} true` }); return 0;
} catch (error) { pjson({ ok: false, reason: error.reason || 'invalid_host', detail: error.message }); return 2; }
}
if (action === 'run') {
const { resolveToolName } = await tools();
let toolArgs = {};
if (typeof flags['args-json'] === 'string') {
try { toolArgs = JSON.parse(flags['args-json']); } catch { console.error('--args-json is not valid JSON'); return 2; }
}
const tool = resolveToolName(String(flags.tool || '')) || String(flags.tool || '');
const res = await callTool('ruview_host_run', { host: String(flags.host || ''), tool, args: toolArgs });
pjson(res); return res.ok ? 0 : 1;
}
console.error('Usage: ruview hosts list | add --name pi --ssh user@pi.local [--port 22] [--version X.Y.Z] | run --host pi --tool ruview_devices_scan [--args-json {...}]');
return 2;
}
case '--version': case '-v': {
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
console.log(pkg.version); return 0;
}
case '--help': case '-h': return help();
default:
console.error(`Unknown command: ${cmd}. Try \`${NAME} --help\`.`);
return 2;
}
}
+471
View File
@@ -0,0 +1,471 @@
// SPDX-License-Identifier: MIT
// CSI node network access (ADR-373): receive the node UDP stream and summarize
// it. Decodes ESP32 ADR-018 frames and edge packets, ADR-110 sync packets, and
// Realtek RTL8721Dx RAC1 CSI envelopes plus their RHB1 heartbeats (ADR-323).
// Receive-only: the socket never sends a byte to a node.
import dgram from 'node:dgram';
/** ESP32 packet magics (little-endian u32) from firmware/esp32-csi-node/main. */
export const PACKET_KINDS = Object.freeze({
0xC5110001: 'csi',
0xC5110002: 'vitals',
0xC5110003: 'features',
0xC5110004: 'fused-vitals',
0xC5110005: 'compressed',
0xC5110006: 'feature-state',
0xC5110007: 'wasm-output',
0xC511A110: 'sync',
});
/** ADR-081 mesh envelope (rv_mesh.h): 16-byte header, no one-byte node id. */
export const MESH_MAGIC = 0xC5118100;
const MESH_TYPES = Object.freeze({
1: 'time-sync', 2: 'role-assign', 3: 'channel-plan', 4: 'calibration-start',
5: 'feature-delta', 6: 'health', 7: 'anomaly-alert',
});
/** Realtek RTL8721Dx magics (ADR-323): ASCII "RAC1" and "RHB1" read as LE u32. */
export const RAC1_MAGIC = 0x31434152;
export const RHB1_MAGIC = 0x31424852;
const RAC1_HEADER_LEN = 49;
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
/** CRC-32/IEEE, the RAC1 trailer checksum. */
export function crc32(buf, end = buf.length) {
let c = 0xFFFFFFFF;
for (let i = 0; i < end; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
/**
* Mean |IQ| over int8 pairs, and optionally the per-tone amplitude/phase arrays.
* `imagFirst` matches the ESP-IDF buffer order (imag, real); Realtek is (I, Q).
*/
function iq8(buf, offset, tones, imagFirst, wantIq) {
const v = new Int8Array(buf.buffer, buf.byteOffset + offset, tones * 2);
let sum = 0;
const amplitudes = wantIq ? new Array(tones) : null;
const phases = wantIq ? new Array(tones) : null;
for (let k = 0, j = 0; k < tones; k++, j += 2) {
const re = imagFirst ? v[j + 1] : v[j];
const im = imagFirst ? v[j] : v[j + 1];
const a = Math.sqrt(re * re + im * im);
sum += a;
if (wantIq) { amplitudes[k] = a; phases[k] = Math.atan2(im, re); }
}
return { mean: sum / tones, amplitudes, phases };
}
function iq16(buf, offset, tones, wantIq) {
let sum = 0;
const amplitudes = wantIq ? new Array(tones) : null;
const phases = wantIq ? new Array(tones) : null;
for (let k = 0; k < tones; k++) {
const re = buf.readInt16LE(offset + 4 * k);
const im = buf.readInt16LE(offset + 4 * k + 2);
const a = Math.sqrt(re * re + im * im);
sum += a;
if (wantIq) { amplitudes[k] = a; phases[k] = Math.atan2(im, re); }
}
return { mean: sum / tones, amplitudes, phases };
}
function parseRac1(buf, wantIq) {
if (buf.length < RAC1_HEADER_LEN + 4) return { kind: 'malformed', reason: 'rac1 header' };
const version = buf.readUInt8(4);
const headerLen = buf.readUInt16LE(5);
const frameLen = buf.readUInt32LE(7);
if (version !== 1 || headerLen !== RAC1_HEADER_LEN) return { kind: 'malformed', reason: 'rac1 version' };
if (frameLen !== buf.length) return { kind: 'malformed', reason: 'rac1 length' };
const tones = buf.readUInt16LE(37);
const bitsPerTone = buf.readUInt8(39);
const payloadLen = buf.readUInt32LE(45);
if ((bitsPerTone !== 16 && bitsPerTone !== 32) || tones === 0 || payloadLen !== tones * (bitsPerTone / 8)
|| RAC1_HEADER_LEN + payloadLen + 4 !== frameLen) return { kind: 'malformed', reason: 'rac1 payload' };
if (crc32(buf, frameLen - 4) !== buf.readUInt32LE(frameLen - 4)) return { kind: 'malformed', reason: 'rac1 crc' };
const iq = bitsPerTone === 16 ? iq8(buf, RAC1_HEADER_LEN, tones, false, wantIq) : iq16(buf, RAC1_HEADER_LEN, tones, wantIq);
const flags = buf.readUInt8(44);
return {
kind: 'csi', source: 'realtek', nodeId: buf.readUInt8(11), antennas: 1, subcarriers: tones,
channel: buf.readUInt8(33), seq: buf.readUInt32LE(13), rssi: buf.readInt8(41),
bw40: buf.readUInt8(34) === 1, protocol: buf.readUInt8(36), bitsPerTone,
csiValid: buf.readUInt8(43) === 1, synthetic: Boolean(flags & 1),
meanAmplitude: iq.mean, amplitudes: iq.amplitudes, phases: iq.phases,
};
}
/**
* Parse one datagram. Unknown or malformed input returns {kind:'unknown'|'malformed'}.
* opts.iq adds per-tone `amplitudes`/`phases` to CSI packets (used for kernel analysis).
*/
export function parsePacket(buf, opts = {}) {
if (!Buffer.isBuffer(buf) || buf.length < 4) return { kind: 'malformed', reason: 'short' };
const magic = buf.readUInt32LE(0);
if (magic === RAC1_MAGIC) return parseRac1(buf, Boolean(opts.iq));
if (magic === RHB1_MAGIC) return { kind: 'heartbeat', source: 'realtek', nodeId: null };
if (magic === MESH_MAGIC) {
if (buf.length < 16) return { kind: 'malformed', reason: 'mesh header' };
const type = buf.readUInt8(5);
return { kind: 'mesh', source: 'esp32', nodeId: null, version: buf.readUInt8(4), msgType: MESH_TYPES[type] || `type-${type}` };
}
const kind = PACKET_KINDS[magic];
if (!kind) return { kind: 'unknown', magic: `0x${magic.toString(16)}` };
if (kind === 'csi') {
if (buf.length < 20) return { kind: 'malformed', reason: 'csi header' };
const antennas = buf.readUInt8(5);
const subcarriers = buf.readUInt16LE(6);
const tones = antennas * subcarriers;
if (antennas === 0 || subcarriers === 0 || buf.length < 20 + tones * 2) return { kind: 'malformed', reason: 'csi payload' };
const flags = buf.readUInt8(19);
// Per-tone arrays only for single-antenna frames: the kernel takes one chain.
const iq = iq8(buf, 20, tones, true, Boolean(opts.iq) && antennas === 1);
// The waterfall (ADR-378) draws multi-antenna nodes from their first chain:
// ADR-018 lays tones out antenna-major, so chain 0 is the first `subcarriers`.
const chain0 = opts.firstChain && antennas > 1 ? iq8(buf, 20, subcarriers, true, true).amplitudes : undefined;
return {
kind, source: 'esp32', nodeId: buf.readUInt8(4), antennas, subcarriers,
freqMhz: buf.readUInt32LE(8), seq: buf.readUInt32LE(12),
rssi: buf.readInt8(16), noiseFloor: buf.readInt8(17), ppdu: buf.readUInt8(18),
bw40: Boolean(flags & 1), firstWordZeroed: Boolean(flags & 0x20),
meanAmplitude: iq.mean, amplitudes: iq.amplitudes, phases: iq.phases,
...(chain0 ? { chain0Amplitudes: chain0 } : {}),
};
}
if (kind === 'vitals') {
if (buf.length < 32) return { kind: 'malformed', reason: 'vitals length' };
const flags = buf.readUInt8(5);
return {
kind, source: 'esp32', nodeId: buf.readUInt8(4),
presence: Boolean(flags & 1), fall: Boolean(flags & 2), motion: Boolean(flags & 4),
breathingBpm: buf.readUInt16LE(6) / 100, heartBpm: buf.readUInt32LE(8) / 10000,
rssi: buf.readInt8(12), persons: buf.readUInt8(13),
motionEnergy: buf.readFloatLE(16), presenceScore: buf.readFloatLE(20), uptimeMs: buf.readUInt32LE(24),
};
}
if (kind === 'sync') {
if (buf.length < 32) return { kind: 'malformed', reason: 'sync length' };
return {
kind, source: 'esp32', nodeId: buf.readUInt8(4), protoVer: buf.readUInt8(5),
leader: Boolean(buf.readUInt8(6) & 1), highWaterSeq: buf.readUInt32LE(24),
};
}
return { kind, source: 'esp32', nodeId: buf.length > 4 ? buf.readUInt8(4) : null, bytes: buf.length };
}
const shapeKey = (pkt) => `${pkt.antennas}x${pkt.subcarriers}`;
/** Jumps larger than this are a counter reset or a stray frame, not loss. */
export const SEQ_WINDOW = 1024;
/**
* Loss accounting that survives UDP reordering and stray sequence values.
* A late packet fills the hole it left instead of being re-counted. A single
* out-of-window value is a stray (observed live on an RTL8721Dx: sporadic
* seq 0 and +52,835 frames between in-order ones) and leaves the baseline
* alone; two consecutive frames agreeing on a new range are a counter reset.
*/
function trackSequence(n, seq) {
if (n.lastSeq === null) { n.lastSeq = seq; return; }
const d = seq - n.lastSeq;
if (d >= 1 && d <= SEQ_WINDOW) {
n.seqGaps += d - 1;
n.lastSeq = seq;
n.pendingSeq = null;
} else if (d <= 0 && d > -SEQ_WINDOW) {
n.reordered += 1; // late or duplicate; a late packet fills one counted gap
if (d < 0 && n.seqGaps > 0) n.seqGaps -= 1;
} else if (n.pendingSeq != null && seq - n.pendingSeq >= 1 && seq - n.pendingSeq <= SEQ_WINDOW) {
n.strays -= 1; // the previous out-of-window frame started a new range
n.resyncs += 1;
n.lastSeq = seq;
n.pendingSeq = null;
} else {
n.strays += 1;
n.pendingSeq = seq;
}
}
/** Aggregate parsed packets into a per-node summary. */
export class NodeStats {
constructor() {
this.nodes = new Map();
this.unknown = 0;
this.unknownMagics = new Map();
this.malformed = 0;
this.malformedReasons = new Map();
this.senders = new Map(); // address → { packets, csi, heartbeats }
this.mesh = new Map(); // msgType → count
}
add(pkt, sender) {
const s = sender ? (this.senders.get(sender) || { packets: 0, csi: 0, heartbeats: 0 }) : null;
if (s) { s.packets += 1; this.senders.set(sender, s); }
if (pkt.kind === 'unknown') {
this.unknown += 1;
if (this.unknownMagics.size < 32 || this.unknownMagics.has(pkt.magic)) this.unknownMagics.set(pkt.magic, (this.unknownMagics.get(pkt.magic) || 0) + 1);
return;
}
if (pkt.kind === 'malformed') {
this.malformed += 1;
this.malformedReasons.set(pkt.reason, (this.malformedReasons.get(pkt.reason) || 0) + 1);
return;
}
if (pkt.kind === 'heartbeat') { if (s) s.heartbeats += 1; return; }
if (pkt.kind === 'mesh') { this.mesh.set(pkt.msgType, (this.mesh.get(pkt.msgType) || 0) + 1); return; }
if (s && pkt.kind === 'csi') s.csi += 1;
const key = `${pkt.source}:${pkt.nodeId ?? -1}`;
let n = this.nodes.get(key);
if (!n) {
n = { source: pkt.source, nodeId: pkt.nodeId ?? -1, packets: {}, rssiSum: 0, rssiCount: 0, lastSeq: null, pendingSeq: null, seqGaps: 0, reordered: 0, strays: 0, resyncs: 0, shapes: new Map(), vitals: null };
this.nodes.set(key, n);
}
n.packets[pkt.kind] = (n.packets[pkt.kind] || 0) + 1;
if (pkt.kind === 'csi') {
n.rssiSum += pkt.rssi; n.rssiCount += 1;
trackSequence(n, pkt.seq);
const shape = shapeKey(pkt);
const sh = n.shapes.get(shape) || { count: 0, ampSum: 0, last: null };
sh.count += 1; sh.ampSum += pkt.meanAmplitude; sh.last = pkt;
n.shapes.set(shape, sh);
} else if (pkt.kind === 'vitals') {
n.rssiSum += pkt.rssi; n.rssiCount += 1;
const { kind, nodeId, source, ...rest } = pkt;
n.vitals = rest;
}
}
summary(seconds) {
const nodes = [...this.nodes.values()]
.sort((a, b) => (a.source === b.source ? a.nodeId - b.nodeId : a.source.localeCompare(b.source)))
.map((n) => {
const csi = n.packets.csi || 0;
const received = csi + n.seqGaps;
const shapes = [...n.shapes.entries()].sort((a, b) => b[1].count - a[1].count);
const describe = ([shape, sh]) => {
const p = sh.last;
return {
shape, frames: sh.count, antennas: p.antennas, subcarriers: p.subcarriers,
...(p.source === 'esp32' ? { freqMhz: p.freqMhz, ppdu: p.ppdu } : { channel: p.channel, protocol: p.protocol, bitsPerTone: p.bitsPerTone, synthetic: p.synthetic }),
bw40: p.bw40, meanAmplitude: Number((sh.ampSum / sh.count).toFixed(2)),
};
};
return {
source: n.source,
nodeId: n.nodeId,
packets: n.packets,
csiRateHz: Number((csi / seconds).toFixed(2)),
csiLossFraction: received ? Number((n.seqGaps / received).toFixed(4)) : null,
...(n.reordered ? { seqReordered: n.reordered } : {}),
...(n.strays ? { seqStrays: n.strays } : {}),
...(n.resyncs ? { seqResyncs: n.resyncs } : {}),
rssiMean: n.rssiCount ? Number((n.rssiSum / n.rssiCount).toFixed(1)) : null,
csi: shapes.length ? describe(shapes[0]) : null,
...(shapes.length > 1 ? { csiShapes: shapes.map(describe) } : {}),
lastVitals: n.vitals,
};
});
const senders = [...this.senders.keys()].slice(0, 32);
const heartbeatOnly = [...this.senders.entries()].filter(([, s]) => s.heartbeats > 0 && s.csi === 0).map(([a, s]) => ({ address: a, heartbeats: s.heartbeats }));
return {
nodes,
unknownPackets: this.unknown,
...(this.unknown ? { unknownMagics: Object.fromEntries(this.unknownMagics) } : {}),
malformedPackets: this.malformed,
...(this.malformed ? { malformedReasons: Object.fromEntries(this.malformedReasons) } : {}),
heartbeats: [...this.senders.values()].reduce((a, s) => a + s.heartbeats, 0),
...(this.mesh.size ? { meshMessages: Object.fromEntries(this.mesh) } : {}),
...(heartbeatOnly.length ? { heartbeatOnlySenders: heartbeatOnly } : {}),
senders,
};
}
}
/** Bounded per-node frame buffer for kernel analysis (one CSI shape per node). */
class FrameCollector {
constructor(maxFrames) { this.max = maxFrames; this.byNode = new Map(); }
add(pkt, t) {
if (pkt.kind !== 'csi' || !pkt.amplitudes) return;
const key = `${pkt.source}:${pkt.nodeId}|${shapeKey(pkt)}`;
let c = this.byNode.get(key);
if (!c) { c = { source: pkt.source, nodeId: pkt.nodeId, subcarriers: pkt.subcarriers, frames: [], first: t, last: t, dropped: 0 }; this.byNode.set(key, c); }
if (c.frames.length >= this.max) { c.dropped += 1; return; }
c.frames.push({ amplitudes: pkt.amplitudes, phases: pkt.phases });
c.last = t;
}
/** The requested node's (or the busiest node's) dominant shape. */
pick(nodeId) {
const all = [...this.byNode.values()].filter((c) => nodeId === undefined || c.nodeId === nodeId);
return all.sort((a, b) => b.frames.length - a.frames.length)[0] || null;
}
}
/** Average a frame's per-subcarrier amplitudes into `bins` equal groups. */
export function binAmplitudes(amplitudes, bins) {
const n = amplitudes.length;
const b = Math.max(1, Math.min(bins, n));
const out = new Array(b);
for (let i = 0; i < b; i++) {
const start = Math.floor((i * n) / b);
const end = Math.max(start + 1, Math.floor(((i + 1) * n) / b));
let sum = 0;
for (let k = start; k < end; k++) sum += amplitudes[k];
out[i] = Math.round((sum / (end - start)) * 10) / 10;
}
return out;
}
/**
* The most recent amplitude frames per node, binned, for a waterfall view
* (ADR-378). A ring buffer: unlike the analyzer's collector it keeps the
* newest frames. Each node reports its dominant CSI shape only.
*/
export class SpectrumCollector {
constructor(frames, bins, maxNodes = 8) { this.frames = frames; this.bins = bins; this.maxNodes = maxNodes; this.byKey = new Map(); }
add(pkt, t) {
const amplitudes = pkt.amplitudes ?? pkt.chain0Amplitudes;
if (pkt.kind !== 'csi' || !amplitudes) return;
const key = `${pkt.source}:${pkt.nodeId}|${shapeKey(pkt)}`;
let c = this.byKey.get(key);
if (!c) {
if (this.byKey.size >= this.maxNodes * 4) return;
c = { source: pkt.source, nodeId: pkt.nodeId, shape: shapeKey(pkt), subcarriers: pkt.subcarriers, rows: [], count: 0, first: t, last: t, synthetic: false };
this.byKey.set(key, c);
}
c.rows.push(binAmplitudes(amplitudes, this.bins));
if (c.rows.length > this.frames) c.rows.shift();
c.count += 1;
c.last = t;
c.synthetic ||= Boolean(pkt.synthetic);
}
result() {
const best = new Map();
for (const c of this.byKey.values()) {
const node = `${c.source}:${c.nodeId}`;
if (!best.has(node) || best.get(node).count < c.count) best.set(node, c);
}
return [...best.values()]
.sort((a, b) => (a.source === b.source ? a.nodeId - b.nodeId : a.source.localeCompare(b.source)))
.slice(0, this.maxNodes)
.map((c) => ({
source: c.source, nodeId: c.nodeId, shape: c.shape, subcarriers: c.subcarriers, bins: c.rows[0]?.length ?? 0,
frames: c.rows, framesSeen: c.count,
rateHz: c.last > c.first ? Number((((c.count - 1) * 1000) / (c.last - c.first)).toFixed(2)) : null,
synthetic: c.synthetic,
}));
}
}
const BIND_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::', '::1']);
const NODE_PACKETS = (summary) => summary.nodes.reduce((a, n) => a + Object.values(n.packets).reduce((x, y) => x + y, 0), 0);
/**
* Listen for node packets. args: { udp_port=5005, bind='0.0.0.0', seconds=10, max_packets=200000,
* analyze=false, node_id, analyze_max_frames=6000, spectrum=false, spectrum_bins=48, spectrum_frames=64 }.
* deps.createSocket is injectable for tests;
* deps.analyze(frames, config) runs the compute kernel when args.analyze is set.
*/
export function captureEsp32(args = {}, deps = {}) {
const port = args.udp_port ?? 5005;
const bind = args.bind ?? '0.0.0.0';
const seconds = Math.min(Math.max(args.seconds ?? 10, 1), 300);
const maxPackets = Math.min(args.max_packets ?? 200_000, 1_000_000);
const analyze = Boolean(args.analyze);
const maxFrames = Math.min(Math.max(args.analyze_max_frames ?? 6000, 64), 20_000);
const spectrum = Boolean(args.spectrum);
const spectrumBins = Math.min(Math.max(Math.round(args.spectrum_bins ?? 48), 8), 128);
const spectrumFrames = Math.min(Math.max(Math.round(args.spectrum_frames ?? 64), 8), 256);
if (!Number.isInteger(port) || port < 1024 || port > 65535) return Promise.resolve({ ok: false, reason: 'invalid_port', detail: 'udp_port must be an integer in 1024..65535' });
if (!BIND_HOSTS.has(bind)) return Promise.resolve({ ok: false, reason: 'invalid_bind', detail: `bind must be one of ${[...BIND_HOSTS].join(', ')}` });
if (args.node_id !== undefined && (!Number.isInteger(args.node_id) || args.node_id < 0 || args.node_id > 255)) return Promise.resolve({ ok: false, reason: 'invalid_node_id', detail: 'node_id must be an integer in 0..255' });
if (analyze && typeof deps.analyze !== 'function') return Promise.resolve({ ok: false, reason: 'analyze_unavailable', detail: 'no compute kernel bridge was provided' });
const createSocket = deps.createSocket || ((type) => dgram.createSocket({ type, reuseAddr: false }));
const stats = new NodeStats();
const collector = analyze ? new FrameCollector(maxFrames) : null;
const spectra = spectrum ? new SpectrumCollector(spectrumFrames, spectrumBins) : null;
const parseOpts = { iq: analyze || spectrum, firstChain: spectrum };
let packets = 0;
return new Promise((resolve) => {
const socket = createSocket(bind.includes(':') ? 'udp6' : 'udp4');
let timer;
let done = false;
const finish = async (extra = {}) => {
if (done) return;
done = true;
clearTimeout(timer);
try { socket.close(); } catch { /* already closed */ }
const summary = stats.summary(seconds);
const csiNodes = summary.nodes.filter((n) => (n.packets.csi || 0) > 0).length;
const decoded = packets - summary.unknownPackets - summary.malformedPackets;
let failure = null;
if (packets === 0) {
failure = { reason: 'no_packets', remedy: `No node packets reached ${bind}:${port}. Check the node's target IP/port (provision.py --target-ip/--target-port), that this host is on the same network, and the firewall (UDP ${port}).` };
} else if (NODE_PACKETS(summary) === 0 && !summary.meshMessages && summary.heartbeats > 0) {
failure = { reason: 'heartbeat_only', remedy: 'Realtek nodes are alive (RHB1 heartbeats) but no RAC1 CSI arrived. Run `ruview monitor --port <p> --baud 1500000`: "lack of csi buf" lines mean CSI report-buffer starvation in the firmware (reset the board); otherwise check csi_sequence and channel contention (ADR-323).' };
} else if (decoded === 0) {
failure = { reason: 'no_decodable_packets', remedy: 'Packets arrived but none matched a known RuView format (see unknownMagics). Check the sender and firmware version.' };
}
const result = {
ok: !failure,
...(failure || {}),
listen: `${bind}:${port}`, seconds, packets, decodedPackets: decoded, csiNodes, ...summary,
evidence: failure ? null : 'MEASURED: live UDP packets received and decoded on this host',
...extra,
};
if (spectra && !failure) result.spectrum = spectra.result();
if (collector && !failure) result.analysis = await runAnalysis(collector, args.node_id, deps.analyze);
resolve(result);
};
socket.on('error', (error) => {
if (error.code === 'EADDRINUSE') {
done = true;
clearTimeout(timer);
try { socket.close(); } catch { /* ignore */ }
resolve({ ok: false, reason: 'port_in_use', detail: `UDP ${port} is already bound (is the sensing server running?)`, remedy: 'Stop the sensing server or capture on another port the nodes target.' });
} else finish({ ok: false, reason: 'socket_error', detail: error.message });
});
socket.on('message', (msg, rinfo) => {
if (done) return;
packets += 1;
const pkt = parsePacket(msg, parseOpts);
stats.add(pkt, rinfo?.address);
if (collector || spectra) {
const t = performance.now();
if (collector) collector.add(pkt, t);
if (spectra) spectra.add(pkt, t);
}
if (packets >= maxPackets) finish({ truncated: true });
});
socket.bind(port, bind, () => { timer = setTimeout(() => finish(), seconds * 1000); });
});
}
async function runAnalysis(collector, nodeId, analyzeFn) {
const c = collector.pick(nodeId);
if (!c || c.frames.length < 2) {
return { ok: false, reason: 'no_frames', detail: nodeId === undefined ? 'no single-antenna CSI frames were captured' : `no single-antenna CSI frames from node ${nodeId}` };
}
const spanSec = (c.last - c.first) / 1000;
const sampleRateHz = spanSec > 0 ? (c.frames.length - 1) / spanSec : 0;
const input = { source: c.source, nodeId: c.nodeId, frames: c.frames.length, droppedOverCap: c.dropped, subcarriers: c.subcarriers, sampleRateHz: Number(sampleRateHz.toFixed(3)) };
if (!(sampleRateHz > 0)) return { ok: false, input, reason: 'no_frames', detail: 'frames arrived without a measurable time span' };
const config = { n_subcarriers: c.subcarriers, sample_rate_hz: input.sampleRateHz };
try {
const out = await analyzeFn(c.frames, config);
return {
input, ...out,
note: 'Signal-processing estimates from live CSI with no reference measurement; not clinical or camera-grade. The sample rate is the measured arrival rate.',
};
} catch (error) {
return { ok: false, input, reason: error?.code || 'kernel_error', detail: String(error?.message || error) };
}
}
+274
View File
@@ -0,0 +1,274 @@
// SPDX-License-Identifier: MIT
// ESPHome native API reader (ADR-373 Amendment 3). Radar kits such as the
// Seeed MR60BHA2 (XIAO ESP32-C6 + 60 GHz radar) ship ESPHome firmware that owns
// the radar UART, so no raw frames reach USB. Their values are published
// through ESPHome's native API on TCP 6053.
//
// This is a dependency-free, read-only client for the plaintext protocol:
// hello → connect → device info → list entities → subscribe states, for a
// bounded window. It never sends a command to the device. Only private,
// link-local and loopback addresses are accepted. Encrypted APIs (Noise) are
// reported honestly as unsupported.
import net from 'node:net';
import { lookup } from 'node:dns/promises';
export const ESPHOME_DEFAULT_PORT = 6053;
const HOST_RE = /^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,62})(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,62}))*\.?$/;
const MAX_BUFFER = 1024 * 1024;
// Message type ids (api.proto).
const T = Object.freeze({
HELLO_REQ: 1, HELLO_RES: 2, CONNECT_REQ: 3, CONNECT_RES: 4, DISCONNECT_REQ: 5, DISCONNECT_RES: 6,
PING_REQ: 7, PING_RES: 8, DEVICE_INFO_REQ: 9, DEVICE_INFO_RES: 10, LIST_REQ: 11,
LIST_BINARY: 12, LIST_SENSOR: 16, LIST_TEXT: 18, LIST_DONE: 19, SUBSCRIBE_STATES: 20,
STATE_BINARY: 21, STATE_SENSOR: 25, STATE_TEXT: 27,
});
// --- protobuf (proto3) primitives -------------------------------------------
export function encodeVarint(n) {
const out = [];
let v = n >>> 0;
do { let b = v & 0x7f; v >>>= 7; if (v) b |= 0x80; out.push(b); } while (v);
return Buffer.from(out);
}
export function readVarint(buf, offset) {
let result = 0;
let shift = 0;
let i = offset;
for (;;) {
if (i >= buf.length) return null;
const b = buf[i++];
result += (b & 0x7f) * 2 ** shift;
if (!(b & 0x80)) return [result, i];
shift += 7;
if (shift > 63) throw new Error('varint too long');
}
}
/** Decode a protobuf message into { field: [{wt, v}] } (wire types 0, 1, 2, 5). */
export function decodeFields(buf) {
const fields = {};
let o = 0;
while (o < buf.length) {
const tag = readVarint(buf, o);
if (!tag) throw new Error('truncated tag');
o = tag[1];
const field = Math.floor(tag[0] / 8);
const wt = tag[0] & 7;
let v;
if (wt === 0) { const r = readVarint(buf, o); if (!r) throw new Error('truncated varint'); [v, o] = r; }
else if (wt === 2) {
const r = readVarint(buf, o); if (!r) throw new Error('truncated length');
if (r[1] + r[0] > buf.length) throw new Error('truncated bytes');
v = buf.subarray(r[1], r[1] + r[0]); o = r[1] + r[0];
} else if (wt === 5) { if (o + 4 > buf.length) throw new Error('truncated fixed32'); v = buf.subarray(o, o + 4); o += 4; }
else if (wt === 1) { if (o + 8 > buf.length) throw new Error('truncated fixed64'); v = buf.subarray(o, o + 8); o += 8; }
else throw new Error(`unsupported wire type ${wt}`);
(fields[field] ??= []).push({ wt, v });
}
return fields;
}
const fStr = (f, n) => (f[n]?.[0]?.wt === 2 ? f[n][0].v.toString('utf8') : '');
const fKey = (f, n) => (f[n]?.[0]?.wt === 5 ? f[n][0].v.readUInt32LE(0) : null);
const fBool = (f, n) => (f[n]?.[0]?.wt === 0 ? f[n][0].v !== 0 : false);
const fFloat = (f, n) => (f[n]?.[0]?.wt === 5 ? f[n][0].v.readFloatLE(0) : 0); // proto3: absent float is 0
const fUint = (f, n) => (f[n]?.[0]?.wt === 0 ? f[n][0].v : 0);
const pbString = (field, s) => { const b = Buffer.from(s, 'utf8'); return Buffer.concat([encodeVarint((field << 3) | 2), encodeVarint(b.length), b]); };
const pbUint = (field, n) => Buffer.concat([encodeVarint(field << 3), encodeVarint(n)]);
/** Plaintext frame: 0x00, varint(length), varint(type), payload. */
export function encodeFrame(type, payload = Buffer.alloc(0)) {
return Buffer.concat([Buffer.from([0]), encodeVarint(payload.length), encodeVarint(type), payload]);
}
/** Split complete frames off a buffer. Returns { frames, rest } or throws on an encrypted stream. */
export function splitFrames(buf) {
const frames = [];
let o = 0;
while (o < buf.length) {
if (buf[o] === 0x01) { const e = new Error('encrypted'); e.reason = 'esphome_encrypted'; throw e; }
if (buf[o] !== 0x00) { const e = new Error(`bad frame indicator 0x${buf[o].toString(16)}`); e.reason = 'esphome_protocol_error'; throw e; }
const len = readVarint(buf, o + 1);
if (!len) break;
const type = readVarint(buf, len[1]);
if (!type) break;
if (type[1] + len[0] > buf.length) break;
frames.push({ type: type[0], payload: buf.subarray(type[1], type[1] + len[0]) });
o = type[1] + len[0];
}
return { frames, rest: buf.subarray(o) };
}
// --- address policy ------------------------------------------------------------
export function isPrivateAddress(ip) {
if (net.isIPv4(ip)) {
const [a, b] = ip.split('.').map(Number);
return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127);
}
if (net.isIPv6(ip)) {
const v = ip.toLowerCase();
if (v.startsWith('::ffff:')) return isPrivateAddress(v.slice(7));
return v === '::1' || v.startsWith('fc') || v.startsWith('fd') || v.startsWith('fe8') || v.startsWith('fe9') || v.startsWith('fea') || v.startsWith('feb');
}
return false;
}
async function resolveHost(host, deps) {
if (typeof host !== 'string' || !(net.isIP(host) || HOST_RE.test(host))) {
return { ok: false, reason: 'invalid_host', detail: 'host must be an IP address or a hostname' };
}
let address = host;
if (!net.isIP(host)) {
try { address = (await (deps.lookup || lookup)(host)).address; } catch (error) {
return { ok: false, reason: 'host_unresolved', detail: `${host}: ${error.code || error.message}`, remedy: 'Use the device IP (the router DHCP table, or `arp -a`), or its mDNS name <name>.local.' };
}
}
if (!isPrivateAddress(address)) {
return { ok: false, reason: 'host_not_private', detail: `${host} resolves to ${address}`, remedy: 'ESPHome reads are limited to private, link-local, CGNAT/Tailscale and loopback addresses.' };
}
return { ok: true, address };
}
// --- radar roles -----------------------------------------------------------------
const ROLES = [
['heartBpm', (e) => /heart/i.test(e.name + e.objectId)],
['breathingBpm', (e) => /respir|breath/i.test(e.name + e.objectId)],
['distanceCm', (e) => /distance/i.test(e.name + e.objectId)],
['targets', (e) => /target/i.test(e.name + e.objectId) && e.kind === 'sensor'],
['present', (e) => e.kind === 'binary' && /person|presence|occup|motion|human/i.test(e.name + e.objectId)],
];
const roleOf = (e) => (ROLES.find(([, test]) => test(e)) || [null])[0];
const mean = (xs) => (xs.length ? Number((xs.reduce((a, b) => a + b, 0) / xs.length).toFixed(2)) : null);
/** Summarize a session into the mmWave result shape (shared with the serial path). */
export function summarizeEsphome(session, seconds) {
const entities = [...session.entities.values()];
const byRole = {};
for (const e of entities) { const r = roleOf(e); if (r && !byRole[r]) byRole[r] = e; }
const values = (e) => (e ? e.samples.filter((s) => !s.missing).map((s) => s.value) : []);
const presence = values(byRole.present);
const toCm = (e) => (e && /^m$/i.test(e.unit || '') ? values(e).map((v) => v * 100) : values(e));
return {
source: 'esphome',
device: session.device,
seconds,
stateUpdates: session.updates,
updateRateHz: Number((session.updates / seconds).toFixed(2)),
presentFraction: presence.length ? Number((presence.filter(Boolean).length / presence.length).toFixed(3)) : null,
presentNow: byRole.present && byRole.present.last && !byRole.present.last.missing ? Boolean(byRole.present.last.value) : null,
heartBpmMean: mean(values(byRole.heartBpm).filter((v) => v > 0 && v <= 250)),
breathingBpmMean: mean(values(byRole.breathingBpm).filter((v) => v > 0 && v <= 60)),
distanceCmMean: mean(toCm(byRole.distanceCm).filter((v) => v > 0)),
targetsMax: values(byRole.targets).length ? Math.max(...values(byRole.targets)) : null,
entities: entities.map((e) => ({
kind: e.kind, name: e.name, unit: e.unit || undefined, role: roleOf(e) || undefined,
updates: e.samples.length, last: e.last ? (e.last.missing ? null : e.last.value) : null,
})),
};
}
/**
* Read an ESPHome device. args: { host, api_port=6053, seconds=10 }.
* deps.connect(port, host) → net.Socket and deps.lookup are injectable for tests.
*/
export async function readEsphome(args = {}, deps = {}) {
const seconds = Math.min(Math.max(args.seconds ?? 10, 1), 120);
const port = args.api_port ?? ESPHOME_DEFAULT_PORT;
if (!Number.isInteger(port) || port < 1 || port > 65535) return { ok: false, reason: 'invalid_port', detail: 'api_port must be 1..65535' };
const target = await resolveHost(args.host, deps);
if (!target.ok) return target;
const session = { device: {}, entities: new Map(), updates: 0, hello: false };
return new Promise((resolve) => {
const socket = (deps.connect || ((p, h) => net.connect(p, h)))(port, target.address);
let buf = Buffer.alloc(0);
let done = false;
const finish = (out) => {
if (done) return;
done = true;
clearTimeout(timer);
clearTimeout(connectTimer);
try { socket.write(encodeFrame(T.DISCONNECT_REQ)); } catch { /* closing anyway */ }
socket.destroy();
resolve(out);
};
const fail = (reason, detail, remedy) => finish({ ok: false, reason, detail, ...(remedy ? { remedy } : {}), host: args.host, port });
const connectTimer = setTimeout(() => fail('esphome_unreachable', `no TCP connection to ${target.address}:${port} within 5 s`, 'Check that the device is on the network and that `api:` is enabled in its ESPHome config.'), 5000);
let timer = null;
socket.on('connect', () => {
clearTimeout(connectTimer);
socket.write(encodeFrame(T.HELLO_REQ, Buffer.concat([pbString(1, 'ruview'), pbUint(2, 1), pbUint(3, 10)])));
socket.write(encodeFrame(T.CONNECT_REQ));
socket.write(encodeFrame(T.DEVICE_INFO_REQ));
socket.write(encodeFrame(T.LIST_REQ));
timer = setTimeout(() => {
if (!session.hello) return fail('esphome_protocol_error', 'no HelloResponse from the device');
const summary = summarizeEsphome(session, seconds);
finish({
ok: true, host: args.host, port, ...summary,
evidence: 'MEASURED: device-reported radar values read on this host (not an accuracy claim)',
note: 'Values are computed by the device firmware and are not validated against a reference.',
});
}, seconds * 1000);
});
socket.on('error', (error) => fail('esphome_unreachable', `${target.address}:${port}: ${error.code || error.message}`, 'Check that the device is on the network and that `api:` is enabled in its ESPHome config.'));
socket.on('close', () => { if (!done && session.hello) fail('esphome_closed', 'the device closed the API connection early'); else if (!done) fail('esphome_unreachable', 'connection closed before the API handshake'); });
socket.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk]);
if (buf.length > MAX_BUFFER) return fail('esphome_protocol_error', 'response exceeded 1 MiB');
let frames;
try { ({ frames, rest: buf } = splitFrames(buf)); } catch (error) {
if (error.reason === 'esphome_encrypted') return fail('esphome_encrypted', 'the device requires the encrypted (Noise) API', 'API encryption is not supported yet: read the value over USB logs, or use a build without `api: encryption:` on a trusted network.');
return fail(error.reason || 'esphome_protocol_error', error.message);
}
for (const { type, payload } of frames) {
let f;
try { f = decodeFields(payload); } catch (error) { return fail('esphome_protocol_error', error.message); }
switch (type) {
case T.HELLO_RES:
session.hello = true;
session.device.apiVersion = `${fUint(f, 1)}.${fUint(f, 2)}`;
session.device.serverInfo = fStr(f, 3);
session.device.name = fStr(f, 4) || undefined;
break;
case T.CONNECT_RES:
if (fBool(f, 1)) return fail('esphome_password_required', 'the device API requires a password', 'Password-protected ESPHome APIs are not supported; remove `api: password:` or use the USB log.');
break;
case T.DEVICE_INFO_RES:
Object.assign(session.device, {
name: fStr(f, 2) || session.device.name, esphomeVersion: fStr(f, 4) || undefined, compiled: fStr(f, 5) || undefined,
model: fStr(f, 6) || undefined, project: fStr(f, 8) || undefined, projectVersion: fStr(f, 9) || undefined,
});
break;
case T.PING_REQ:
socket.write(encodeFrame(T.PING_RES));
break;
case T.LIST_BINARY: case T.LIST_SENSOR: case T.LIST_TEXT: {
const key = fKey(f, 2);
if (key !== null) session.entities.set(key, { kind: type === T.LIST_BINARY ? 'binary' : type === T.LIST_SENSOR ? 'sensor' : 'text', objectId: fStr(f, 1), name: fStr(f, 3), unit: type === T.LIST_SENSOR ? fStr(f, 6) : '', samples: [], last: null });
break;
}
case T.LIST_DONE:
socket.write(encodeFrame(T.SUBSCRIBE_STATES));
break;
case T.STATE_BINARY: case T.STATE_SENSOR: case T.STATE_TEXT: {
const e = session.entities.get(fKey(f, 1));
if (!e) break;
const value = type === T.STATE_SENSOR ? fFloat(f, 2) : type === T.STATE_BINARY ? fBool(f, 2) : fStr(f, 2);
const sample = { value, missing: fBool(f, 3) };
session.updates += 1;
if (e.samples.length < 10_000) e.samples.push(sample);
e.last = sample;
break;
}
default: break; // lights, switches, etc. are ignored (read-only client)
}
}
});
});
}
+167
View File
@@ -0,0 +1,167 @@
// SPDX-License-Identifier: MIT
// LiDAR access (ADR-373):
// * rplidar — Slamtec RPLIDAR A1/A2/A3/C1/S-series legacy SCAN protocol over
// USB serial. Starting a scan spins the motor/laser; stopping
// is sent on exit. No persistent configuration is written.
// * iphone — the RuView iPhone LiDAR relay (integrations/iphone-lidar),
// read as a WebSocket client. Only depth statistics are
// returned; raw depth never leaves this process.
import { readSerial } from './serial-pump.js';
const SCAN = 'a520';
const STOP = 'a525';
const DESCRIPTOR = [0xa5, 0x5a, 0x05, 0x00, 0x00, 0x40, 0x81];
/** Parse an RPLIDAR SCAN byte stream into measurement nodes. */
export function parseRplidar(bytes) {
let i = 0;
// Skip to the scan response descriptor if present; otherwise assume raw nodes.
for (let j = 0; j + DESCRIPTOR.length <= bytes.length; j++) {
if (DESCRIPTOR.every((v, k) => bytes[j + k] === v)) { i = j + DESCRIPTOR.length; break; }
}
const points = [];
let invalid = 0;
let revolutions = 0;
while (i + 5 <= bytes.length) {
const b0 = bytes[i];
const b1 = bytes[i + 1];
const start = b0 & 1;
const inv = (b0 >> 1) & 1;
if (start === inv || (b1 & 1) !== 1) { invalid += 1; i += 1; continue; } // resync
const angle = ((b1 >> 1) | (bytes[i + 2] << 7)) / 64;
const distanceMm = (bytes[i + 3] | (bytes[i + 4] << 8)) / 4;
if (start) revolutions += 1;
points.push({ angle, distanceMm, quality: b0 >> 2, start: Boolean(start) });
i += 5;
}
return { points, invalid, revolutions };
}
const median = (xs) => {
if (!xs.length) return null;
const s = [...xs].sort((a, b) => a - b);
return s[Math.floor(s.length / 2)];
};
export function summarizeScan({ points, invalid, revolutions }, seconds) {
const valid = points.filter((p) => p.distanceMm > 0);
const d = valid.map((p) => p.distanceMm);
const sectors = new Set(valid.map((p) => Math.floor(p.angle / 10) % 36));
return {
points: points.length, validPoints: valid.length, resyncBytes: invalid, revolutions,
scanRateHz: Number((revolutions / seconds).toFixed(2)),
rangeMm: d.length ? { min: Math.min(...d), median: median(d), max: Math.max(...d) } : null,
angularCoverage: Number((sectors.size / 36).toFixed(2)),
};
}
export async function readRplidar(args, deps) {
const seconds = args.seconds ?? 5;
let cap;
try {
cap = await readSerial({ port: args.port, baud: args.baud ?? 115200, seconds, startHex: SCAN, stopHex: STOP, dtr: 'low', maxBytes: 4 * 1024 * 1024 }, deps);
} catch (error) {
return { ok: false, reason: error.reason || 'invalid_arguments', detail: error.message };
}
if (!cap.ok) return { ok: false, reason: cap.reason, detail: cap.detail, remedy: cap.remedy };
const summary = summarizeScan(parseRplidar(cap.bytes), cap.seconds);
if (!summary.validPoints) {
return { ok: false, reason: 'no_scan_points', bytes: cap.bytes.length, ...summary, remedy: 'A2/A3/S-series need 115200/256000/1000000 baud per model; check the USB adapter powers the motor (A1 motor follows DTR).' };
}
return { ok: true, source: 'rplidar', bytes: cap.bytes.length, ...summary, ...(cap.warnings ? { warnings: cap.warnings } : {}), evidence: 'MEASURED: live scan read on this host' };
}
/** Validate and reduce one ruview.lidar.depth.v1 packet to statistics. */
export function depthStats(packet) {
if (!packet || packet.type !== 'ruview.lidar.depth.v1') throw new Error('unsupported packet type');
const depth = packet.depth || {};
if (depth.encoding !== 'u16le-mm+u8-confidence') throw new Error('unsupported depth encoding');
const { width, height } = depth;
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || width * height > 1_000_000) throw new Error('invalid depth dimensions');
const mm = Buffer.from(String(depth.millimetersBase64 || ''), 'base64');
const conf = Buffer.from(String(depth.confidenceBase64 || ''), 'base64');
if (mm.length !== width * height * 2 || conf.length !== width * height) throw new Error('depth payload length mismatch');
const values = [];
let confident = 0;
for (let i = 0; i < width * height; i++) {
const v = mm.readUInt16LE(i * 2);
if (v > 0 && conf[i] >= 1) { values.push(v); confident += 1; }
}
return { width, height, confidentFraction: Number((confident / (width * height)).toFixed(3)), medianDepthM: values.length ? median(values) / 1000 : null };
}
/**
* Connect to the iPhone LiDAR relay as a WebSocket client.
* args: { url: ws(s)://host:port/ws/lidar, seconds, max_frames }. Returns early
* once max_frames arrive or the connection is refused. The access token comes
* only from RUVIEW_LIDAR_TOKEN (never from tool arguments or logs).
*/
export function readIphoneLidar(args, deps = {}) {
const seconds = Math.min(Math.max(args.seconds ?? 5, 1), 60);
const maxFrames = Math.min(Math.max(args.max_frames ?? 10_000, 1), 10_000);
let url;
try {
url = new URL(args.url);
if (!['ws:', 'wss:'].includes(url.protocol)) throw new Error('url must be ws:// or wss://');
if (url.searchParams.has('token')) throw new Error('put the relay token in RUVIEW_LIDAR_TOKEN, not in the URL');
} catch (error) {
return Promise.resolve({ ok: false, reason: 'invalid_url', detail: error.message });
}
const token = (deps.env || process.env).RUVIEW_LIDAR_TOKEN;
if (token) url.searchParams.set('token', token);
const WS = deps.WebSocket || globalThis.WebSocket;
if (typeof WS !== 'function') return Promise.resolve({ ok: false, reason: 'websocket_unavailable', remedy: 'Use Node 22+ (built-in WebSocket client).' });
const shown = `${url.protocol}//${url.host}${url.pathname}`;
return new Promise((resolve) => {
const frames = [];
let rejected = 0;
let lastError = null;
let ws;
let done = false;
let opened = false;
const finish = (extra = {}) => {
if (done) return;
done = true;
clearTimeout(timer);
try { ws?.close(); } catch { /* ignore */ }
const n = frames.length;
resolve({
ok: n > 0 && !extra.reason,
source: 'iphone', relay: shown, seconds, frames: n, rejectedFrames: rejected,
fps: Number((n / Math.max((Date.now() - started) / 1000, 0.001)).toFixed(2)),
...(n ? { last: frames.at(-1), confidentFractionMean: Number((frames.reduce((a, f) => a + f.confidentFraction, 0) / n).toFixed(3)) } : {}),
...(n === 0 && !extra.reason ? (opened
? { reason: 'no_frames', detail: lastError, remedy: 'Connected, but the iPhone is not streaming: start capture in the RuView LiDAR app pointed at this relay.' }
: { reason: 'connect_failed', detail: lastError, remedy: 'Check the relay is running at this URL and RUVIEW_LIDAR_TOKEN matches the token it printed.' }) : {}),
evidence: n ? 'MEASURED: live depth frames received on this host' : null,
...extra,
});
};
const started = Date.now();
const timer = setTimeout(() => finish(), seconds * 1000);
try {
ws = new WS(url.href);
} catch (error) {
finish({ ok: false, reason: 'connect_failed', detail: error.message });
return;
}
ws.onopen = () => { opened = true; };
ws.onmessage = (event) => {
try {
const text = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8');
if (text.length > 4_000_000) throw new Error('frame too large');
frames.push(depthStats(JSON.parse(text)));
if (frames.length >= maxFrames) finish();
} catch (error) { rejected += 1; lastError = error.message; }
};
ws.onerror = () => {
lastError = 'websocket error';
if (!opened) finish(); // refused before opening: nothing will arrive
};
ws.onclose = (event) => {
if (!opened) finish();
else if (!frames.length && event?.code && event.code !== 1000) finish({ ok: false, reason: 'relay_closed', detail: `close code ${event.code}${event.code === 1008 ? ' (token rejected?)' : ''}` });
};
});
}
+193
View File
@@ -0,0 +1,193 @@
// SPDX-License-Identifier: MIT
// mmWave radar access (ADR-373): Seeed MR60BHA2 (60 GHz, 115200 baud) and
// HLK-LD2410 (24 GHz, 256000 baud) over a USB-UART adapter. The parsers are
// byte-for-byte ports of firmware/esp32-csi-node/main/mmwave_sensor.c so the
// host and the node agree on what a valid frame is.
import { readSerial } from './serial-pump.js';
export const MMWAVE_MODELS = Object.freeze({
mr60bha2: Object.freeze({ baud: 115200, band: '60 GHz', vendor: 'Seeed' }),
ld2410: Object.freeze({ baud: 256000, band: '24 GHz', vendor: 'HLK' }),
});
const MR60 = Object.freeze({ SOF: 0x01, MAX_PAYLOAD: 30, BREATHING: 0x0a14, HEART: 0x0a15, DISTANCE: 0x0a16, PRESENCE: 0x0f09, PHASE: 0x0a13, POINTCLOUD: 0x0a04 });
/** ~XOR checksum used by the Seeed protocol. */
export function mr60Checksum(bytes) {
let x = 0;
for (const b of bytes) x ^= b;
return (~x) & 0xff;
}
/** Streaming MR60BHA2 parser. feed() returns decoded frames. */
export class Mr60Parser {
constructor() { this.reset(); this.errors = 0; }
reset() { this.state = 'sof'; this.header = []; this.data = []; }
feed(bytes) {
const frames = [];
for (const b of bytes) {
if (this.state === 'sof') {
if (b === MR60.SOF) { this.header = [b]; this.state = 'header'; }
} else if (this.state === 'header') {
this.header.push(b);
if (this.header.length === 8) {
if (mr60Checksum(this.header.slice(0, 7)) !== this.header[7]) { this.errors += 1; this.reset(); continue; }
this.len = (this.header[3] << 8) | this.header[4];
this.type = (this.header[5] << 8) | this.header[6];
this.data = [];
if (this.len > MR60.MAX_PAYLOAD) { this.errors += 1; this.reset(); } else this.state = this.len === 0 ? 'cksum' : 'data';
}
} else if (this.state === 'data') {
this.data.push(b);
if (this.data.length >= this.len) this.state = 'cksum';
} else {
if (this.len === 0 || mr60Checksum(this.data) === b) frames.push(decodeMr60(this.type, Buffer.from(this.data)));
else this.errors += 1;
this.reset();
}
}
return frames;
}
}
function decodeMr60(type, d) {
switch (type) {
case MR60.BREATHING: {
const v = d.length >= 4 ? d.readFloatLE(0) : NaN;
return { type: 'breathing', breathingBpm: v >= 0 && v <= 60 ? v : null };
}
case MR60.HEART: {
const v = d.length >= 4 ? d.readFloatLE(0) : NaN;
return { type: 'heart', heartBpm: v >= 0 && v <= 250 ? v : null };
}
case MR60.DISTANCE:
return d.length >= 8 && d.readUInt32LE(0) !== 0 ? { type: 'distance', distanceCm: d.readFloatLE(4) } : { type: 'distance', distanceCm: null };
case MR60.PRESENCE:
return { type: 'presence', present: d.length >= 1 ? d[0] !== 0 : null };
case MR60.PHASE: return { type: 'phase', bytes: d.length };
case MR60.POINTCLOUD: return { type: 'pointcloud', bytes: d.length };
default: return { type: `0x${type.toString(16).padStart(4, '0')}`, bytes: d.length };
}
}
const LD_HEAD = [0xf4, 0xf3, 0xf2, 0xf1];
const LD_TAIL = [0xf8, 0xf7, 0xf6, 0xf5];
/** Streaming HLK-LD2410 report parser. */
export class Ld2410Parser {
constructor() { this.state = 0; this.errors = 0; this.data = []; }
feed(bytes) {
const frames = [];
for (const b of bytes) {
if (this.state < 4) {
this.state = b === LD_HEAD[this.state] ? this.state + 1 : (b === LD_HEAD[0] ? 1 : 0);
} else if (this.state === 4) { this.len = b; this.state = 5; }
else if (this.state === 5) {
this.len |= b << 8;
this.data = [];
this.state = this.len === 0 || this.len > 256 ? 0 : 6;
} else if (this.state === 6) {
this.data.push(b);
if (this.data.length >= this.len) this.state = 7;
} else {
const i = this.state - 7;
if (b !== LD_TAIL[i]) { this.errors += 1; this.state = 0; continue; }
if (i === 3) { const f = decodeLd2410(this.data); if (f) frames.push(f); this.state = 0; } else this.state += 1;
}
}
return frames;
}
}
function decodeLd2410(d) {
if (d.length < 12 || d[1] !== 0xaa) return null;
const target = d[2];
const moving = d[3] | (d[4] << 8);
const stat = d[6] | (d[7] << 8);
return {
type: 'target',
mode: d[0] === 0x01 ? 'engineering' : 'basic',
targetState: ['none', 'moving', 'static', 'moving+static'][target] ?? `0x${target.toString(16)}`,
present: target !== 0,
movingCm: moving, movingEnergy: d[5], staticCm: stat, staticEnergy: d[8], detectCm: d[9] | (d[10] << 8),
distanceCm: target === 1 || target === 3 ? moving : target === 2 ? stat : 0,
};
}
const mean = (xs) => (xs.length ? Number((xs.reduce((a, b) => a + b, 0) / xs.length).toFixed(2)) : null);
/** Summarize decoded frames from either model. */
export function summarizeMmwave(model, frames, errors, bytes, seconds) {
const pick = (key) => frames.map((f) => f[key]).filter((v) => typeof v === 'number' && Number.isFinite(v));
const presence = frames.filter((f) => typeof f.present === 'boolean');
const types = {};
for (const f of frames) types[f.type] = (types[f.type] || 0) + 1;
return {
model, band: MMWAVE_MODELS[model].band, bytes, frames: frames.length, checksumErrors: errors,
frameRateHz: Number((frames.length / seconds).toFixed(2)), frameTypes: types,
presentFraction: presence.length ? Number((presence.filter((f) => f.present).length / presence.length).toFixed(3)) : null,
breathingBpmMean: mean(pick('breathingBpm')),
heartBpmMean: mean(pick('heartBpm')),
distanceCmMean: mean(pick('distanceCm')),
last: frames.slice(-5),
};
}
export function parseMmwave(model, bytes) {
const parser = model === 'ld2410' ? new Ld2410Parser() : new Mr60Parser();
const frames = parser.feed(bytes);
return { frames, errors: parser.errors };
}
/**
* Read an mmWave radar. args: { port, model: auto|mr60bha2|ld2410, seconds }.
* Auto-detect probes each model's baud for up to 2 s; the probe's frames are
* kept and only the remainder of the window is read afterwards, so a request
* for N seconds costs about N seconds rather than probe + N.
*/
export async function readMmwave(args, deps) {
const seconds = args.seconds ?? 10;
const auto = !args.model || args.model === 'auto';
const models = auto ? ['mr60bha2', 'ld2410'] : [args.model];
const probeSeconds = auto ? Math.min(seconds, 2) : seconds;
const attempts = [];
const capture = async (model, secs) => {
try {
return await readSerial({ port: args.port, baud: MMWAVE_MODELS[model].baud, seconds: secs }, deps);
} catch (error) {
return { ok: false, reason: error.reason || 'invalid_arguments', detail: error.message };
}
};
for (const model of models) {
const cap = await capture(model, probeSeconds);
if (!cap.ok) return { ok: false, reason: cap.reason, detail: cap.detail, remedy: cap.remedy };
let { frames, errors } = parseMmwave(model, cap.bytes);
attempts.push({ model, bytes: cap.bytes.length, frames: frames.length, errors });
if (frames.length < 2) continue;
let bytes = cap.bytes.length;
let elapsed = cap.seconds;
const remaining = seconds - probeSeconds;
if (auto && remaining >= 0.5) {
const more = await capture(model, remaining);
if (more.ok) {
// A fresh parser: the probe may have ended mid-frame.
const extra = parseMmwave(model, more.bytes);
frames = frames.concat(extra.frames);
errors += extra.errors;
bytes += more.bytes.length;
elapsed += more.seconds;
}
}
return {
ok: true,
...summarizeMmwave(model, frames, errors, bytes, elapsed),
...(auto ? { detected: model } : {}),
evidence: 'MEASURED: device-reported radar values read on this host (not an accuracy claim)',
};
}
return {
ok: false, reason: 'no_valid_frames', attempts,
remedy: 'Check TX/RX wiring (crossed), 5 V power, and model/baud (MR60BHA2 115200, LD2410 256000). A C6+MR60 board streams through ESPHome/firmware instead — use its serial log or the node UDP stream.',
};
}
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MIT
// Device discovery (ADR-373): enumerate serial ports and classify them by USB
// VID:PID into likely RuView roles. Classification is a hint, not identity —
// many boards share generic USB-UART bridges, so each candidate lists the
// read command that confirms it. `realtek` marks RTL8721Dx (Ameba) CSI boards
// (ADR-323), which stream RAC1 over UDP and log at 1500000 baud.
import { listSerialPorts } from '../firmware.js';
/** Known USB bridges/native USB in the RuView hardware set. */
export const USB_IDS = Object.freeze({
'303A:1001': { chip: 'Espressif native USB (ESP32-S3/C3/C6 USB-Serial/JTAG)', roles: ['esp32'] },
'303A:4001': { chip: 'Espressif native USB (ESP32-S2/S3 CDC)', roles: ['esp32'] },
'10C4:EA60': { chip: 'Silicon Labs CP210x', roles: ['esp32', 'rplidar', 'mmwave'] },
'1A86:7523': { chip: 'WCH CH340', roles: ['esp32', 'mmwave'] },
'1A86:55D4': { chip: 'WCH CH9102', roles: ['esp32', 'mmwave'] },
'1A86:55D3': { chip: 'WCH CH343', roles: ['esp32', 'mmwave'] },
'0403:6001': { chip: 'FTDI FT232R', roles: ['mmwave', 'rplidar', 'esp32'] },
'0403:6015': { chip: 'FTDI FT231X', roles: ['mmwave', 'esp32'] },
'067B:23A3': { chip: 'Prolific PL2303GC (Realtek Ameba RTL8721Dx boards)', roles: ['realtek'] },
'067B:2303': { chip: 'Prolific PL2303', roles: ['realtek', 'esp32'] },
'2341:0043': { chip: 'Arduino Uno', roles: [] },
});
const NEXT = Object.freeze({
esp32: (p) => `ruview doctor --port ${p} --probe | ruview monitor --port ${p}`,
mmwave: (p) => `ruview mmwave --port ${p} --model auto`,
rplidar: (p) => `ruview lidar --source rplidar --port ${p}`,
realtek: (p) => `ruview monitor --port ${p} --baud 1500000 | ruview esp32 --seconds 10 (RAC1 over UDP)`,
});
export function classifyPort(port) {
const key = port.vid && port.pid ? `${port.vid}:${port.pid}` : null;
const known = key ? USB_IDS[key] : null;
const text = `${port.description || ''} ${port.hwid || ''}`;
let roles = known ? [...known.roles] : [];
if (/slamtec|rplidar/i.test(text)) roles = ['rplidar'];
else if (/espressif|esp32|usb jtag/i.test(text) && !roles.includes('esp32')) roles.unshift('esp32');
const builtin = /ttyS\d+|Bluetooth|debug-console|wlan-debug/i.test(port.port + text);
return {
...port,
usb: key,
bridge: known?.chip || null,
likelyRoles: builtin ? [] : roles,
builtin,
confirmWith: builtin ? [] : roles.map((r) => NEXT[r](port.port)),
};
}
export async function scanDevices(deps) {
const listed = await listSerialPorts(deps);
if (!listed.ok) return { ok: false, reason: listed.reason, remedy: listed.reason === 'pyserial_missing' ? 'pip install pyserial' : listed.remedy, devices: [] };
const devices = listed.ports.map(classifyPort);
const candidates = devices.filter((d) => d.likelyRoles.length);
return {
ok: true,
devices,
candidates: candidates.length,
network: {
esp32Udp: 'ESP32 nodes stream UDP to their provisioned target; run `ruview esp32 --seconds 10` on that host (default UDP 5005).',
iphoneLidar: 'Start integrations/iphone-lidar/web relay, export RUVIEW_LIDAR_TOKEN, then `ruview lidar --source iphone --url ws://HOST:8787/ws/lidar`.',
},
note: candidates.length ? 'Roles are VID:PID hints; confirm with the listed read command.' : 'No USB serial devices matched known bridges. Check the cable (data-capable) and drivers (CP210x/CH34x/PL2303).',
};
}
+76
View File
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: MIT
// Bounded raw serial capture through pyserial (ADR-373). Node has no built-in
// serial API and the package stays dependency-free, so a fixed Python script
// streams lowercase-hex chunks; all parameters are argv, never spliced into
// source. Hex (not base64) because process output passes through secret
// redaction, whose patterns cannot match the [0-9a-f] alphabet.
import { validatePort } from '../firmware.js';
export const PUMP_SCRIPT = [
'import sys,time,binascii',
'try:',
' import serial',
'except Exception:',
" print('NO_PYSERIAL'); sys.exit(3)",
'port,baud,dur,maxb,start,stop,dtr=sys.argv[1],int(sys.argv[2]),float(sys.argv[3]),int(sys.argv[4]),sys.argv[5],sys.argv[6],sys.argv[7]',
'try:',
' ser=serial.Serial(port,baud,timeout=0.2)',
'except Exception as e:',
" print('OPEN_ERROR '+str(e)[:200]); sys.exit(4)",
'try:',
" if dtr=='low': ser.dtr=False",
" if dtr=='high': ser.dtr=True",
'except Exception:',
" print('DTR_UNSUPPORTED')",
'if start: ser.write(binascii.unhexlify(start)); ser.flush()',
'n=0; end=time.time()+dur',
'while time.time()<end and n<maxb:',
' d=ser.read(4096)',
' if d:',
' d=d[:maxb-n]; n+=len(d)',
" print('HEX '+binascii.hexlify(d).decode()); sys.stdout.flush()",
'if stop: ser.write(binascii.unhexlify(stop)); ser.flush()',
'ser.close()',
"print('END '+str(n))",
].join('\n');
const HEX_RE = /^(?:[0-9a-fA-F]{2}){0,64}$/;
export const SERIAL_BAUDS = Object.freeze([9600, 19200, 38400, 57600, 115200, 230400, 256000, 460800, 921600]);
export const MAX_CAPTURE_BYTES = 8 * 1024 * 1024;
/**
* Capture raw bytes from a serial port.
* opts: { port, baud, seconds, maxBytes, startHex, stopHex, dtr: 'keep'|'low'|'high' }
* Returns { ok, bytes: Buffer, reason?, detail? }.
*/
export async function readSerial(opts, deps) {
validatePort(opts.port);
if (!SERIAL_BAUDS.includes(opts.baud)) throw Object.assign(new Error(`baud must be one of ${SERIAL_BAUDS.join(', ')}`), { reason: 'invalid_baud' });
const seconds = Math.min(Math.max(Number(opts.seconds) || 5, 0.5), 120);
const maxBytes = Math.min(Math.max(Number(opts.maxBytes) || 1_048_576, 1), MAX_CAPTURE_BYTES);
const startHex = opts.startHex || '';
const stopHex = opts.stopHex || '';
if (!HEX_RE.test(startHex) || !HEX_RE.test(stopHex)) throw Object.assign(new Error('command bytes must be short hex strings'), { reason: 'invalid_command' });
const dtr = opts.dtr || 'keep';
if (!['keep', 'low', 'high'].includes(dtr)) throw Object.assign(new Error('dtr must be keep, low, or high'), { reason: 'invalid_dtr' });
const python = deps.python();
if (!python) return { ok: false, reason: 'python_missing', remedy: 'Install Python 3 and `pip install pyserial`.', bytes: Buffer.alloc(0) };
const r = await deps.exec(python, ['-c', PUMP_SCRIPT, opts.port, String(opts.baud), String(seconds), String(maxBytes), startHex, stopHex, dtr], {
timeoutMs: (seconds + 15) * 1000,
maxOutputBytes: maxBytes * 2 + Math.ceil(maxBytes / 2048) * 8 + 65_536,
fullOutput: true,
});
const out = r.stdout;
if (out.includes('NO_PYSERIAL')) return { ok: false, reason: 'pyserial_missing', remedy: 'pip install pyserial', bytes: Buffer.alloc(0) };
const openError = /OPEN_ERROR (.*)/.exec(out);
if (openError) {
return { ok: false, reason: 'port_open_failed', detail: openError[1], remedy: 'Close other serial monitors (ESP-IDF monitor, Arduino, the sensing server) and check permissions (Linux: dialout group).', bytes: Buffer.alloc(0) };
}
const chunks = [];
for (const line of out.split(/\r?\n/)) if (/^HEX [0-9a-f]*$/.test(line)) chunks.push(Buffer.from(line.slice(4), 'hex'));
const bytes = Buffer.concat(chunks);
if (!r.ok && !bytes.length) return { ok: false, reason: 'capture_failed', detail: (r.stderr || r.error || '').slice(-400), bytes };
return { ok: true, bytes, seconds, ...(out.includes('DTR_UNSUPPORTED') ? { warnings: ['port does not support DTR control; an RPLIDAR A1 motor may not spin'] } : {}) };
}
+234
View File
@@ -0,0 +1,234 @@
// SPDX-License-Identifier: MIT
// Structured debugging doctor (ADR-372).
//
// Every check returns {id, group, status: pass|warn|fail|skip, detail, remedy}.
// `fail` means a requested capability cannot work; optional tooling that is
// absent is `warn`. The doctor is read-only: it never flashes, writes, or
// opens a serial port unless `probe_port` is explicitly requested (esptool
// chip_id resets the board but writes nothing).
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { claimCheck } from './guardrails.js';
import { loadBrain } from './brain.js';
import { FIRMWARE_VARIANTS, listSerialPorts, parseChip, resolveBundle, validatePort } from './firmware.js';
import { classifyPort } from './devices/registry.js';
import { loadHosts } from './remote.js';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
export const DOCTOR_GROUPS = Object.freeze(['runtime', 'harness', 'hosts', 'repo', 'rust', 'python', 'firmware', 'serial', 'devices', 'remote', 'sensing', 'kernel']);
const sha = (value) => createHash('sha256').update(value).digest('hex');
/** Verify the packaged provenance manifest (same rules as scripts/verify-manifest.mjs). */
export function verifyPackageManifest(root = ROOT) {
const path = join(root, '.harness', 'manifest.json');
if (!existsSync(path)) return { ok: false, findings: ['manifest.json:missing'] };
const raw = readFileSync(path);
const manifest = JSON.parse(raw);
const findings = [];
for (const [name, expected] of Object.entries(manifest.files || {})) {
const target = join(root, name);
if (!existsSync(target)) findings.push(`${name}:missing`);
else if (sha(readFileSync(target, 'utf8').replace(/\r\n/g, '\n')) !== expected) findings.push(`${name}:hash-mismatch`);
}
const outer = readFileSync(join(root, '.harness', 'manifest.sha256'), 'utf8').trim().split(/\s+/)[0];
if (sha(raw) !== outer) findings.push('manifest.sha256:mismatch');
return { ok: findings.length === 0, files: Object.keys(manifest.files || {}).length, findings };
}
const firstLine = (text) => String(text || '').trim().split(/\r?\n/)[0]?.slice(0, 200) || '';
/**
* Run diagnostics. args: { groups?, port?, probe_port?, sensing_url? }.
* deps: { exec, which, findRepoRoot, python, importer, fetch }.
*/
export async function runDoctor(args = {}, deps) {
const groups = new Set(args.groups?.length ? args.groups : DOCTOR_GROUPS);
const checks = [];
const add = (group, id, status, detail, remedy) => checks.push({ group, id, status, detail, ...(remedy ? { remedy } : {}) });
const want = (g) => groups.has(g);
if (want('runtime')) {
const major = Number(process.versions.node.split('.')[0]);
add('runtime', 'node-version', major >= 20 ? 'pass' : 'fail', `Node ${process.version} on ${process.platform}-${process.arch}`, major >= 20 ? null : 'Install Node.js 20 or newer.');
}
if (want('harness')) {
const manifest = verifyPackageManifest();
add('harness', 'package-integrity', manifest.ok ? 'pass' : 'warn',
manifest.ok ? `${manifest.files} packaged files match .harness/manifest.json` : `manifest drift: ${manifest.findings.slice(0, 5).join(', ')}`,
manifest.ok ? null : 'Reinstall the package; in a checkout run `npm run manifest:update` after intentional edits.');
const guard = !claimCheck('We hit 100% accuracy on poses.').ok && claimCheck('Held-out PCK@20 59.5% (MEASURED vs mean-pose baseline, verify.py).').ok;
add('harness', 'claim-guardrail', guard ? 'pass' : 'fail', guard ? 'claim_check flags overclaims and passes tagged claims' : 'claim_check self-test failed', guard ? null : 'Reinstall @ruvnet/ruview.');
try {
const brain = loadBrain();
add('harness', 'shared-brain', 'pass', `${brain.records.length} reviewed records, digest ${brain.digest.slice(0, 12)}`);
} catch (error) {
add('harness', 'shared-brain', 'fail', error.message, 'Reinstall the package; the reviewed corpus is corrupt.');
}
}
if (want('hosts')) {
for (const [id, bin, remedy] of [['claude-code', 'claude', 'npm i -g @anthropic-ai/claude-code'], ['codex', 'codex', 'npm i -g @openai/codex']]) {
const path = deps.which(bin);
add('hosts', id, path ? 'pass' : 'warn', path ? `found ${path}` : `${bin} not on PATH (optional: agent run)`, path ? null : remedy);
}
}
const repo = deps.findRepoRoot();
if (want('repo')) {
if (!repo) add('repo', 'checkout', 'warn', 'not inside a RuView checkout (verify/train/build need one)', 'git clone https://github.com/ruvnet/RuView && cd RuView');
else {
add('repo', 'checkout', 'pass', repo);
const missing = ['vendor/rufield/Cargo.toml', 'v2/crates/ruview-swarm/Cargo.toml'].filter((p) => !existsSync(join(repo, p)));
add('repo', 'submodules', missing.length ? 'warn' : 'pass', missing.length ? `uninitialized: ${missing.join(', ')}` : 'workspace submodules present',
missing.length ? 'git submodule update --init --recursive' : null);
}
}
if (want('rust')) {
// Run inside v2/ so a pinned rust-toolchain file selects the same toolchain CI uses.
const rustCwd = repo && existsSync(join(repo, 'v2')) ? join(repo, 'v2') : undefined;
const cargo = deps.which('cargo');
if (!cargo) add('rust', 'cargo', 'warn', 'cargo not on PATH (needed for training, calibration from source, kernel builds)', 'Install Rust: https://rustup.rs');
else {
const v = await deps.exec(cargo, ['--version'], { cwd: rustCwd, timeoutMs: 20_000 });
add('rust', 'cargo', v.ok ? 'pass' : 'warn', firstLine(v.stdout) || v.error);
const rustup = deps.which('rustup');
if (rustup) {
const t = await deps.exec(rustup, ['target', 'list', '--installed'], { cwd: rustCwd, timeoutMs: 20_000 });
const wasm = t.stdout.includes('wasm32-unknown-unknown');
add('rust', 'wasm32-target', wasm ? 'pass' : 'warn', wasm ? 'wasm32-unknown-unknown installed' : 'wasm32-unknown-unknown missing (kernel WASM builds)', wasm ? null : 'rustup target add wasm32-unknown-unknown');
}
}
const bin = deps.which('wifi-densepose');
add('rust', 'wifi-densepose-cli', bin ? 'pass' : 'warn', bin ? `found ${bin}` : 'wifi-densepose binary not on PATH; calibration/room training will use cargo run',
bin ? null : 'cargo install --path v2/crates/wifi-densepose-cli');
}
const python = deps.python();
if (want('python')) {
if (!python) add('python', 'python', 'warn', 'python not on PATH (needed for flashing, serial monitor, proof)', 'Install Python 3.9+');
else {
const v = await deps.exec(python, ['--version'], { timeoutMs: 20_000 });
add('python', 'python', 'pass', `${python}: ${firstLine(v.stdout || v.stderr)}`);
const serial = await deps.exec(python, ['-c', 'import serial;print(serial.__version__)'], { timeoutMs: 20_000 });
add('python', 'pyserial', serial.ok ? 'pass' : 'warn', serial.ok ? `pyserial ${firstLine(serial.stdout)}` : 'pyserial not importable', serial.ok ? null : 'pip install pyserial');
const esp = await deps.exec(python, ['-m', 'esptool', 'version'], { timeoutMs: 30_000 });
const espVersion = /v?(\d+\.\d+(?:\.\d+)?)/.exec(esp.stdout)?.[1];
add('python', 'esptool', esp.ok && espVersion ? 'pass' : 'warn', esp.ok && espVersion ? `esptool ${espVersion}` : 'esptool not importable', esp.ok ? null : 'pip install esptool');
}
}
if (want('firmware')) {
const binsRoot = repo && join(repo, 'firmware', 'esp32-csi-node', 'release_bins');
if (!binsRoot || !existsSync(binsRoot)) add('firmware', 'bundles', 'skip', 'no firmware/esp32-csi-node/release_bins in this checkout; pass --bundle <extracted release bundle> to flash');
else {
for (const name of readdirSync(binsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort()) {
const variant = name.startsWith('c6') ? 'c6' : 's3-8mb';
try {
const b = resolveBundle(join(binsRoot, name), variant);
add('firmware', `bundle:${name}`, 'pass', `${b.chipName} ${b.images.length} images verified against ${b.checksums}`);
} catch (error) {
add('firmware', `bundle:${name}`, 'fail', error.message, 'Re-download the release bundle and verify SHA256SUMS before flashing.');
}
}
}
add('firmware', 'variants', 'pass', `supported: ${Object.keys(FIRMWARE_VARIANTS).join(', ')}`);
}
if (want('serial') && python) {
const ports = await listSerialPorts(deps);
if (!ports.ok) add('serial', 'ports', 'warn', `cannot enumerate ports (${ports.reason})`, ports.reason === 'pyserial_missing' ? 'pip install pyserial' : null);
else add('serial', 'ports', ports.ports.length ? 'pass' : 'warn', ports.ports.length ? ports.ports.map((p) => `${p.port} ${p.description}`).join('; ').slice(0, 600) : 'no serial ports found',
ports.ports.length ? null : 'Connect the board with a data-capable USB cable; install the CP210x/CH340 driver if needed.');
if (args.port) {
try {
validatePort(args.port);
const listed = ports.ports.some((p) => p.port === args.port);
add('serial', 'port', listed ? 'pass' : 'warn', listed ? `${args.port} present` : `${args.port} not in enumerated ports`, listed ? null : 'Check the port name; on Linux add your user to the dialout group.');
if (args.probe_port === true) {
const probe = await deps.exec(python, ['-m', 'esptool', '--port', args.port, 'chip_id'], { timeoutMs: 60_000 });
const chip = parseChip(probe.stdout + probe.stderr);
add('serial', 'chip', chip ? 'pass' : 'fail', chip ? `detected ${chip}` : `no chip response: ${firstLine(probe.stderr || probe.error)}`,
chip ? null : 'Close other serial monitors; hold BOOT and tap RESET; try --baud 115200.');
}
} catch (error) {
add('serial', 'port', 'fail', error.message);
}
}
}
if (want('devices') && python) {
const listed = await listSerialPorts(deps);
if (listed.ok) {
const devices = listed.ports.map(classifyPort).filter((d) => d.likelyRoles.length);
if (!devices.length) add('devices', 'usb-serial', 'warn', 'no ESP32 / Realtek / mmWave / RPLIDAR candidates on USB', 'Use a data-capable cable; install CP210x, CH34x or PL2303 drivers; on Linux add your user to dialout.');
for (const d of devices) add('devices', `usb:${d.port}`, 'pass', `${d.bridge || d.usb}: likely ${d.likelyRoles.join('/')} — confirm: ${d.confirmWith[0]}`);
} else add('devices', 'usb-serial', 'warn', `cannot enumerate (${listed.reason})`, listed.reason === 'pyserial_missing' ? 'pip install pyserial' : null);
add('devices', 'network-streams', 'skip', 'ESP32 UDP and iPhone LiDAR are network streams: `ruview esp32 --seconds 10`, `ruview lidar --source iphone --url ws://HOST:8787/ws/lidar`');
}
if (want('remote')) {
try {
const { path, hosts } = loadHosts();
add('remote', 'hosts-file', 'pass', hosts.length ? `${hosts.length} host(s) in ${path}: ${hosts.map((h) => h.name).join(', ')}` : `no remote hosts configured (${path})`);
} catch (error) {
add('remote', 'hosts-file', 'fail', `invalid hosts file: ${error.message}`, 'Fix or remove ~/.config/ruview/hosts.json; re-add hosts with `ruview hosts add`.');
}
const ssh = deps.which('ssh');
add('remote', 'ssh', ssh ? 'pass' : 'warn', ssh ? `found ${ssh}` : 'ssh client not on PATH (remote hosts)', ssh ? null : 'Install the OpenSSH client.');
}
if (want('sensing')) {
if (!args.sensing_url) add('sensing', 'server', 'skip', 'pass --url http://<host>:<port> to check a running sensing server');
else {
try {
const url = new URL(args.sensing_url);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('sensing_url must be http(s)');
const target = new URL('/health', url);
const res = await deps.fetch(target, { signal: AbortSignal.timeout(5000) });
add('sensing', 'server', res.ok ? 'pass' : 'fail', `${target.href} → HTTP ${res.status}`, res.ok ? null : 'Start the sensing server: cargo run -p wifi-densepose-sensing-server');
} catch (error) {
add('sensing', 'server', 'fail', `unreachable: ${error.message}`, 'Check host/port and firewall; ESP32 nodes stream UDP to the aggregator, the HTTP API is separate.');
}
}
}
if (want('kernel')) {
try {
const mod = await deps.importer('@ruvnet/ruview-kernel');
const status = mod.kernelStatus();
add('kernel', 'wasm', status.wasm.available ? 'pass' : 'fail', status.wasm.available ? `ruview-kernel ${status.wasm.version} (${status.wasm.integrity})` : status.wasm.reason);
add('kernel', 'napi', status.napi.available ? 'pass' : 'warn', status.napi.available ? `native ${status.triple}` : `optional: ${status.napi.reason}`);
} catch {
add('kernel', 'package', 'warn', '@ruvnet/ruview-kernel not installed (optional compute/SDK kernel)', 'npm install @ruvnet/ruview-kernel');
}
}
const summary = { pass: 0, warn: 0, fail: 0, skip: 0 };
for (const c of checks) summary[c.status] += 1;
return {
ok: summary.fail === 0,
summary,
checks,
nextSteps: checks.filter((c) => c.remedy && c.status !== 'pass').map((c) => `${c.group}/${c.id}: ${c.remedy}`),
};
}
/** Human-readable rendering for the CLI. */
export function formatDoctor(report) {
const icon = { pass: 'PASS', warn: 'WARN', fail: 'FAIL', skip: 'SKIP' };
const lines = [];
let group = null;
for (const c of report.checks) {
if (c.group !== group) { group = c.group; lines.push(`\n[${group}]`); }
lines.push(` ${icon[c.status]} ${c.id} — ${c.detail}`);
if (c.remedy && c.status !== 'pass') lines.push(` fix: ${c.remedy}`);
}
const s = report.summary;
lines.push(`\nruview doctor: ${report.ok ? 'no failures' : 'failures found'} — ${s.pass} pass, ${s.warn} warn, ${s.fail} fail, ${s.skip} skip`);
return lines.join('\n').trimStart();
}
+34
View File
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: MIT
// Non-throwing wrapper over the scrubbed-environment process runner, for
// operator tools (esptool, cargo, python). Never uses a shell.
import { DEFAULT_ENV_ALLOWLIST, runProcess } from './process-runner.js';
// Toolchain locations operators legitimately configure; no credentials.
export const TOOLCHAIN_ENV = Object.freeze([
...DEFAULT_ENV_ALLOWLIST,
'CARGO_HOME', 'RUSTUP_HOME', 'RUSTUP_TOOLCHAIN', 'VIRTUAL_ENV', 'CONDA_PREFIX',
'LIBTORCH', 'LIBTORCH_USE_PYTORCH', 'LIBTORCH_BYPASS_VERSION_CHECK', 'LD_LIBRARY_PATH', 'DYLD_LIBRARY_PATH', 'CUDA_HOME', 'IDF_PATH',
]);
const TAIL = 16_384;
/** Run a command; resolves {ok, code, stdout, stderr, error} and never rejects. */
export async function execTool(command, args, { cwd, timeoutMs = 120_000, maxOutputBytes = 4_194_304, fullOutput = false } = {}) {
// fullOutput: return all of stdout (bounded by maxOutputBytes) for binary
// captures; otherwise keep only a diagnostic tail.
const tail = (text) => (fullOutput ? text : text.slice(-TAIL));
try {
const r = await runProcess(command, args, { cwd, timeoutMs, maxOutputBytes, envAllowlist: TOOLCHAIN_ENV });
return { ok: true, code: r.code, stdout: tail(r.stdout), stderr: r.stderr.slice(-TAIL), error: null };
} catch (error) {
return {
ok: false,
code: error.code ?? null,
stdout: tail(String(error.stdout || '')),
stderr: String(error.stderr || '').slice(-TAIL),
error: String(error.message || error).slice(0, 2000),
timedOut: Boolean(error.timedOut),
};
}
}
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff. Show more