mirror of
https://github.com/ruvnet/RuView
synced 2026-07-16 16:23:18 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0b203d088 |
@@ -9,7 +9,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
PYTHON_VERSION: '3.11'
|
PYTHON_VERSION: '3.11'
|
||||||
NODE_VERSION: '20' # ADR-265: all Node packages in this repo declare engines >= 20
|
NODE_VERSION: '18'
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
# ADR-265 D1 — the npm-package gate.
|
|
||||||
#
|
|
||||||
# Every Node package in this repo (published or private) gets: install, build,
|
|
||||||
# tests, a version-literal gate (D3 — package.json is the only place a version
|
|
||||||
# lives), a pack-content gate (no source maps, unpacked-size budget), a
|
|
||||||
# tarball-install smoke test (would have caught ADR-264 F1's broken `require`
|
|
||||||
# export), and the claim-check honesty lint on the README (D4).
|
|
||||||
|
|
||||||
name: npm packages
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'harness/ruview/**'
|
|
||||||
- 'tools/ruview-mcp/**'
|
|
||||||
- 'tools/ruview-cli/**'
|
|
||||||
- '.github/workflows/npm-packages.yml'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'harness/ruview/**'
|
|
||||||
- 'tools/ruview-mcp/**'
|
|
||||||
- 'tools/ruview-cli/**'
|
|
||||||
- '.github/workflows/npm-packages.yml'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
gate:
|
|
||||||
name: ${{ matrix.package.dir }} (node ${{ matrix.node }})
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
node: ['20', '22']
|
|
||||||
package:
|
|
||||||
- dir: harness/ruview
|
|
||||||
build: false
|
|
||||||
publishable: true
|
|
||||||
# ADR-263: dependency-free harness; budget guards against dep creep.
|
|
||||||
unpacked_budget: 65536
|
|
||||||
- dir: tools/ruview-mcp
|
|
||||||
build: true
|
|
||||||
publishable: true
|
|
||||||
# ADR-264 O2: map-free tarball (was 188 kB with maps).
|
|
||||||
unpacked_budget: 140000
|
|
||||||
- dir: tools/ruview-cli
|
|
||||||
build: true
|
|
||||||
publishable: false
|
|
||||||
unpacked_budget: 0
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: ${{ matrix.package.dir }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node }}
|
|
||||||
|
|
||||||
# Repo policy gitignores lockfiles under harness/ (the harness is
|
|
||||||
# dependency-free anyway); the TS packages commit theirs.
|
|
||||||
- name: Install
|
|
||||||
run: |
|
|
||||||
if [ -f package-lock.json ]; then npm ci; else npm install --no-fund --no-audit; fi
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
if: ${{ matrix.package.build }}
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: npm test --if-present
|
|
||||||
|
|
||||||
# ADR-265 D3 — package.json is the only place a version string lives.
|
|
||||||
- name: Version-literal gate
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
hits=""
|
|
||||||
for d in src bin; do
|
|
||||||
if [ -d "$d" ]; then
|
|
||||||
hits+=$(grep -rEn '\b[0-9]+\.[0-9]+\.[0-9]+\b' "$d" | grep -vE '127\.0\.0\.1|0\.0\.0\.0' || true)
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ -n "$hits" ]; then
|
|
||||||
echo "Hardcoded version-like literals found (read package.json instead — ADR-265 D3):"
|
|
||||||
echo "$hits"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ADR-265 D1.3 — pack-content gate: no maps, size budget enforced.
|
|
||||||
- name: Pack gate
|
|
||||||
if: ${{ matrix.package.publishable }}
|
|
||||||
run: |
|
|
||||||
npm pack --dry-run --json 2>/dev/null | node -e "
|
|
||||||
const [info] = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
|
||||||
const budget = Number(process.env.UNPACKED_BUDGET);
|
|
||||||
const maps = info.files.filter((f) => f.path.endsWith('.map'));
|
|
||||||
if (maps.length > 0) {
|
|
||||||
console.error('Tarball contains source maps (ADR-264 F2):', maps.map((m) => m.path));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (info.unpackedSize > budget) {
|
|
||||||
console.error(\`Unpacked size \${info.unpackedSize} B exceeds budget \${budget} B\`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log(\`pack gate OK: \${info.files.length} files, \${info.unpackedSize} B unpacked (budget \${budget} B), 0 maps\`);
|
|
||||||
"
|
|
||||||
env:
|
|
||||||
UNPACKED_BUDGET: ${{ matrix.package.unpacked_budget }}
|
|
||||||
|
|
||||||
# ADR-265 D1.4 — install the real tarball and drive each bin/export.
|
|
||||||
- name: Tarball smoke test
|
|
||||||
if: ${{ matrix.package.publishable }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
TGZ="$PWD/$(npm pack --silent 2>/dev/null | tail -1)"
|
|
||||||
SMOKE="$(mktemp -d)"
|
|
||||||
cd "$SMOKE"
|
|
||||||
npm init -y > /dev/null
|
|
||||||
npm i --no-fund --no-audit "$TGZ"
|
|
||||||
case "${{ matrix.package.dir }}" in
|
|
||||||
harness/ruview)
|
|
||||||
./node_modules/.bin/ruview --version
|
|
||||||
./node_modules/.bin/ruview doctor
|
|
||||||
# the honesty gate must fail closed on empty input (ADR-263 F1)
|
|
||||||
if ./node_modules/.bin/ruview 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);"
|
|
||||||
;;
|
|
||||||
tools/ruview-mcp)
|
|
||||||
# initialize over stdio; server must answer and exit 0 on EOF
|
|
||||||
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}\n' \
|
|
||||||
| timeout 30 ./node_modules/.bin/rvagent | grep -q '"serverInfo"'
|
|
||||||
# the ESM export must resolve from the installed tarball (ADR-264 F1)
|
|
||||||
timeout 30 node --input-type=module -e "await import('@ruvnet/rvagent');" < /dev/null
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# ADR-265 D4 — package READMEs must pass the project's own honesty lint.
|
|
||||||
- name: Claim-check README
|
|
||||||
run: |
|
|
||||||
if [ -f README.md ]; then
|
|
||||||
node "$GITHUB_WORKSPACE/harness/ruview/bin/cli.js" claim-check --file README.md
|
|
||||||
else
|
|
||||||
echo "no README.md — skipping"
|
|
||||||
fi
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
# ADR-265 D2 — publish only from CI, with provenance.
|
|
||||||
#
|
|
||||||
# Manual `npm publish` from laptops stops: this workflow re-runs the ADR-265 D1
|
|
||||||
# gate for the selected package and then publishes with npm provenance
|
|
||||||
# attestations (OIDC), tying every published version to a public commit +
|
|
||||||
# workflow run — the npm-side analogue of the ADR-028 witness bundle.
|
|
||||||
#
|
|
||||||
# Requires: NPM_TOKEN repo secret (an npm automation token), or npm Trusted
|
|
||||||
# Publishing configured for the package (in which case the token is unused).
|
|
||||||
|
|
||||||
name: ruview npm release
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
package:
|
|
||||||
description: 'Package directory to publish'
|
|
||||||
required: true
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- harness/ruview
|
|
||||||
- tools/ruview-mcp
|
|
||||||
dist_tag:
|
|
||||||
description: 'npm dist-tag'
|
|
||||||
required: false
|
|
||||||
default: 'latest'
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write # npm --provenance
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: ${{ inputs.package }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
registry-url: 'https://registry.npmjs.org'
|
|
||||||
|
|
||||||
- name: Install
|
|
||||||
run: |
|
|
||||||
if [ -f package-lock.json ]; then npm ci; else npm install --no-fund --no-audit; fi
|
|
||||||
|
|
||||||
- name: Build (if present)
|
|
||||||
run: npm run build --if-present
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: npm test --if-present
|
|
||||||
|
|
||||||
# ADR-265 D3 — package.json is the only place a version string lives.
|
|
||||||
- name: Version-literal gate
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
hits=""
|
|
||||||
for d in src bin; do
|
|
||||||
if [ -d "$d" ]; then
|
|
||||||
hits+=$(grep -rEn '\b[0-9]+\.[0-9]+\.[0-9]+\b' "$d" | grep -vE '127\.0\.0\.1|0\.0\.0\.0' || true)
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ -n "$hits" ]; then
|
|
||||||
echo "Hardcoded version-like literals found (read package.json instead — ADR-265 D3):"
|
|
||||||
echo "$hits"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ADR-265 D1.3 — pack-content gate: no maps AND the per-package
|
|
||||||
# unpacked-size budget (the budgets that npm-packages.yml enforces).
|
|
||||||
- name: Pack gate (no maps + size budget)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
case "${{ inputs.package }}" in
|
|
||||||
# ADR-263: dependency-free harness; budget guards against dep creep.
|
|
||||||
harness/ruview) export UNPACKED_BUDGET=65536 ;;
|
|
||||||
# ADR-264 O2: map-free tarball (was 188 kB with maps).
|
|
||||||
tools/ruview-mcp) export UNPACKED_BUDGET=140000 ;;
|
|
||||||
*) echo "Unknown package '${{ inputs.package }}' — no budget defined"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
npm pack --dry-run --json 2>/dev/null | node -e "
|
|
||||||
const [info] = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
|
||||||
const budget = Number(process.env.UNPACKED_BUDGET);
|
|
||||||
const maps = info.files.filter((f) => f.path.endsWith('.map'));
|
|
||||||
if (maps.length > 0) {
|
|
||||||
console.error('Tarball contains source maps (ADR-264 F2):', maps.map((m) => m.path));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (info.unpackedSize > budget) {
|
|
||||||
console.error(\`Unpacked size \${info.unpackedSize} B exceeds budget \${budget} B\`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log(\`pack gate OK: \${info.files.length} files, \${info.unpackedSize} B unpacked (budget \${budget} B), 0 maps\`);
|
|
||||||
"
|
|
||||||
|
|
||||||
# ADR-265 D1.4 — install the real tarball and drive each bin/export.
|
|
||||||
- name: Tarball smoke test
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
TGZ="$PWD/$(npm pack --silent 2>/dev/null | tail -1)"
|
|
||||||
SMOKE="$(mktemp -d)"
|
|
||||||
cd "$SMOKE"
|
|
||||||
npm init -y > /dev/null
|
|
||||||
npm i --no-fund --no-audit "$TGZ"
|
|
||||||
case "${{ inputs.package }}" in
|
|
||||||
harness/ruview)
|
|
||||||
./node_modules/.bin/ruview --version
|
|
||||||
./node_modules/.bin/ruview doctor
|
|
||||||
# the honesty gate must fail closed on empty input (ADR-263 F1)
|
|
||||||
if ./node_modules/.bin/ruview 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);"
|
|
||||||
;;
|
|
||||||
tools/ruview-mcp)
|
|
||||||
# initialize over stdio; server must answer and exit 0 on EOF
|
|
||||||
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}\n' \
|
|
||||||
| timeout 30 ./node_modules/.bin/rvagent | grep -q '"serverInfo"'
|
|
||||||
# the ESM export must resolve from the installed tarball (ADR-264 F1)
|
|
||||||
timeout 30 node --input-type=module -e "await import('@ruvnet/rvagent');" < /dev/null
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
- name: Claim-check README
|
|
||||||
run: |
|
|
||||||
if [ -f README.md ]; then
|
|
||||||
node "$GITHUB_WORKSPACE/harness/ruview/bin/cli.js" claim-check --file README.md
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Publish (with provenance)
|
|
||||||
run: npm publish --provenance --access public --tag "${{ inputs.dist_tag }}"
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
features:
|
features:
|
||||||
- { label: 'default', flags: '--no-default-features' }
|
- { label: 'default', flags: '--no-default-features' }
|
||||||
- { label: 'train', flags: '--features train' }
|
- { label: 'train', flags: '--features train' }
|
||||||
- { label: 'ruflo', flags: '--features ruflo' }
|
- { label: 'ruflo+itar', flags: '--features ruflo,itar-unrestricted' }
|
||||||
- { label: 'full+train', flags: '--features full,train' }
|
- { label: 'full+train', flags: '--features full,train' }
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
-14
@@ -277,17 +277,3 @@ aether-arena/staging/
|
|||||||
# MM-Fi benchmark dataset archives — large data, fetch separately, never commit
|
# MM-Fi benchmark dataset archives — large data, fetch separately, never commit
|
||||||
assets/MM-Fi/E0*.zip
|
assets/MM-Fi/E0*.zip
|
||||||
assets/MM-Fi/*.zip
|
assets/MM-Fi/*.zip
|
||||||
|
|
||||||
# through-wall demo: regenerable trained model artifact
|
|
||||||
examples/through-wall/model/
|
|
||||||
|
|
||||||
# RuView harness (npx ruview) build artifacts — ADR-182
|
|
||||||
harness/**/node_modules/
|
|
||||||
harness/**/*.tgz
|
|
||||||
harness/**/package-lock.json
|
|
||||||
harness/**/.claude-flow/
|
|
||||||
harness/**/ruvector.db
|
|
||||||
|
|
||||||
# ruvector runtime/hook DB — never tracked (any depth)
|
|
||||||
ruvector.db
|
|
||||||
**/ruvector.db
|
|
||||||
|
|||||||
@@ -21,11 +21,3 @@
|
|||||||
[submodule "vendor/rufield"]
|
[submodule "vendor/rufield"]
|
||||||
path = vendor/rufield
|
path = vendor/rufield
|
||||||
url = https://github.com/ruvnet/rufield
|
url = https://github.com/ruvnet/rufield
|
||||||
[submodule "v2/crates/ruview-swarm"]
|
|
||||||
path = v2/crates/ruview-swarm
|
|
||||||
url = https://github.com/ruvnet/ruv-drone.git
|
|
||||||
branch = main
|
|
||||||
[submodule "v2/crates/worldgraph"]
|
|
||||||
path = v2/crates/worldgraph
|
|
||||||
url = https://github.com/ruvnet/worldgraph.git
|
|
||||||
branch = main
|
|
||||||
|
|||||||
@@ -7,22 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Multi-actor PoseCode scene crate (`wifi-densepose-posecode`, ADR-266).** Adds a deterministic Rust scene model for multiple persistent RuView actors, synchronized poses, inter-person contacts, confidence and observation provenance. Includes a bounded line parser, canonical serializer, direct `PoseTrack` adapter, confidence-aware phase segmentation, provenance-sensitive range validation, 14 feature-enabled unit tests and Criterion parser/serializer benchmarks. Raw 20 Hz observations remain separate from semantic phases; observed range anomalies are warnings rather than medical safety claims. MEASURED on the implementation container: two-actor parse 3.40 µs, serialization 3.29 µs.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (−22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **EngineBridge now honors `WDP_GUARD_INTERVAL_US`/`WDP_SOFT_GUARD_US`/`WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US`** (#1309, PR #1312, @erichkusuki). The governed trust path previously built its multistatic fuser from a hardcoded `MultistaticConfig::default()` (60 ms guard), so multi-node deployments with WiFi/ESP-NOW time sync (10–150 ms drift) failed every governed cycle regardless of configuration — while the startup log claimed the override took effect. New `StreamingEngine::set_multistatic_config()`; `EngineBridge::new()` takes an `Option<MultistaticConfig>` threaded from the same env-derived config as `AppState.multistatic_fuser`. Hardware-verified on a live 2-node ESP32-S3 setup (90 s window, 0 fusion errors; previously every cycle failed).
|
|
||||||
- **`/api/v1/stream/pose` WebSocket reachable with `RUVIEW_API_TOKEN` set + dashboard bearer-token field** (#1310, PR #1313, @erichkusuki). Browsers cannot attach an `Authorization` header to a WS upgrade, so the Live Demo pose stream always failed when auth was on; the path is now on a narrow exact-match exemption list (mirrors `/ws/sensing`), with a regression test pinning that the exemption doesn't leak to other `/api/v1/*` paths. The QuickSettings panel gains an "API Access" field storing the bearer token in `localStorage`; the token is applied at `api.service.js` module load so the very first request carries it.
|
|
||||||
- **Display-less DevKitC-1 boards: `sdkconfig.defaults.devkitc` build overlay** (#1308, PR #1311, @erichkusuki). The ADR-045 runtime display probe false-positives on stock ESP32-S3-DevKitC-1 (floating QSPI pins), which silently skipped the RuView#893 MGMT+DATA CSI upgrade and collapsed CSI yield to 0 pps. The overlay compiles display support out (`has_display` constant-false). Also fixes stale `espressif/idf:v5.2` README references to v5.4 (source requires `esp_driver_uart`, IDF ≥5.3). Hardware-verified on 2× DevKitC-1-N16R8 (0 → 40–45 pps).
|
|
||||||
- **ADR-263/264/265 implemented — the RuView npm surface fixed end-to-end (`@ruvnet/ruview@0.2.0`, `@ruvnet/rvagent@0.2.0`, `@ruv/ruview-cli`).** Harness (ADR-263 O1–O9): `claim-check` now **fails closed** on empty input (CLI exit 2 + `empty_text` tool error); the MCP stdio server dispatches `tools/call` asynchronously over promise-based `spawn` — `ping` answers while a long `verify`/`calibrate` runs (pinned by a new e2e test that runs a 3 s fake proof and asserts sub-second ping); the two `optionalDependencies` are gone so a cold `npx` installs exactly 1 package (MEASURED: was 4 packages / 620 kB / 71 files, `npm i` in a clean prefix); child output is captured as bounded rolling tails (no more 1 MiB `maxBuffer` kills); `node_monitor` passes the port via `sys.argv` instead of splicing it into `python -c` source; the MCP `serverInfo.version` reads package.json; `.claude/skills/*/SKILL.md` are generated from `skills/*.md` by a `prepack` sync script (byte-equality pinned by test); `which()` is a memoized dep-free PATH scan; tools are underscore-canonical (`ruview_claim_check`, …) with the dotted names accepted as call-time aliases, plus `resources/list`/`prompts/list` stubs; the guardrail's `METRIC_TERMS` matching is precision-fixed (word-boundary `map`/`f1`/`auc`/`iou`, code-span + label scrubbing, quantitative-claims-only) — ADR-263/264/265 and both package READMEs now PASS `claim-check` while real untagged claims still flag. 30/30 tests (MEASURED, `node --test`). rvagent (ADR-264 O1–O9): `exports` fixed (types-first, the never-built `dist/index.cjs` `require` target removed — verified broken in the published 0.1.0 tarball); tarball is map-free (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, down from 188 kB with 44 maps); the Streamable HTTP transport is **actually wired** behind `RVAGENT_HTTP_PORT` with one transport + one MCP server per session (`mcp-session-id` routing), a 1 MiB body cap (413), and a port-aware localhost origin gate — the "dual-transport" description is now true; tools renamed to underscore-canonical with dotted router aliases; ONE Zod validation gate per call with the advertised JSON Schema generated from the same Zod source (`zod-to-json-schema`); `train_count` closes its log fds (was leaking 2/job) and persists job records to `<jobsDir>/<id>.json` so `job_status` survives restarts, with bounded log-tail reads; `detectCogBinary` actually probes its candidate paths; version reads package.json; `@types/express` dropped, `@types/jest` aligned to jest 29; README rewritten to match reality (no phantom `stdio`/`http`/`policy grant` subcommands; unimplemented ADR-124 catalog tools labeled roadmap). 99/99 jest tests (MEASURED); stdio handshake + HTTP session flow + 403/400/404/413 gates smoke-tested live. CLI: bin renamed `ruview-cli` (the `ruview` bin belongs to `@ruvnet/ruview`, ADR-265 D4), version single-sourced. Distribution (ADR-265 D1–D4): new `npm-packages.yml` (3-package × Node 20/22 matrix: tests, version-literal grep gate, pack-content/size gate, tarball-install smoke test incl. the fail-closed claim-check and an ESM-import probe that would have caught the broken `require` export, README claim-check) and `ruview-npm-release.yml` (publish from CI only, `npm publish --provenance`); `ci.yml` NODE_VERSION 18→20.
|
|
||||||
- **Multistatic fusion never ran on a mixed-mode ESP32 mesh — live bridge fed raw, un-canonicalized per-node CSI to the fuser (#1170).** `node_frame_from_state` (`multistatic_bridge.rs`) wrapped each node's **raw** amplitude vector (HT20 ≈ 64 bins, HT40 ≈ 128/192) into a struct *named* `CanonicalCsiFrame` without ever resampling, so `MultistaticFuser::fuse` tripped `DimensionMismatch` on every cycle, silently fell back to per-node sum/dedup, and spun `total_engine_errors` unbounded. Added `HardwareNormalizer::resample_to_canonical` (resample-only, **no z-score** — preserves the amplitude scale the person-score's `variance/mean²` relies on) and run every node frame through it onto the canonical 56-tone grid before fusion. Heterogeneous meshes now fuse instead of erroring. Pinned by `heterogeneous_node_counts_canonicalize_and_fuse` (mixed 64/192 → fuses), `resample_to_canonical_is_length_only_no_zscore`, and an updated `test_node_frame_conversion`; the pre-existing `engine_bridge::observe_cycle_counts_engine_errors` was retargeted to force a `TimestampMismatch` (its old 56-vs-30 setup now canonicalizes cleanly). `wifi-densepose-signal` 501 / `wifi-densepose-sensing-server` 677 tests, 0 failed.
|
|
||||||
- **`csi_fps_ema` reported the CSI frame rate 40–840× too high under bursty UDP delivery (#1180).** `update_csi_fps_ema` only rejected deltas `≤ 0` or `≥ 1 s`, so a 36 µs intra-burst arrival delta yielded `1/dt ≈ 27 kHz` straight into the EMA — the metric measured server arrival jitter, not the node's ~40 fps production rate. Added a `MIN_PLAUSIBLE_CSI_DT_SEC = 0.005` floor (derived from the firmware's 50 fps `CSI_MIN_SEND_INTERVAL_US` ceiling, ×4 slack) and made `observe_csi_frame_arrival` keep its anchor across sub-floor bursts so the next genuine inter-frame gap measures true cadence. Pinned by `subms_burst_delta_rejected`, `burst_interleaved_with_nominal_stays_in_band`, and `observe_csi_frame_arrival_ignores_subms_bursts`.
|
|
||||||
- **`stream_sender` ENOMEM backoff starved low-rate control packets under a weak uplink (#1183, follow-up to #1135/#1159).** The global `s_backoff_until_us` gate (triggered by the 50 Hz CSI flood at weak RSSI) also suppressed the ≤48 B, ≤1 Hz `feature_state` / mesh `HEALTH` / sync packets that contribute negligible buffer pressure, so telemetry failed essentially every cycle. Added `stream_sender_send_priority()` — bypasses the backoff gate, reports ENOMEM quietly, and never extends/resets the global streak — and routed `feature_state`, HEALTH/anomaly (`rv_mesh_send`), and sync packets through it. Also fixed the misleading `"HEALTH sent"` log that printed unconditionally even when `rv_mesh_send` returned `ESP_FAIL` (now prints `sent`/`FAILED` from the actual return). Firmware builds clean (ESP-IDF v5.4).
|
|
||||||
- **Multistatic fusion guard interval is now operator-configurable — fixes permanent trust demotion with WiFi-synced ESP32 nodes (#1049).** Two independently-clocked ESP32-S3 boards on ESP-NOW sync drift 10–150 ms (typ. ~70 ms) — the 100 ms beacon + WiFi-MAC jitter cannot hold them within the published 60 ms default guard, so the governed-trust cycle permanently demoted to `Restricted`, suppressed all pose output, and spun the error counter to 200k+ with **no escape hatch but a container restart**. Added a **direct `WDP_GUARD_INTERVAL_US` override** (+ optional `WDP_SOFT_GUARD_US`) to `multistatic_guard_config_from_env`, so a deployment can lift the hard guard past its measured spread (e.g. `WDP_GUARD_INTERVAL_US=200000`) without having to know its exact TDM schedule. Precedence is most-specific-wins: a direct override beats the existing `WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US` schedule-derived guard, which beats the 60 ms/20 ms default; the override is applied on top of whichever base is selected, the soft band is always clamped strictly below the hard guard, and a malformed/zero value is ignored (falls back to the base rather than breaking fusion). The effective guard is now logged at startup. Pinned by 6 new tests (`multistatic_guard_config_tests`): direct-override-wins / beats-TDM-derived / soft-clamped-below-hard / lowering-hard-pulls-soft-down / malformed-or-zero-falls-back / default-when-unset. `wifi-densepose-sensing-server` bin tests **449 → 455**, 0 failed; Python proof VERDICT PASS, hash unchanged (off the signal proof path).
|
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- **`wifi-densepose-occworld-candle` — beyond-SOTA security + correctness review (Milestone #9, crate 4/4).** (1) **HIGH (MEASURED) — checkpoint-load crash on any int32 tensor** (`model.rs::safetensor_dtype_to_candle`). `safetensors::Dtype::I32` was mapped to `candle_core::DType::I64` and the raw int32 byte buffer (4 bytes/elem) was then handed to `Tensor::from_raw_buffer(.., I64, shape, ..)`. Candle derives `elem_count = data.len() / dtype.size_in_bytes()`, so the I64 path halved the element count while keeping the *original* shape — yielding a tensor whose declared shape claims twice as many elements as its backing storage holds. Reading it **panics** (`range end index 6 out of range for slice of length 3` — slice OOB inside candle-core) on any attacker-supplied or PyTorch-exported checkpoint containing an int32 tensor (common: index/buffer tensors). Fixed by mapping `I32 → DType::I32` (and `I16 → DType::I16`), both first-class candle dtypes. Reproduction recorded on old code; pinned by `tests/checkpoint_loading.rs::int32_tensor_loads_with_consistent_shape_and_values` (panics on old, passes on new) plus F32/I64/corrupt-file control cases. (2) **LOW (MEASURED) — `predict()` lacked frame/batch validation at the input boundary** (`inference.rs`). It validated H/W/D but not the externally-supplied frame count; an `f_in > num_frames*2` over-indexed the temporal positional embedding deep in the transformer and surfaced as a cryptic candle "gather" `InvalidIndex` (returned error, not a panic — candle bounds-checks), and a zero frame/batch dim fed a zero-element tensor into the pipeline. Now rejected at the boundary with a clear `ShapeMismatch`. Pinned by `predict_rejects_zero_frames` / `predict_rejects_too_many_frames` / `predict_accepts_frame_count_at_capacity`. (3) **LOW (MEASURED) — divide-by-zero panic on a degenerate input to the public `VQCodebook::encode`** (`vqvae.rs`): a rank-0 / empty-last-dim tensor made `last == 0` and panicked on `elem_count() / last`. Now fails closed with a clear error. Pinned by `encode_rejects_scalar_without_panicking`. **Dimensions confirmed CLEAN with evidence:** panic surface — zero `unwrap()`/`expect()`/`panic!`/`unreachable!` in production code paths (grep evidence; all error handling via `?`/`map_err`); NaN-state-poisoning — N/A (engine is stateless between `predict` calls, input is `u8` class indices so non-finite input is structurally impossible, no persistent world-model buffer to latch into); unbounded-alloc / shape-data mismatch from malformed weights — defended upstream by `safetensors::validate()` (overflow-checked `nelements*dtype.size()` vs declared byte range, rejected before reaching candle); secrets — none (grep clean, only `token_h`/`token_w` config fields match). `unsafe_code = forbid` in the crate manifest. **Build/validation status (MEASURED on Windows):** crate builds and tests under `cargo test -p wifi-densepose-occworld-candle --no-default-features` — **29/29 pass** (20 unit + 4 checkpoint_loading + 3 predict_honesty + 2 doc) after fixes; `cargo test --workspace --no-default-features` = 0 failed across all crates (lone `wifi-densepose-desktop` `api_integration` failure was a Windows "Access is denied (os error 5)" file-lock flake — re-ran in isolation **21/21 pass**); Python proof VERDICT PASS, hash `f8e76f21…446f7a` unchanged. *Warrants ADR slot 179 (parent to author).*
|
- **`wifi-densepose-occworld-candle` — beyond-SOTA security + correctness review (Milestone #9, crate 4/4).** (1) **HIGH (MEASURED) — checkpoint-load crash on any int32 tensor** (`model.rs::safetensor_dtype_to_candle`). `safetensors::Dtype::I32` was mapped to `candle_core::DType::I64` and the raw int32 byte buffer (4 bytes/elem) was then handed to `Tensor::from_raw_buffer(.., I64, shape, ..)`. Candle derives `elem_count = data.len() / dtype.size_in_bytes()`, so the I64 path halved the element count while keeping the *original* shape — yielding a tensor whose declared shape claims twice as many elements as its backing storage holds. Reading it **panics** (`range end index 6 out of range for slice of length 3` — slice OOB inside candle-core) on any attacker-supplied or PyTorch-exported checkpoint containing an int32 tensor (common: index/buffer tensors). Fixed by mapping `I32 → DType::I32` (and `I16 → DType::I16`), both first-class candle dtypes. Reproduction recorded on old code; pinned by `tests/checkpoint_loading.rs::int32_tensor_loads_with_consistent_shape_and_values` (panics on old, passes on new) plus F32/I64/corrupt-file control cases. (2) **LOW (MEASURED) — `predict()` lacked frame/batch validation at the input boundary** (`inference.rs`). It validated H/W/D but not the externally-supplied frame count; an `f_in > num_frames*2` over-indexed the temporal positional embedding deep in the transformer and surfaced as a cryptic candle "gather" `InvalidIndex` (returned error, not a panic — candle bounds-checks), and a zero frame/batch dim fed a zero-element tensor into the pipeline. Now rejected at the boundary with a clear `ShapeMismatch`. Pinned by `predict_rejects_zero_frames` / `predict_rejects_too_many_frames` / `predict_accepts_frame_count_at_capacity`. (3) **LOW (MEASURED) — divide-by-zero panic on a degenerate input to the public `VQCodebook::encode`** (`vqvae.rs`): a rank-0 / empty-last-dim tensor made `last == 0` and panicked on `elem_count() / last`. Now fails closed with a clear error. Pinned by `encode_rejects_scalar_without_panicking`. **Dimensions confirmed CLEAN with evidence:** panic surface — zero `unwrap()`/`expect()`/`panic!`/`unreachable!` in production code paths (grep evidence; all error handling via `?`/`map_err`); NaN-state-poisoning — N/A (engine is stateless between `predict` calls, input is `u8` class indices so non-finite input is structurally impossible, no persistent world-model buffer to latch into); unbounded-alloc / shape-data mismatch from malformed weights — defended upstream by `safetensors::validate()` (overflow-checked `nelements*dtype.size()` vs declared byte range, rejected before reaching candle); secrets — none (grep clean, only `token_h`/`token_w` config fields match). `unsafe_code = forbid` in the crate manifest. **Build/validation status (MEASURED on Windows):** crate builds and tests under `cargo test -p wifi-densepose-occworld-candle --no-default-features` — **29/29 pass** (20 unit + 4 checkpoint_loading + 3 predict_honesty + 2 doc) after fixes; `cargo test --workspace --no-default-features` = 0 failed across all crates (lone `wifi-densepose-desktop` `api_integration` failure was a Windows "Access is denied (os error 5)" file-lock flake — re-ran in isolation **21/21 pass**); Python proof VERDICT PASS, hash `f8e76f21…446f7a` unchanged. *Warrants ADR slot 179 (parent to author).*
|
||||||
- **`wifi-densepose-wasm-edge` beyond-SOTA closing review — boundary NaN-state-poisoning guard + clean-with-evidence attestation (ADR-040 edge crate, ~70 modules).** Closing pass of the security campaign over the last untouched sizeable crate. **One real finding fixed (LOW / source-analysis + reproduced):** the two WASM↔host frame boundaries (`lib.rs::on_frame`/`on_timer` and `bin/ghost_hunter.rs::on_frame`) read raw IEEE-754 `f32` from the `csi_get_phase`/`csi_get_amplitude`/`csi_get_variance`/`csi_get_motion_energy` host imports **without any finiteness check** — the entire crate had **zero** `is_finite`/`is_nan` guards, and the in-crate `clamp` helpers propagate NaN (`NaN < lo` and `NaN > hi` are both false). A single non-finite value (firmware DSP bug, uninitialised buffer, or hostile host) latches NaN into the long-lived per-module accumulators (EMA, Welford, phasor sums, anomaly baselines); once latched, every downstream comparison evaluates `false`, so detectors fail **degraded** (stuck gate state, silently-disabled anomaly checks) — silent corruption, not a crash (WASM `panic=abort` is *not* tripped: no indexing/`unwrap` on the poisoned value). Threat model is a **semi-trusted** boundary (the Tier-2 DSP firmware supplies the imports, not direct network/JS), hence LOW severity / defense-in-depth. **Fix:** added `sanitize_host_f32()` (maps non-finite→`0.0`, `core`-only so it holds in `no_std`) applied at every `host_get_*` float read — a single chokepoint covering all ~70 downstream modules, mirroring the existing M-01 negative-`n_subcarriers` boundary clamp. **Pinned by** `boundary_tests::{sanitize_passes_finite_values_through, sanitize_maps_non_finite_to_zero, coherence_monitor_nan_latches_without_sanitize_but_not_with}` — the last asserts on the *current* `CoherenceMonitor` that a raw NaN frame latches the smoothed score (documents the hazard) while the boundary-sanitized path stays finite. **Dimensions attested CLEAN with evidence (source-analysis):** (a) **panic-on-input** — every non-test `unwrap()`/`expect()` is either `#[cfg(test)]` or in the `std`-gated RVF *builder* host tool writing to an in-memory `Vec` (infallible); no `panic!`/`unreachable!`/`todo!`/`get_unchecked` in any hot path. (b) **shape/bounds** — all frame-buffer access is `min()`-clamped (`MAX_SC=32`, `DTW_MAX_LEN`, `LCS_WINDOW`, `PATTERN_LEN`), all index-by-cast sites (`feature_id as usize`, `conclusion_id`, `minute_counter`, `plan_step`) are either compile-time-const-bounded or `if idx <`/`%`-guarded; negative `n_subcarriers` already mapped to 0 (M-01). (c) **memory/leak** — no `move ||` closures, no `mem::forget`/`Box::leak`/`.leak()`; the only `Box::new` is in the `std`-gated `skill_registry` (one-time init, bounded). (d) **secrets** — none (grep clean). **MEASURED build/test evidence:** host `cargo test --features std,medical-experimental` = **672 passed / 0 failed** (was 669 pre-fix; +3 new tests); the real deployment artifacts all build clean on the actual target — `cargo build --target wasm32-unknown-unknown --release` (no_std/panic=abort default lib), `--bin ghost_hunter --no-default-features --features standalone-bin`, and `--features medical-experimental` (toolchain 1.89 per `rust-toolchain.toml`). No ADR slot needed — a single LOW defense-in-depth boundary fix; CHANGELOG attestation suffices.
|
- **`wifi-densepose-wasm-edge` beyond-SOTA closing review — boundary NaN-state-poisoning guard + clean-with-evidence attestation (ADR-040 edge crate, ~70 modules).** Closing pass of the security campaign over the last untouched sizeable crate. **One real finding fixed (LOW / source-analysis + reproduced):** the two WASM↔host frame boundaries (`lib.rs::on_frame`/`on_timer` and `bin/ghost_hunter.rs::on_frame`) read raw IEEE-754 `f32` from the `csi_get_phase`/`csi_get_amplitude`/`csi_get_variance`/`csi_get_motion_energy` host imports **without any finiteness check** — the entire crate had **zero** `is_finite`/`is_nan` guards, and the in-crate `clamp` helpers propagate NaN (`NaN < lo` and `NaN > hi` are both false). A single non-finite value (firmware DSP bug, uninitialised buffer, or hostile host) latches NaN into the long-lived per-module accumulators (EMA, Welford, phasor sums, anomaly baselines); once latched, every downstream comparison evaluates `false`, so detectors fail **degraded** (stuck gate state, silently-disabled anomaly checks) — silent corruption, not a crash (WASM `panic=abort` is *not* tripped: no indexing/`unwrap` on the poisoned value). Threat model is a **semi-trusted** boundary (the Tier-2 DSP firmware supplies the imports, not direct network/JS), hence LOW severity / defense-in-depth. **Fix:** added `sanitize_host_f32()` (maps non-finite→`0.0`, `core`-only so it holds in `no_std`) applied at every `host_get_*` float read — a single chokepoint covering all ~70 downstream modules, mirroring the existing M-01 negative-`n_subcarriers` boundary clamp. **Pinned by** `boundary_tests::{sanitize_passes_finite_values_through, sanitize_maps_non_finite_to_zero, coherence_monitor_nan_latches_without_sanitize_but_not_with}` — the last asserts on the *current* `CoherenceMonitor` that a raw NaN frame latches the smoothed score (documents the hazard) while the boundary-sanitized path stays finite. **Dimensions attested CLEAN with evidence (source-analysis):** (a) **panic-on-input** — every non-test `unwrap()`/`expect()` is either `#[cfg(test)]` or in the `std`-gated RVF *builder* host tool writing to an in-memory `Vec` (infallible); no `panic!`/`unreachable!`/`todo!`/`get_unchecked` in any hot path. (b) **shape/bounds** — all frame-buffer access is `min()`-clamped (`MAX_SC=32`, `DTW_MAX_LEN`, `LCS_WINDOW`, `PATTERN_LEN`), all index-by-cast sites (`feature_id as usize`, `conclusion_id`, `minute_counter`, `plan_step`) are either compile-time-const-bounded or `if idx <`/`%`-guarded; negative `n_subcarriers` already mapped to 0 (M-01). (c) **memory/leak** — no `move ||` closures, no `mem::forget`/`Box::leak`/`.leak()`; the only `Box::new` is in the `std`-gated `skill_registry` (one-time init, bounded). (d) **secrets** — none (grep clean). **MEASURED build/test evidence:** host `cargo test --features std,medical-experimental` = **672 passed / 0 failed** (was 669 pre-fix; +3 new tests); the real deployment artifacts all build clean on the actual target — `cargo build --target wasm32-unknown-unknown --release` (no_std/panic=abort default lib), `--bin ghost_hunter --no-default-features --features standalone-bin`, and `--features medical-experimental` (toolchain 1.89 per `rust-toolchain.toml`). No ADR slot needed — a single LOW defense-in-depth boundary fix; CHANGELOG attestation suffices.
|
||||||
@@ -36,7 +20,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **`homecore-recorder` security review (ADR-132 surfaces) — two real bounding fixes; SQL-injection & NaN-index dimensions confirmed clean with evidence.** Beyond-SOTA review of the HA-compat state recorder (DB persistence + history + ruvector semantic search), the crux being its DB-backed SQL-injection surface. **Findings + fixes:** (1) **Memory-DoS — unbounded `get_state_history`.** The history query carried no `LIMIT`, so a wide `[since, until]` window over a high-frequency entity (a per-second sensor ≈ 86k rows/day) would load an unbounded row set into a single in-memory `Vec`. Added a hard `LIMIT MAX_HISTORY_ROWS` (1,000,000 — generous enough never to truncate a realistic history graph, bounded enough to cap the worst case); the sibling search paths were already `k`-bounded. (2) **Disk-DoS / documented-but-missing `purge`.** The README + HA-compat table advertised `Recorder::purge(older_than)` as a capability, but **no such method existed** — i.e. no retention path at all → unbounded disk growth. Implemented a **transactional** `purge` that deletes `states` + `events` strictly **older than** the cutoff (**exclusive** boundary — idempotent, no off-by-one; a row at the cutoff instant is kept) and **garbage-collects** orphaned `state_attributes` blobs (a dedup-shared blob is dropped only once its last referencing state is gone); all three deletes run in one transaction so a mid-purge failure rolls back cleanly (no states-deleted-but-events-kept corruption). **Confirmed clean with evidence:** SQL injection — **every** query in `db.rs` uses bound `?` parameters (no `format!`/string-concat of user data into SQL); the lone `format!` builds the LIKE *pattern*, which is itself bound as a parameter with `ESCAPE '\\'` and metacharacter escaping. Pinned: a state value `'; DROP TABLE states; --` is stored/queried **literally** (table survives), and a `%`/`_` in a search query matches **literally**, not as a wildcard. NaN-index poisoning (the calibration/vitals/geo class) — **structurally impossible** here: embeddings are SHA-256 → `i32` → `f32` (an `i32` cast to `f32` is always finite, never NaN/Inf), with an all-zero-digest norm guard; probed empty-index search, empty-string query, and `k=0` — all return `Ok(0)`, **no panic**. Fail-closed write path — a removal event yields `Ok(None)`, semantic-index failure is logged not propagated (best-effort, never blocks the durable SQLite write), and `EntityId` parsing failures fall back rather than panic. **6 new pinning tests** (SQL-injection literal-storage, LIKE-metacharacter literalness, history `LIMIT`, purge exclusive-boundary, purge attribute-GC-keeps-shared, purge old-events): `homecore-recorder` **19 → 25** (`--no-default-features`) / **25 → 31** (`--features ruvector`), 0 failed; the purge-boundary test is a true pin (fails deleting 2 rows under an inclusive cutoff, passes deleting 1 under the exclusive cutoff). Behaviour otherwise unchanged; Python deterministic proof unchanged (recorder is off the signal proof path).
|
- **`homecore-recorder` security review (ADR-132 surfaces) — two real bounding fixes; SQL-injection & NaN-index dimensions confirmed clean with evidence.** Beyond-SOTA review of the HA-compat state recorder (DB persistence + history + ruvector semantic search), the crux being its DB-backed SQL-injection surface. **Findings + fixes:** (1) **Memory-DoS — unbounded `get_state_history`.** The history query carried no `LIMIT`, so a wide `[since, until]` window over a high-frequency entity (a per-second sensor ≈ 86k rows/day) would load an unbounded row set into a single in-memory `Vec`. Added a hard `LIMIT MAX_HISTORY_ROWS` (1,000,000 — generous enough never to truncate a realistic history graph, bounded enough to cap the worst case); the sibling search paths were already `k`-bounded. (2) **Disk-DoS / documented-but-missing `purge`.** The README + HA-compat table advertised `Recorder::purge(older_than)` as a capability, but **no such method existed** — i.e. no retention path at all → unbounded disk growth. Implemented a **transactional** `purge` that deletes `states` + `events` strictly **older than** the cutoff (**exclusive** boundary — idempotent, no off-by-one; a row at the cutoff instant is kept) and **garbage-collects** orphaned `state_attributes` blobs (a dedup-shared blob is dropped only once its last referencing state is gone); all three deletes run in one transaction so a mid-purge failure rolls back cleanly (no states-deleted-but-events-kept corruption). **Confirmed clean with evidence:** SQL injection — **every** query in `db.rs` uses bound `?` parameters (no `format!`/string-concat of user data into SQL); the lone `format!` builds the LIKE *pattern*, which is itself bound as a parameter with `ESCAPE '\\'` and metacharacter escaping. Pinned: a state value `'; DROP TABLE states; --` is stored/queried **literally** (table survives), and a `%`/`_` in a search query matches **literally**, not as a wildcard. NaN-index poisoning (the calibration/vitals/geo class) — **structurally impossible** here: embeddings are SHA-256 → `i32` → `f32` (an `i32` cast to `f32` is always finite, never NaN/Inf), with an all-zero-digest norm guard; probed empty-index search, empty-string query, and `k=0` — all return `Ok(0)`, **no panic**. Fail-closed write path — a removal event yields `Ok(None)`, semantic-index failure is logged not propagated (best-effort, never blocks the durable SQLite write), and `EntityId` parsing failures fall back rather than panic. **6 new pinning tests** (SQL-injection literal-storage, LIKE-metacharacter literalness, history `LIMIT`, purge exclusive-boundary, purge attribute-GC-keeps-shared, purge old-events): `homecore-recorder` **19 → 25** (`--no-default-features`) / **25 → 31** (`--features ruvector`), 0 failed; the purge-boundary test is a true pin (fails deleting 2 rows under an inclusive cutoff, passes deleting 1 under the exclusive cutoff). Behaviour otherwise unchanged; Python deterministic proof unchanged (recorder is off the signal proof path).
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **ADR-263/264/265: deep review of the RuView npm surface (`@ruvnet/ruview`, `@ruvnet/rvagent`, `@ruv/ruview-cli`) with optimization strategies recorded as ADRs.** ADR-263 reviews the published `@ruvnet/ruview@0.1.0` harness: fail-open `claim-check` on empty input (HIGH), `spawnSync` head-of-line blocking of the MCP stdio server during long `verify`/`calibrate` runs (HIGH), optionalDependencies tripling the cold `npx` install for a code path that never uses them (MEASURED, `npm i` in a clean prefix: 4 packages / 620 kB / 71 files default vs 1 package / 172 kB / 22 files with `--omit=optional`), 1 MiB `maxBuffer` truncation risk, `python -c` port-interpolation surface in `node_monitor`, hardcoded MCP server version, duplicated skill payload — optimizations O1–O8. ADR-264 reviews `@ruvnet/rvagent@0.1.0` + the private CLI **against the published registry tarball**: `exports.require` → nonexistent `dist/index.cjs` (HIGH, every CJS consumer breaks), 44 dead source-map files = 62,698 B of the 188 kB unpacked payload pointing at unshipped `../src` (MEASURED), stdio-only server described as "dual-transport" (CLAIMED capability), mixed dot/underscore tool naming, double Zod validation + hand-duplicated advertised schemas, 2-fd leak per training job, unbounded request body in the unwired HTTP scaffold, dead `detectCogBinary` candidate list, `ruview` bin-name collision — optimizations O1–O9. ADR-265 adds the cross-cutting distribution layer: an `npm-packages.yml` CI matrix (tests + pack-content/size gate + tarball-install smoke test — none of the three packages currently has any CI, and `ci.yml` pins Node 18 against `engines >= 20`), publish-from-CI-only with `npm publish --provenance`, version single-sourcing from package.json, bin/namespace ownership (the `ruview` bin belongs to `@ruvnet/ruview`), and claim-check enforcement on package READMEs/descriptions. Docs only — no runtime code changed; the findings are the work orders for the follow-up PRs.
|
|
||||||
- **ADR-131 §11–§12: HOMECORE-UI wired to a real backend — single-origin BFF gateway + production front-end (no mock in prod).** Implements the §11 wiring decision so the dashboard stops rendering fabricated data. **Front-end (DONE + verified under Node):** `api.js` rewritten so every data accessor is async and calls the §11.2 gateway routes; the in-browser mock is demoted to a **dev-only fixture** reachable only via `?demo=1`/`HOMECORE_UI_DEMO` (§2.2); all ten panels now `await` and render a **typed empty/error state** on upstream failure (no mock fallback in production) — 3 panels converted by hand, 7 via a parallel agent swarm. **New `homecore-server` BFF gateway (`src/gateway.rs`, compile-pending — no Rust toolchain in the authoring env):** promotes `homecore-server` to the single origin (§2.1); adds `/api/homecore/*` + `/api/cal/*` merged into `build_app`, with `reqwest` + CLI/env flags (`--calibration-url`/`--calibration-token`/`--apps-dir`/`--gateway-timeout-ms`). Real handlers: calibration **reverse-proxy** (W2), `GET /api/homecore/rooms` with the §11.3 **RoomState adapter** (`breathing`→`breathing_bpm`, `heartbeat`→`heart_bpm`, `None`→`null` preserving not-trained-vs-withheld, injected `anomaly.threshold`/`room_id`), **COG supervisor** over `/var/lib/cognitum/apps/` (W4), and **appliance metrics** from `/proc` + TCP service probes (W6); SEED-device/appliance routes (seeds/federation/witness/privacy/settings/automations/events-history/hailo/tokens — W3/W5) return a typed `503 upstream_unavailable` and the UI shows error states. **Tests:** front-end **5 files green** — import-graph, boot, render-smoke (22), interaction (3), and a **new prod-errors suite (13)** that runs with demo OFF + gateway unreachable and proves every panel renders an error state, never mock, never throws (it caught + fixed a real unhandled-rejection in the events automation builder). **Gateway compiled, tested, and run on Rust 1.89:** `cargo test -p homecore-server --no-default-features` = **12/12 pass** (6 gateway + 6 UI mount); the binary was **run live** — `GET /api/homecore/appliance` returns real `/proc` metrics + TCP service probes, unauth → `401`, `cogs` → `[]` (no apps dir), SEED-tier → typed `503`, and against a mock calibration upstream the `/api/cal/*` proxy passes through (`200`) and `GET /api/homecore/rooms` adapts `RoomState` to the UI shape (`breathing`→`breathing_bpm`, `heartbeat:null`→`heart_bpm:null`, injected `anomaly.threshold`/`room_id`). **Live testing caught + fixed a real bug** — a double-`v1` segment in the `/api/cal/*` proxy URL. **Remaining (intrinsic, not an env limit):** W3/W5/W6-Hailo/federation depend on services/hardware **not in this repo** (recorder/automation HTTP wrappers, real SEED nodes, Hailo stat source), so they return honest `503`s rather than fabricate data; W1/W2/W4/W6-appliance are functional now. ADR-131 §10/§12.1 updated with per-wave status.
|
- **ADR-131 §11–§12: HOMECORE-UI wired to a real backend — single-origin BFF gateway + production front-end (no mock in prod).** Implements the §11 wiring decision so the dashboard stops rendering fabricated data. **Front-end (DONE + verified under Node):** `api.js` rewritten so every data accessor is async and calls the §11.2 gateway routes; the in-browser mock is demoted to a **dev-only fixture** reachable only via `?demo=1`/`HOMECORE_UI_DEMO` (§2.2); all ten panels now `await` and render a **typed empty/error state** on upstream failure (no mock fallback in production) — 3 panels converted by hand, 7 via a parallel agent swarm. **New `homecore-server` BFF gateway (`src/gateway.rs`, compile-pending — no Rust toolchain in the authoring env):** promotes `homecore-server` to the single origin (§2.1); adds `/api/homecore/*` + `/api/cal/*` merged into `build_app`, with `reqwest` + CLI/env flags (`--calibration-url`/`--calibration-token`/`--apps-dir`/`--gateway-timeout-ms`). Real handlers: calibration **reverse-proxy** (W2), `GET /api/homecore/rooms` with the §11.3 **RoomState adapter** (`breathing`→`breathing_bpm`, `heartbeat`→`heart_bpm`, `None`→`null` preserving not-trained-vs-withheld, injected `anomaly.threshold`/`room_id`), **COG supervisor** over `/var/lib/cognitum/apps/` (W4), and **appliance metrics** from `/proc` + TCP service probes (W6); SEED-device/appliance routes (seeds/federation/witness/privacy/settings/automations/events-history/hailo/tokens — W3/W5) return a typed `503 upstream_unavailable` and the UI shows error states. **Tests:** front-end **5 files green** — import-graph, boot, render-smoke (22), interaction (3), and a **new prod-errors suite (13)** that runs with demo OFF + gateway unreachable and proves every panel renders an error state, never mock, never throws (it caught + fixed a real unhandled-rejection in the events automation builder). **Gateway compiled, tested, and run on Rust 1.89:** `cargo test -p homecore-server --no-default-features` = **12/12 pass** (6 gateway + 6 UI mount); the binary was **run live** — `GET /api/homecore/appliance` returns real `/proc` metrics + TCP service probes, unauth → `401`, `cogs` → `[]` (no apps dir), SEED-tier → typed `503`, and against a mock calibration upstream the `/api/cal/*` proxy passes through (`200`) and `GET /api/homecore/rooms` adapts `RoomState` to the UI shape (`breathing`→`breathing_bpm`, `heartbeat:null`→`heart_bpm:null`, injected `anomaly.threshold`/`room_id`). **Live testing caught + fixed a real bug** — a double-`v1` segment in the `/api/cal/*` proxy URL. **Remaining (intrinsic, not an env limit):** W3/W5/W6-Hailo/federation depend on services/hardware **not in this repo** (recorder/automation HTTP wrappers, real SEED nodes, Hailo stat source), so they return honest `503`s rather than fabricate data; W1/W2/W4/W6-appliance are functional now. ADR-131 §10/§12.1 updated with per-wave status.
|
||||||
- **ADR-131: HOMECORE-UI — the complete operational dashboard for the two-tier Cognitum stack, served by `homecore-server` at `/homecore`.** A zero-dependency, no-build-step vanilla TS/JS + CSS frontend (the `rufield-viewer` "Axum + vanilla-JS" pattern) that extends the Cognitum Appliance shell as a first-class nav section (Framework | Guide | Cog Store | **HOMECORE** | Status). **Complete, not a scaffold** (per the ADR's revised §2/§7): all **10 panels** ship fully built and rendered — §4.1 System Dashboard (v0 Appliance health strip + SEED fleet grid + ESP32 summary + COG status row + event-bus sparkline), §4.2 SEED Detail (vector store / witness chain / 5 onboard sensors / reflex rules / cognitive-fragility / ingest packet-type), §4.3 SEED Fleet Map (Appliance→SEED→ESP32 hierarchy, ESP-NOW mesh, cross-SEED fusion badges, ADR-105 federation), §4.4 Entity & State Browser (domain-grouped, **live WebSocket `subscribe_events` patching — never polls**, first-class provenance badges, keyword filter, context-causality slide-over), §4.5 RoomState/Sensing (mixture-of-specialists), §4.6 COG Management + App Registry, §4.7 Calibration Wizard (5-step baseline→enroll→train→verify), §4.8 Event Bus + Automation builder, §4.9 Witness/Audit log (two-tier SHA-256 + Ed25519 timeline, privacy-mode banner, pagination, export), §4.10 Settings. **Design system is the exact production Cognitum palette** (`tokens.css` carries `--cyan #4ecdc4` … `--r 10px` verbatim, §3.1) so there is no visual seam with the Cog Store (§3.3 invariant). **§6 UX invariants enforced in code and pinned by tests:** tier-origin provenance is always-visible (never collapsed); `stale`/`vetoed` flags and the kNN fragility score are prominent (amber/red tint + banners, never grey-on-grey); a `null` specialist renders "Not trained / calibrate to enable" **visually distinct from** veto-`withheld` (rendered as explicitly withheld, never zero) **distinct from** an error; all IDs/hashes/endpoints/payloads use `--mono`; Hailo-sourced COGs (`arch: hailo10`) are visually distinguished from CPU-only (`arch: arm`). **Wiring:** `homecore-server` gains a `--ui-dir`/`HOMECORE_UI_DIR` flag and mounts the assets via `tower-http` `ServeDir` at `/homecore` alongside the unchanged HA-compat `/api` surface (new testable `build_app()`), with **5 Rust integration tests** (`#[cfg(test)] mod ui_tests`, `tower::oneshot`) asserting index / design tokens / all-10-panels are served, the API coexists, and an empty `--ui-dir` disables the mount. **JS test + benchmark suite (`ui/`, runs under plain `node`, no npm install): 24 checks / 0 failed** — an import/export graph verifier (15 modules consistent), a DOM-shim render-smoke that *executes every panel* (21 checks: ui helpers + mock contracts + all 10 panels render without throwing), and an interaction suite (3 checks: live WS state-patch, ws.js handshake/parse, calibration backend contract). **Benchmark:** total bundle **136.8 KB uncompressed across 18 files — ~37× smaller than HA's ~5 MB Lit bundle** (the ADR-126 §1.1 foil), slowest panel **1.5 ms/cold-render**. **Honest scope (§7.1):** the live HOMECORE REST API (`/api/config|states|services`) and the WebSocket `subscribe_events` feed are driven for real; panels whose backing service is **not** in this binary (SEED HTTPS API, calibration ADR-151, ADR-105 federation) render against a **contract-conformant mock layer flagged with a DEMO banner** and swap to live the moment those endpoints land — no mock data is ever presented as real. **Not verified in this environment:** the Rust crate was edited and the integration tests written but **not compiled/run here** (no Rust toolchain present); `cargo test -p homecore-server` + `cargo build` must be run on a Rust host before merge.
|
- **ADR-131: HOMECORE-UI — the complete operational dashboard for the two-tier Cognitum stack, served by `homecore-server` at `/homecore`.** A zero-dependency, no-build-step vanilla TS/JS + CSS frontend (the `rufield-viewer` "Axum + vanilla-JS" pattern) that extends the Cognitum Appliance shell as a first-class nav section (Framework | Guide | Cog Store | **HOMECORE** | Status). **Complete, not a scaffold** (per the ADR's revised §2/§7): all **10 panels** ship fully built and rendered — §4.1 System Dashboard (v0 Appliance health strip + SEED fleet grid + ESP32 summary + COG status row + event-bus sparkline), §4.2 SEED Detail (vector store / witness chain / 5 onboard sensors / reflex rules / cognitive-fragility / ingest packet-type), §4.3 SEED Fleet Map (Appliance→SEED→ESP32 hierarchy, ESP-NOW mesh, cross-SEED fusion badges, ADR-105 federation), §4.4 Entity & State Browser (domain-grouped, **live WebSocket `subscribe_events` patching — never polls**, first-class provenance badges, keyword filter, context-causality slide-over), §4.5 RoomState/Sensing (mixture-of-specialists), §4.6 COG Management + App Registry, §4.7 Calibration Wizard (5-step baseline→enroll→train→verify), §4.8 Event Bus + Automation builder, §4.9 Witness/Audit log (two-tier SHA-256 + Ed25519 timeline, privacy-mode banner, pagination, export), §4.10 Settings. **Design system is the exact production Cognitum palette** (`tokens.css` carries `--cyan #4ecdc4` … `--r 10px` verbatim, §3.1) so there is no visual seam with the Cog Store (§3.3 invariant). **§6 UX invariants enforced in code and pinned by tests:** tier-origin provenance is always-visible (never collapsed); `stale`/`vetoed` flags and the kNN fragility score are prominent (amber/red tint + banners, never grey-on-grey); a `null` specialist renders "Not trained / calibrate to enable" **visually distinct from** veto-`withheld` (rendered as explicitly withheld, never zero) **distinct from** an error; all IDs/hashes/endpoints/payloads use `--mono`; Hailo-sourced COGs (`arch: hailo10`) are visually distinguished from CPU-only (`arch: arm`). **Wiring:** `homecore-server` gains a `--ui-dir`/`HOMECORE_UI_DIR` flag and mounts the assets via `tower-http` `ServeDir` at `/homecore` alongside the unchanged HA-compat `/api` surface (new testable `build_app()`), with **5 Rust integration tests** (`#[cfg(test)] mod ui_tests`, `tower::oneshot`) asserting index / design tokens / all-10-panels are served, the API coexists, and an empty `--ui-dir` disables the mount. **JS test + benchmark suite (`ui/`, runs under plain `node`, no npm install): 24 checks / 0 failed** — an import/export graph verifier (15 modules consistent), a DOM-shim render-smoke that *executes every panel* (21 checks: ui helpers + mock contracts + all 10 panels render without throwing), and an interaction suite (3 checks: live WS state-patch, ws.js handshake/parse, calibration backend contract). **Benchmark:** total bundle **136.8 KB uncompressed across 18 files — ~37× smaller than HA's ~5 MB Lit bundle** (the ADR-126 §1.1 foil), slowest panel **1.5 ms/cold-render**. **Honest scope (§7.1):** the live HOMECORE REST API (`/api/config|states|services`) and the WebSocket `subscribe_events` feed are driven for real; panels whose backing service is **not** in this binary (SEED HTTPS API, calibration ADR-151, ADR-105 federation) render against a **contract-conformant mock layer flagged with a DEMO banner** and swap to live the moment those endpoints land — no mock data is ever presented as real. **Not verified in this environment:** the Rust crate was edited and the integration tests written but **not compiled/run here** (no Rust toolchain present); `cargo test -p homecore-server` + `cargo build` must be run on a Rust host before merge.
|
||||||
- **ADR-175: int8 quantization of the WiFlow-STD "half" pose model — MEASURED fp32-vs-int8 accuracy/size trade-off (honest negative).** Sub-deliverable 8.2 of the benchmark/optimization milestone, and the reading of the SOTA brief's "one untested edge lever" (QAT-int8 on the 843,834-param half model that strictly dominates the published 2.23M model). A new committed script `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py` quantizes `half_best.pth` to int8 two ways and scores both with the **same** upstream `calculate_pck`/`calculate_mpjpe` that produced the fp32 sweep numbers, under **one locked normalization** (ADR-173 torso-diameter PCK — neck idx2→pelvis idx12, `use_torso_norm=True`, the standard MM-Fi/GraphPose-Fi convention), on the **same** seed-42 file-level 70/15/15 test split (52,560 NaN-free / 54,000 full windows). **MEASURED on ruvultra (RTX 5080, torch 2.11.0+cu128, fbgemm; clean test, torso-PCK):** fp32 = 96.62% PCK@20 / 99.47% PCK@50 / 0.008981 MPJPE / 3.351 MB (fp32-CPU reproduces fp32-GPU to 4 dp, so the int8 deltas are pure quantization, not CPU/GPU drift); **int8 static PTQ = 40.98% PCK@20 (−55.64 pp), 1.046 MB** — naive static QDQ **collapses** on this model (the brief's 2.23M "sweet spot" does NOT transfer to the 843k half model at the tight @20 threshold); **int8 QAT (3-epoch FX fake-quant fine-tune from half_best) = 67.48% PCK@20 (−29.15 pp) / 98.69% PCK@50 (−0.78 pp), 1.043 MB.** **Verdict (honest no):** int8 is **not a win** at the strict PCK@20 edge target — QAT recovers a large share of the PTQ collapse and is near-lossless at the loose PCK@50 (coarse localization survives int8, fine does not), but a **3.2× size win at −29 pp PCK@20** is a bad trade when the half model already fits edge flash at fp32 → **keep fp32/fp16 on the edge for now.** **Disclosed gap:** the QAT *fake-quant* val PCK@20 reached 83.45% but the *converted* int8 model scores 67.48% — a real ~16 pp `convert_fx` gap (fbgemm int8 kernels ≠ straight-through estimate, esp. the axial-attention einsum/softmax); we report the converted-int8 number, not the fake-quant proxy. **MEASURED:** every table number + the PTQ collapse + the QAT partial recovery + the conversion gap. **CLAIMED/not done:** ONNX/TFLite export, on-edge-SoC latency/energy (int8 measured on x86 fbgemm — size transfers, latency does NOT), mixed-precision keeping attention fp32, longer/better-tuned QAT. **Honest limitations:** single in-domain eval split (no cross-environment split), x86-int8 not edge-SoC-int8, lightly-tuned QAT. Additive only — no production Rust or signal-pipeline change; Python deterministic proof unchanged (`f8e76f21…46f7a`, bit-exact — off the signal proof path).
|
- **ADR-175: int8 quantization of the WiFlow-STD "half" pose model — MEASURED fp32-vs-int8 accuracy/size trade-off (honest negative).** Sub-deliverable 8.2 of the benchmark/optimization milestone, and the reading of the SOTA brief's "one untested edge lever" (QAT-int8 on the 843,834-param half model that strictly dominates the published 2.23M model). A new committed script `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py` quantizes `half_best.pth` to int8 two ways and scores both with the **same** upstream `calculate_pck`/`calculate_mpjpe` that produced the fp32 sweep numbers, under **one locked normalization** (ADR-173 torso-diameter PCK — neck idx2→pelvis idx12, `use_torso_norm=True`, the standard MM-Fi/GraphPose-Fi convention), on the **same** seed-42 file-level 70/15/15 test split (52,560 NaN-free / 54,000 full windows). **MEASURED on ruvultra (RTX 5080, torch 2.11.0+cu128, fbgemm; clean test, torso-PCK):** fp32 = 96.62% PCK@20 / 99.47% PCK@50 / 0.008981 MPJPE / 3.351 MB (fp32-CPU reproduces fp32-GPU to 4 dp, so the int8 deltas are pure quantization, not CPU/GPU drift); **int8 static PTQ = 40.98% PCK@20 (−55.64 pp), 1.046 MB** — naive static QDQ **collapses** on this model (the brief's 2.23M "sweet spot" does NOT transfer to the 843k half model at the tight @20 threshold); **int8 QAT (3-epoch FX fake-quant fine-tune from half_best) = 67.48% PCK@20 (−29.15 pp) / 98.69% PCK@50 (−0.78 pp), 1.043 MB.** **Verdict (honest no):** int8 is **not a win** at the strict PCK@20 edge target — QAT recovers a large share of the PTQ collapse and is near-lossless at the loose PCK@50 (coarse localization survives int8, fine does not), but a **3.2× size win at −29 pp PCK@20** is a bad trade when the half model already fits edge flash at fp32 → **keep fp32/fp16 on the edge for now.** **Disclosed gap:** the QAT *fake-quant* val PCK@20 reached 83.45% but the *converted* int8 model scores 67.48% — a real ~16 pp `convert_fx` gap (fbgemm int8 kernels ≠ straight-through estimate, esp. the axial-attention einsum/softmax); we report the converted-int8 number, not the fake-quant proxy. **MEASURED:** every table number + the PTQ collapse + the QAT partial recovery + the conversion gap. **CLAIMED/not done:** ONNX/TFLite export, on-edge-SoC latency/energy (int8 measured on x86 fbgemm — size transfers, latency does NOT), mixed-precision keeping attention fp32, longer/better-tuned QAT. **Honest limitations:** single in-domain eval split (no cross-environment split), x86-int8 not edge-SoC-int8, lightly-tuned QAT. Additive only — no production Rust or signal-pipeline change; Python deterministic proof unchanged (`f8e76f21…46f7a`, bit-exact — off the signal proof path).
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
|||||||
| `wifi-densepose-mat` | Mass Casualty Assessment Tool — disaster survivor detection |
|
| `wifi-densepose-mat` | Mass Casualty Assessment Tool — disaster survivor detection |
|
||||||
| `wifi-densepose-hardware` | ESP32 aggregator, TDM protocol, channel hopping firmware; `ieee80211bf/` 802.11bf forward-compat protocol model (ADR-153) |
|
| `wifi-densepose-hardware` | ESP32 aggregator, TDM protocol, channel hopping firmware; `ieee80211bf/` 802.11bf forward-compat protocol model (ADR-153) |
|
||||||
| `wifi-densepose-ruvector` | RuVector v2.0.4 integration + cross-viewpoint fusion (5 modules) |
|
| `wifi-densepose-ruvector` | RuVector v2.0.4 integration + cross-viewpoint fusion (5 modules) |
|
||||||
| `wifi-densepose-posecode` | ADR-266 multi-actor semantic motion scenes, PoseTrack adapter, bounded parser, confidence-aware phase segmentation |
|
|
||||||
| `wifi-densepose-wasm` | WebAssembly bindings for browser deployment |
|
| `wifi-densepose-wasm` | WebAssembly bindings for browser deployment |
|
||||||
| `wifi-densepose-cli` | CLI tool (`wifi-densepose` binary) — `calibrate`/`calibrate-serve`/`enroll`/`train-room`/`room-watch` + MAT (MAT gated behind the `mat` feature; build `--no-default-features` for the aarch64/appliance calibration binary) |
|
| `wifi-densepose-cli` | CLI tool (`wifi-densepose` binary) — `calibrate`/`calibrate-serve`/`enroll`/`train-room`/`room-watch` + MAT (MAT gated behind the `mat` feature; build `--no-default-features` for the aarch64/appliance calibration binary) |
|
||||||
| `wifi-densepose-calibration` | ADR-151 per-room calibration & specialist training — `baseline → enroll → extract → train` → bank of small specialists (presence/posture/breathing/heartbeat/restlessness/anomaly) + multistatic fusion; pure Rust, edge-deployable |
|
| `wifi-densepose-calibration` | ADR-151 per-room calibration & specialist training — `baseline → enroll → extract → train` → bank of small specialists (presence/posture/breathing/heartbeat/restlessness/anomaly) + multistatic fusion; pure Rust, edge-deployable |
|
||||||
@@ -63,7 +62,7 @@ All 5 ruvector crates integrated in workspace:
|
|||||||
- `ruvector-attention` → `model.rs` (apply_spatial_attention) + `bvp.rs`
|
- `ruvector-attention` → `model.rs` (apply_spatial_attention) + `bvp.rs`
|
||||||
|
|
||||||
### Architecture Decisions
|
### Architecture Decisions
|
||||||
182 ADRs in `docs/adr/` (numbered ADR-001 through ADR-265, with gaps). Key ones:
|
43 ADRs in `docs/adr/` (ADR-001 through ADR-043). Key ones:
|
||||||
- ADR-014: SOTA signal processing (Accepted)
|
- ADR-014: SOTA signal processing (Accepted)
|
||||||
- ADR-015: MM-Fi + Wi-Pose training datasets (Accepted)
|
- ADR-015: MM-Fi + Wi-Pose training datasets (Accepted)
|
||||||
- ADR-016: RuVector training pipeline integration (Accepted — complete)
|
- ADR-016: RuVector training pipeline integration (Accepted — complete)
|
||||||
@@ -78,11 +77,6 @@ All 5 ruvector crates integrated in workspace:
|
|||||||
- ADR-148: Drone swarm control system / `ruview-swarm` (In Progress)
|
- ADR-148: Drone swarm control system / `ruview-swarm` (In Progress)
|
||||||
- ADR-152: WiFi-Pose SOTA 2026 intake — geometry conditioning, WiFlow-STD benchmark (measurement (a) complete: claims MEASURED-EQUIVALENT at ~96% PCK@20), MAE recipe (Proposed; §2.1–2.3, 2.6 implemented)
|
- ADR-152: WiFi-Pose SOTA 2026 intake — geometry conditioning, WiFlow-STD benchmark (measurement (a) complete: claims MEASURED-EQUIVALENT at ~96% PCK@20), MAE recipe (Proposed; §2.1–2.3, 2.6 implemented)
|
||||||
- ADR-153: IEEE 802.11bf-2025 forward-compatibility protocol model (Accepted — amends ADR-152 §2.4)
|
- ADR-153: IEEE 802.11bf-2025 forward-compatibility protocol model (Accepted — amends ADR-152 §2.4)
|
||||||
- ADR-182: `npx ruview` harness minted via MetaHarness (Accepted — P1+P2 shipped as `@ruvnet/ruview`)
|
|
||||||
- ADR-263: `@ruvnet/ruview` npm harness deep review + optimization strategy (Proposed)
|
|
||||||
- ADR-264: `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` deep review + optimization strategy (Proposed)
|
|
||||||
- ADR-265: RuView npm distribution strategy — CI gate, provenance, version single-sourcing (Proposed)
|
|
||||||
- ADR-266: Multi-actor PoseCode scenes from persistent RuView tracks (Accepted)
|
|
||||||
|
|
||||||
### Supported Hardware
|
### Supported Hardware
|
||||||
|
|
||||||
|
|||||||
@@ -51,26 +51,26 @@ verify-audit:
|
|||||||
|
|
||||||
# ─── Rust Builds ─────────────────────────────────────────────
|
# ─── Rust Builds ─────────────────────────────────────────────
|
||||||
build-rust:
|
build-rust:
|
||||||
cd v2 && cargo build --release
|
cd rust-port/wifi-densepose-rs && cargo build --release
|
||||||
|
|
||||||
build-wasm:
|
build-wasm:
|
||||||
cd v2 && wasm-pack build crates/wifi-densepose-wasm --target web --release
|
cd rust-port/wifi-densepose-rs && wasm-pack build crates/wifi-densepose-wasm --target web --release
|
||||||
|
|
||||||
build-wasm-mat:
|
build-wasm-mat:
|
||||||
cd v2 && wasm-pack build crates/wifi-densepose-wasm --target web --release -- --features mat
|
cd rust-port/wifi-densepose-rs && wasm-pack build crates/wifi-densepose-wasm --target web --release -- --features mat
|
||||||
|
|
||||||
test-rust:
|
test-rust:
|
||||||
cd v2 && cargo test --workspace --no-default-features
|
cd rust-port/wifi-densepose-rs && cargo test --workspace
|
||||||
|
|
||||||
bench:
|
bench:
|
||||||
cd v2 && cargo bench --package wifi-densepose-signal
|
cd rust-port/wifi-densepose-rs && cargo bench --package wifi-densepose-signal
|
||||||
|
|
||||||
# ─── Run ─────────────────────────────────────────────────────
|
# ─── Run ─────────────────────────────────────────────────────
|
||||||
run-api:
|
run-api:
|
||||||
uvicorn archive.v1.src.api.main:app --host 0.0.0.0 --port 8000
|
uvicorn v1.src.api.main:app --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
run-api-dev:
|
run-api-dev:
|
||||||
uvicorn archive.v1.src.api.main:app --host 0.0.0.0 --port 8000 --reload
|
uvicorn v1.src.api.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
run-viz:
|
run-viz:
|
||||||
python3 -m http.server 3000 --directory ui
|
python3 -m http.server 3000 --directory ui
|
||||||
@@ -81,7 +81,7 @@ run-docker:
|
|||||||
# ─── Clean ───────────────────────────────────────────────────
|
# ─── Clean ───────────────────────────────────────────────────
|
||||||
clean:
|
clean:
|
||||||
rm -f .install.log
|
rm -f .install.log
|
||||||
cd v2 && cargo clean 2>/dev/null || true
|
cd rust-port/wifi-densepose-rs && cargo clean 2>/dev/null || true
|
||||||
|
|
||||||
# ─── Help ────────────────────────────────────────────────────
|
# ─── Help ────────────────────────────────────────────────────
|
||||||
help:
|
help:
|
||||||
|
|||||||
@@ -601,8 +601,6 @@ 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).
|
Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full details: [`plugins/ruview/README.md`](plugins/ruview/README.md).
|
||||||
|
|
||||||
**Portable harness — `npx @ruvnet/ruview`:** a lighter, host-portable companion to the in-repo plugin, minted via [MetaHarness](https://www.npmjs.com/package/metaharness) and hardened per [ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md). It runs **without cloning this repo** and on more hosts (Claude Code, Codex, Copilot, opencode, …), exposing the RuView operator tools (`onboard`, `verify`, `node_monitor`, `calibrate`, `node_flash`) over an MCP server — plus the project's **MEASURED-vs-CLAIMED honesty guardrail enforced in code** (`ruview.claim_check` flags untagged or retracted-"100%" accuracy claims). v0.1: the onboarding/verify/claim-check paths are tested (17/17, `verify.py` → PASS); the hardware tools are fail-closed wrappers. Try `npx @ruvnet/ruview` to onboard, or `npx @ruvnet/ruview claim-check --text "…"`. Source: [`harness/ruview/`](harness/ruview/README.md).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📖 Documentation
|
## 📖 Documentation
|
||||||
@@ -616,9 +614,7 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
|
|||||||
| [**SENSE-BRIDGE — rvagent MCP server**](tools/ruview-mcp/README.md) | Dual-transport MCP server (`@ruvnet/rvagent`) bridging the RuView sensing stack to AI agents (Claude Code, Cursor, ruflo swarms). 6 tools wired: `ruview.presence.now`, `ruview.vitals.get_{breathing,heart_rate,all}`, `ruview.bfld.last_scan`, `ruview.bfld.subscribe`. stdio + Streamable HTTP (`POST /mcp`, Origin-validated, bearer-token auth, `127.0.0.1` bind). Full 20-tool Zod schema barrel + 5 RUVIEW-POLICY governance tools. 93 tests. [ADR-124](docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md). Try: `npx @ruvnet/rvagent stdio`. |
|
| [**SENSE-BRIDGE — rvagent MCP server**](tools/ruview-mcp/README.md) | Dual-transport MCP server (`@ruvnet/rvagent`) bridging the RuView sensing stack to AI agents (Claude Code, Cursor, ruflo swarms). 6 tools wired: `ruview.presence.now`, `ruview.vitals.get_{breathing,heart_rate,all}`, `ruview.bfld.last_scan`, `ruview.bfld.subscribe`. stdio + Streamable HTTP (`POST /mcp`, Origin-validated, bearer-token auth, `127.0.0.1` bind). Full 20-tool Zod schema barrel + 5 RUVIEW-POLICY governance tools. 93 tests. [ADR-124](docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md). Try: `npx @ruvnet/rvagent stdio`. |
|
||||||
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
|
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
|
||||||
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
|
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
|
||||||
| [Portable harness — `npx @ruvnet/ruview`](harness/ruview/README.md) | MetaHarness-minted, host-portable RuView operator harness — `ruview.*` MCP tools + the MEASURED-vs-CLAIMED honesty guardrail enforced in code ([ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md)). A lighter, multi-host companion to the in-repo plugin. |
|
| [Architecture Decisions](docs/adr/README.md) | 96 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||||
| [Architecture Decisions](docs/adr/README.md) | 183 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
|
||||||
| [Multi-actor PoseCode](v2/crates/wifi-densepose-posecode/) | Rust scene protocol converting persistent RuView pose tracks into confidence-scored actors, synchronized motion phases, contacts and deterministic text. [ADR-266](docs/adr/ADR-266-multi-actor-posecode-scenes.md). |
|
|
||||||
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
|
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
|
||||||
| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. |
|
| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. |
|
||||||
| [Desktop App](v2/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization |
|
| [Desktop App](v2/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization |
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,272 @@
|
|||||||
|
# ADR-180: Through-Wall Camera↔CSI Hand-off Demo ("Behind the Wall")
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| **Status** | Proposed |
|
||||||
|
| **Date** | 2026-06-15 |
|
||||||
|
| **Deciders** | ruv |
|
||||||
|
| **Codename** | **BEHIND-THE-WALL** |
|
||||||
|
| **Builds on** | ADR-079 (camera ground-truth training), ADR-031 (sensing-first RF mode), ADR-134 (CSI→CIR multipath), ADR-029/030 (RuvSense multistatic + persistent field), ADR-024 (AETHER re-ID), ADR-151 (per-room calibration), ADR-173 (metric-locked PCK), ADR-095/096 (rvcsi nexmon) |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
### The demo we want
|
||||||
|
A single self-contained **HTML page** that tells one honest, visceral story:
|
||||||
|
|
||||||
|
1. You stand in front of the laptop. The camera tracks your **full skeletal pose**;
|
||||||
|
the WiFi-CSI model, trained on *your* movements moments earlier, infers the **same
|
||||||
|
skeleton** in parallel — a side-by-side "camera vs RF agree" view.
|
||||||
|
2. You **walk out the door and behind the wall**. The camera **goes blind** (you are
|
||||||
|
occluded — it honestly shows "no person in frame"). The CSI model **keeps inferring
|
||||||
|
your skeleton** from the WiFi signal alone — the 3D figure keeps walking, behind the
|
||||||
|
wall, smoothly. A badge flips from `CAMERA` to `RF-INFERRED (through-wall)`.
|
||||||
|
3. You **walk back into view**. The camera **re-acquires**; the badge flips back to
|
||||||
|
`CAMERA`, and the RF-inferred and camera skeletons reconverge.
|
||||||
|
|
||||||
|
This is the "WiFi sees through walls" demo — and the user explicitly wants the **inferred
|
||||||
|
skeleton through the wall**, not just a blob. The project's "prove everything / no AI-slop"
|
||||||
|
bar means we make that claim **only because we measure it**: a second camera on the far side
|
||||||
|
of the wall records ground-truth pose *behind* the wall, so the through-wall skeleton's
|
||||||
|
accuracy is a **reported, reproducible number** — never an unfalsifiable "trust me."
|
||||||
|
|
||||||
|
### Honest capability framing (the load-bearing section)
|
||||||
|
Through-wall **per-joint skeletal inference from WiFi CSI is not a generally-validated
|
||||||
|
capability** in open settings — WiFi-DensePose (CMU) is camera-*co-located*. What makes it
|
||||||
|
defensible *here* is the tightly-controlled regime and the measurement:
|
||||||
|
|
||||||
|
- **Controlled regime:** one room, one subject (you), one doorway, a model **camera-supervised
|
||||||
|
on your exact gait and your exact through-door transition** (ADR-079) minutes earlier. This
|
||||||
|
is in-distribution for *this* demo, not a universal claim.
|
||||||
|
- **Measured, not asserted:** a far-side camera (cognitum-v0 has 17 `/dev/video*` nodes — use
|
||||||
|
one, or a phone) records ground-truth pose behind the wall. The through-wall CSI skeleton is
|
||||||
|
scored against it with the metric-locked PCK harness (ADR-173). **We publish the number.**
|
||||||
|
- **Uncertainty is rendered, not hidden:** the through-wall skeleton is drawn **translucent**,
|
||||||
|
with a live **per-joint confidence** and an explicit `RF-INFERRED` badge. High-confidence
|
||||||
|
joints render solid; low-confidence joints fade. It never masquerades as the camera's
|
||||||
|
ground-truth pose.
|
||||||
|
|
||||||
|
| While… | Camera | WiFi CSI (S3 / Pi5 nexmon, fused) | 60 GHz mmWave (C6 + MR60BHA2) |
|
||||||
|
|--------|--------|-----------------------------------|-------------------------------|
|
||||||
|
| In frame | **Full 17-kpt pose** — ground truth | full skeleton (supervised model) — *agrees with camera* | presence + range + micro-motion |
|
||||||
|
| Behind a **drywall** | nothing (occluded) | **inferred full skeleton** (camera-supervised model + multistatic fusion), confidence-scored, **measured vs far-side camera** | presence + range + breathing — independent through-thin-wall confirm |
|
||||||
|
| Behind **brick/metal** | nothing | degrades to coarse motion/position only — report honestly | blocked |
|
||||||
|
|
||||||
|
**The claim — stated precisely:** *"A WiFi-CSI model, camera-supervised on this subject and
|
||||||
|
room, infers a continuous skeletal pose that tracks the subject through a drywall partition;
|
||||||
|
through-wall accuracy is measured at X% PCK@k against a far-side camera (declared, not
|
||||||
|
claimed)."* If X turns out low, that is the **honest result we report** — the skeleton is still
|
||||||
|
rendered (the user wants it) but flagged with its true confidence, and the headline number is
|
||||||
|
whatever we measured, good or bad.
|
||||||
|
|
||||||
|
### Why multistatic + supervision is the enabler
|
||||||
|
A single node behind a wall sees only "something moved." Three spatially-diverse vantage points
|
||||||
|
around the doorway (RuvSense multistatic + cross-viewpoint fusion, ADR-029/030) triangulate the
|
||||||
|
moving scatterer — drywall attenuates and diffracts 2.4/5 GHz but does not block it — giving the
|
||||||
|
model a rich enough multipath signature to regress a skeleton it was *trained* to associate with
|
||||||
|
your through-door motion. AETHER re-ID embeddings (ADR-024) keep it locked to **you** across the
|
||||||
|
camera→RF→camera hand-off.
|
||||||
|
|
||||||
|
### Available hardware (the user's actual rig)
|
||||||
|
| Role | Device | Where | Stream |
|
||||||
|
|------|--------|-------|--------|
|
||||||
|
| Near ground truth (visible) | Laptop / USB camera | front of workstation (ruvzen) | MediaPipe pose → keypoints |
|
||||||
|
| **Far ground truth (validation)** | cognitum-v0 camera (1 of 17 `/dev/video*`) or a phone | **behind the wall** | MediaPipe pose → keypoints (for MEASURING the through-wall skeleton) |
|
||||||
|
| CSI node A | ESP32-S3 (8 MB) | COM9 (ruvzen) | UDP CSI :5005 |
|
||||||
|
| CSI + mmWave node B | ESP32-C6 + Seeed MR60BHA2 | COM12 (ruvzen) | WiFi CSI + 60 GHz FMCW presence/range |
|
||||||
|
| CSI node C (through-wall vantage) | Pi 5, BCM43455c0 | cognitum-v0 (other room) | nexmon_csi `.pcap` → rvcsi → CsiFrame |
|
||||||
|
| Fusion + serving | sensing-server | ruvzen :3000/:8765 | `/ws/sensing`, `/ws/pose`, new `/ws/handoff` |
|
||||||
|
|
||||||
|
Place **node C (Pi 5) and the far camera on the far side of the wall** — the Pi 5 gives the
|
||||||
|
fuser a vantage the camera lacks, and the far camera turns the through-wall claim into a
|
||||||
|
measurement.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Build a **camera↔CSI hand-off demo** as a thin, additive layer over existing components (no new
|
||||||
|
heavy crate). Five parts: a multi-source capture plane, a camera-supervised calibration walk
|
||||||
|
that **learns to infer the skeleton through the wall**, a **hand-off state machine**, a
|
||||||
|
**dead-reckoning smoother** so dropped CSI never makes the figure jump, and a single-file HTML
|
||||||
|
viewer that renders the inferred skeleton with honest confidence.
|
||||||
|
|
||||||
|
### 1. Capture plane (reuse, don't rebuild)
|
||||||
|
- **Near camera:** `scripts/collect-ground-truth.py` already does MediaPipe pose + ESP32 CSI
|
||||||
|
paired capture (ADR-079). Extend it to also subscribe to the Pi 5 nexmon stream (rvcsi), the
|
||||||
|
C6 mmWave presence, **and the far camera**, so every frame is
|
||||||
|
`(near_pose|null, far_pose|null, csi_S3, csi_C6, mmwave_C6, csi_Pi5, t)`.
|
||||||
|
- **CSI nodes:** S3 over UDP :5005, Pi 5 via `rvcsi` (vendor/rvcsi nexmon adapter → `CsiFrame`),
|
||||||
|
C6 WiFi CSI + the MR60BHA2 60 GHz presence/range/breathing.
|
||||||
|
- **Fusion:** all CSI sources into the existing `MultistaticFuser`
|
||||||
|
(`signal/src/ruvsense/multistatic.rs`); node positions around the doorway via
|
||||||
|
`--node-positions` (geometric-diversity index drives confidence). **#1049:** with 3
|
||||||
|
independently-clocked nodes set `WDP_GUARD_INTERVAL_US` to the real inter-node spread or
|
||||||
|
fusion demotes.
|
||||||
|
|
||||||
|
### 2. Calibration walk — "it learns my movements **and infers them through the wall**" (ADR-079)
|
||||||
|
A 3–5 minute guided routine. The HTML page scripts the walk: stand, step left/right, walk to the
|
||||||
|
door, **cross fully behind the wall and back**, repeat — covering the visible AND the occluded
|
||||||
|
zone, because **both cameras label ground truth**:
|
||||||
|
- **Visible-zone supervision:** near camera labels pose; synchronized CSI window is the input.
|
||||||
|
- **Through-wall supervision (the key part):** while you are behind the wall, the **far camera**
|
||||||
|
labels your pose. So the CSI→skeleton model is trained on *real behind-wall poses* paired with
|
||||||
|
the *behind-wall multistatic CSI* — the model genuinely learns to infer your skeleton through
|
||||||
|
the wall, supervised by ground truth, not extrapolated blindly.
|
||||||
|
- Train/fine-tune on `ruvultra` (RTX 5080) if available, else the local recipe. Persist as a
|
||||||
|
per-room calibration bank (ADR-151 `baseline → enroll → extract → train`). AETHER re-ID
|
||||||
|
embeddings (ADR-024) bind the track to you across the hand-off.
|
||||||
|
- **Held-out split:** reserve some behind-wall passes for evaluation so through-wall PCK is
|
||||||
|
measured on data the model never trained on (no leakage — the ADR-152 measurement discipline).
|
||||||
|
|
||||||
|
### 3. Hand-off state machine (`sensing-server/src/handoff.rs`, < 300 lines)
|
||||||
|
States: `CAMERA` → `HANDOFF_OUT` → `RF_INFERRED` → `HANDOFF_IN` → `CAMERA` (+ `LOST`).
|
||||||
|
- **`CAMERA`** — near camera has a confident pose → render it; RF-inferred skeleton ghosted
|
||||||
|
alongside for the "they agree" effect.
|
||||||
|
- **`HANDOFF_OUT`** — near-camera confidence drops at the doorway **while** CSI motion stays high
|
||||||
|
and the multistatic track heads into the door zone → cross-fade source camera→RF.
|
||||||
|
- **`RF_INFERRED`** — no camera pose; the CSI model emits a **full 17-kpt skeleton** + per-joint
|
||||||
|
confidence; AETHER confirms it is still you. Render the translucent skeleton + confidence,
|
||||||
|
badge `RF-INFERRED (through-wall)`. (When fusion confidence is too low for a credible skeleton,
|
||||||
|
degrade gracefully to a coarse marker rather than a flailing one — honest fallback.)
|
||||||
|
- **`HANDOFF_IN`** — near camera re-acquires a pose positionally consistent with the last RF
|
||||||
|
skeleton (continuity gate) → cross-fade RF→camera.
|
||||||
|
- **`LOST`** — neither source for N cycles → "no track," never invented.
|
||||||
|
|
||||||
|
Fail-closed: `RF_INFERRED` requires real multistatic motion energy + an AETHER identity match
|
||||||
|
above calibrated floors; absent that → `LOST`, never a phantom. Mirrors the governed-trust gate
|
||||||
|
(ADR-031 / ADR-141).
|
||||||
|
|
||||||
|
### 4. Dead reckoning & smoothing — fluid, never jumpy (the user's requirement)
|
||||||
|
CSI does **not** arrive cleanly: UDP frames drop, nexmon `.pcap` has gaps, the fuser skips
|
||||||
|
cycles when the #1049 guard rejects a spread, and the model's per-frame skeleton jitters. Render
|
||||||
|
only on real frames and the figure teleports and shakes — which also *reads as fake*. A
|
||||||
|
**predict/correct (dead-reckoning) layer** keeps the skeleton continuous and smooth between
|
||||||
|
measurements, with **bounded** extrapolation so we never invent motion that didn't happen:
|
||||||
|
|
||||||
|
- **Per-joint constant-velocity Kalman filter** — reuse `signal/src/ruvsense/pose_tracker.rs`
|
||||||
|
(the project's existing 17-keypoint Kalman tracker with AETHER re-ID). The renderer runs at a
|
||||||
|
**fixed ~30 Hz, decoupled from CSI arrival**:
|
||||||
|
- **Measurement this tick** → Kalman *update* (correct) each joint with the new inferred pose.
|
||||||
|
- **Dropped CSI this tick** → Kalman *predict* only: advance each joint by `x += v·dt`, so the
|
||||||
|
skeleton keeps moving along its trajectory instead of freezing then snapping. **This is the
|
||||||
|
dead reckoning** — the limbs keep their motion through a dropout.
|
||||||
|
- **Confidence decay (honesty governor):** every predict-only tick multiplies confidence and
|
||||||
|
widens covariance. Dead reckoning is trusted for a **bounded** horizon (default ≤ ~500 ms,
|
||||||
|
`WDP_DEADRECKON_MAX_MS`); past it, confidence hits the floor → state machine → `LOST`. **We
|
||||||
|
coast briefly to stay smooth; we never coast forever to fake a track.** Someone who actually
|
||||||
|
stopped behind the wall converges to a still pose then `LOST`, not perpetual phantom walking.
|
||||||
|
- **Re-acquire smoothing:** a returning measurement after a gap is blended in with a
|
||||||
|
critically-damped step (no overshoot) over 2–3 ticks, so the skeleton eases onto truth.
|
||||||
|
- **Client render smoothing (already present):** `ui/observatory/js/figure-pool.js`
|
||||||
|
`applyKeypoints` already `lerp`s joints with a small velocity overshoot for secondary motion;
|
||||||
|
the hand-off viewer reuses it. The camera↔RF cross-fade is an alpha-lerp over ~300 ms.
|
||||||
|
|
||||||
|
**Dead-reckoning honesty invariants (testable):**
|
||||||
|
1. Predicted-only frames carry `"dead_reckoned": true` + `"age_ms"`; the UI dims them —
|
||||||
|
extrapolation is never shown as a fresh measurement.
|
||||||
|
2. Confidence is **monotonically non-increasing** across consecutive predict-only ticks.
|
||||||
|
3. After `WDP_DEADRECKON_MAX_MS` of silence the state **must** become `LOST` (pinned test:
|
||||||
|
measurements then silence → assert transition within the horizon; no perpetual motion).
|
||||||
|
4. Dead reckoning extrapolates an **existing** track only — no measurement ever ⇒ no track ⇒
|
||||||
|
`LOST`, never a phantom from zero.
|
||||||
|
|
||||||
|
### 5. The HTML demo (single file, vanilla — mirrors the Observatory)
|
||||||
|
`ui/through-wall/index.html` (+ a small JS bundle, zero build step, like `ui/observatory/`):
|
||||||
|
- **Left:** near camera feed with the MediaPipe skeleton overlaid while visible; greys to
|
||||||
|
"CAMERA BLIND" when occluded. (Optional second tile: the far camera, shown only in a
|
||||||
|
"validation" view, not the hero view.)
|
||||||
|
- **Right:** a top-down 3D room (Three.js) with the **wall** drawn, the doorway, the three
|
||||||
|
sensor positions, and the figure: a **solid skeleton** in `CAMERA`, a **translucent skeleton
|
||||||
|
with per-joint confidence fade** in `RF_INFERRED`, eased by the dead-reckoning smoother.
|
||||||
|
- **Banner / `BannerState`** (strict, mirrors rufield-viewer): `CAMERA` / `RF-INFERRED — through
|
||||||
|
wall (conf X%, measured Y% PCK@k)` / `DEAD-RECKONED (age N ms)` / `LOST` — mutually exclusive,
|
||||||
|
with a one-line honesty caption. The measured through-wall PCK is shown, not invented.
|
||||||
|
- Consumes a new `GET /ws/handoff` WS/SSE topic of `HandoffFrame`s; `?demo=1` replays a recorded
|
||||||
|
session badged `REPLAY`.
|
||||||
|
|
||||||
|
### Output contract (`HandoffFrame`, JSON)
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"t_ns": 1718400000000,
|
||||||
|
"state": "RF_INFERRED", // CAMERA | HANDOFF_OUT | RF_INFERRED | HANDOFF_IN | LOST
|
||||||
|
"source": "fused_csi", // camera | fused_csi | mmwave | dead_reckoned
|
||||||
|
"pose": [[x,y,z,conf], …×17], // inferred skeleton WITH per-joint confidence (present in CAMERA/HANDOFF/RF_INFERRED)
|
||||||
|
"pose_confidence": 0.58, // aggregate; the rendered translucency
|
||||||
|
"identity_match": 0.81, // AETHER re-ID — is it still you?
|
||||||
|
"coarse": { "cell":[x,y], "zone":"behind_wall", "heading_deg":95, "node_diversity":0.48 },
|
||||||
|
"dead_reckoned": false, // true on predict-only (extrapolated) ticks
|
||||||
|
"age_ms": 0, // ms since the last real measurement (0 = fresh)
|
||||||
|
"camera_blind": true,
|
||||||
|
"measured_pck": { "k": 20, "value": null }, // filled from the far-camera validation run; null until measured
|
||||||
|
"caption": "RF-inferred skeleton — model camera-supervised on this room; through-wall PCK measured separately"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phased plan (each phase independently demoable + falsifiable)
|
||||||
|
- **P1 — wiring (no claim):** 3-source CSI capture (S3+C6+Pi5) + near camera into the multistatic
|
||||||
|
fuser. Gate: `/ws/sensing` shows ≥3 active nodes + a fused position with the camera running.
|
||||||
|
- **P2 — supervised calibration + through-wall training:** the guided walk with **both cameras**;
|
||||||
|
fine-tune CSI→skeleton on visible AND far-camera-labeled behind-wall poses (ADR-079). Gate:
|
||||||
|
while-visible PCK declared (metric-locked, ADR-173) on a held-out segment.
|
||||||
|
- **P3 — MEASURE the through-wall skeleton:** score the RF-inferred skeleton against the far
|
||||||
|
camera on held-out behind-wall passes → **publish the through-wall PCK@k** (good or bad). Gate:
|
||||||
|
a committed eval script reproduces the number; honest negative if low.
|
||||||
|
- **P4 — hand-off + dead reckoning + HTML:** the camera→RF→camera transition renders end-to-end,
|
||||||
|
smooth through dropped CSI. Gate: a recorded live walk where the camera goes blind, the inferred
|
||||||
|
skeleton keeps walking fluidly behind the wall, dead-reckons through dropouts without jumps, and
|
||||||
|
re-acquisition is position-continuous. **This is the demo.**
|
||||||
|
- **P5 — multi-modal corroboration (optional):** overlay C6 60 GHz presence/range as an
|
||||||
|
independent through-thin-wall confirm (two physics, one conclusion).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- A genuinely compelling demo that does what the user asked — **infers and renders the skeleton
|
||||||
|
through the wall** — while staying honest because the through-wall accuracy is **measured**
|
||||||
|
against a far-side camera, not claimed. Reuses the multistatic fuser, ADR-079 supervision, the
|
||||||
|
Kalman pose tracker, AETHER re-ID, the calibration crate, and the Observatory UI: the new code
|
||||||
|
is a hand-off module + dead-reckoning smoother + an HTML page.
|
||||||
|
|
||||||
|
### Negative / Risks
|
||||||
|
- **Through-wall skeletal accuracy may be modest or poor.** That is acceptable *iff* reported
|
||||||
|
honestly — the headline is the measured PCK, whatever it is; the skeleton renders with its true
|
||||||
|
per-joint confidence (low-confidence joints fade), never as fake certainty.
|
||||||
|
- **Material dependence:** drywall good; brick/metal degrades to coarse-only — shoot on drywall
|
||||||
|
and say so.
|
||||||
|
- **3-node clock sync** is the #1049 hazard — tune `WDP_GUARD_INTERVAL_US`.
|
||||||
|
- **Per-room, per-subject:** the model that "learned your movements" does not transfer without
|
||||||
|
re-calibration — stated on the page.
|
||||||
|
- **Over-claiming is the failure mode.** Mitigations baked in: translucent confidence-faded
|
||||||
|
skeleton, `dead_reckoned`/`age_ms` flags, the measured-PCK banner, bounded extrapolation→`LOST`.
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
- No new heavy crate; signal-path proof (`verify.py`) untouched — capture/fusion/UI orchestration
|
||||||
|
over hardened, already-reviewed components.
|
||||||
|
|
||||||
|
## Acceptance criteria (falsifiable — "prove the haters wrong")
|
||||||
|
On a recorded live session, all must hold:
|
||||||
|
1. A contiguous window where the **near camera reports no person** (verifiable from raw frames)
|
||||||
|
**and** the system renders an `RF_INFERRED` skeleton.
|
||||||
|
2. The inferred skeleton's **gross motion matches reality** — direction of travel and rough gait
|
||||||
|
phase — confirmed against the **far camera** (not eyeballed).
|
||||||
|
3. **Through-wall per-joint accuracy is MEASURED** against the far camera and **reported** as
|
||||||
|
PCK@k from a committed script. Low is fine *if* honestly published; fabricated is not.
|
||||||
|
4. The figure is **smooth through dropped CSI** — no teleports/jitter — and every predicted-only
|
||||||
|
frame is flagged `dead_reckoned`; after `WDP_DEADRECKON_MAX_MS` of silence it goes `LOST`.
|
||||||
|
5. Re-acquisition is **position-continuous** (camera re-detects within a cell of the last RF
|
||||||
|
position), and AETHER confirms identity across the hand-off.
|
||||||
|
6. Every number (visible PCK, through-wall PCK, confidences) is MEASURED and reproducible — no
|
||||||
|
hand-typed metrics.
|
||||||
|
|
||||||
|
A demo that cannot meet (1)–(2) and (4)–(5) on the available hardware is reported as a **negative
|
||||||
|
result** (honest), not dressed up; a poor (3) is published as the real number.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
- ADR-079 — camera ground-truth training (supervision pipeline; extended here to a far camera)
|
||||||
|
- ADR-031 — sensing-first RF mode / coherence gate (fail-closed honesty pattern)
|
||||||
|
- ADR-134 — CSI→CIR multipath (through-wall multipath physics)
|
||||||
|
- ADR-029 / ADR-030 — RuvSense multistatic + persistent field (the localization engine)
|
||||||
|
- ADR-024 — AETHER contrastive re-ID (identity lock across the hand-off)
|
||||||
|
- ADR-151 — per-room calibration crate (bank persistence)
|
||||||
|
- ADR-152 / ADR-173 — measurement discipline + metric-locked PCK (the honest accuracy readout)
|
||||||
|
- ADR-095 / ADR-096 — rvcsi nexmon (Pi 5 BCM43455c0 capture)
|
||||||
|
- `signal/src/ruvsense/pose_tracker.rs` — 17-kpt Kalman tracker reused for dead reckoning
|
||||||
|
- `ui/observatory/` — the vanilla-JS 3D viewer pattern this demo mirrors
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
# ADR-182: `npx ruview` — A RuView Agent Harness Minted via MetaHarness
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|-------|-------|
|
|
||||||
| **Status** | Accepted — **P1+P2 implemented & validated** (`harness/ruview/`, 17/17 tests, MCP handshake + `ruview.verify` PASS against the real repo, packs to 16.7 kB / 21 files) · P3 publish-ready (name decision pending) · P4 (router + provenance) designed |
|
|
||||||
| **Date** | 2026-06-17 |
|
|
||||||
| **Deciders** | ruv |
|
|
||||||
| **Codename** | **RUVIEW-HARNESS** |
|
|
||||||
| **Builds on** | MetaHarness (`metaharness@0.1.15`, `@metaharness/kernel`, `@metaharness/host-*`, `@metaharness/router`), the `ruview-*` Claude Code subagents (`ruview-onboarding-guide`, `ruview-config-engineer`, `ruview-training-engineer`), the `wifi-densepose` CLI (`calibrate`/`enroll`/`train-room`/`room-watch`), the sensing-server, ADR-028 (witness verification), ADR-095/096 (rvCSI runtime), ADR-260/262 (RuField bridge) |
|
|
||||||
| **Supersedes** | none |
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
RuView (WiFi-DensePose) is a deep stack — 15 Rust crates, an ESP32 firmware line,
|
|
||||||
a sensing-server, a CLI, ~180 ADRs, a calibration pipeline, training recipes, and a
|
|
||||||
hard cultural rule that **every claim must be independently reproducible** (the
|
|
||||||
"prove everything" ethos, after the project was accused of AI-slop). The barrier to
|
|
||||||
entry is correspondingly steep: a newcomer who wants to "set up WiFi sensing" must
|
|
||||||
discover the right firmware variant, provision an ESP32 over a Windows-only Python
|
|
||||||
subprocess, point it at the sensing-server, run `calibrate` → `enroll` →
|
|
||||||
`train-room`, and know which numbers are MEASURED vs CLAIMED. We already encode this
|
|
||||||
knowledge as **Claude Code subagents** (`ruview-onboarding-guide`,
|
|
||||||
`ruview-config-engineer`, `ruview-training-engineer`) — but those only exist inside
|
|
||||||
*this* repo's `.claude/agents/`, only on Claude Code, and only for someone who has
|
|
||||||
already cloned the monorepo.
|
|
||||||
|
|
||||||
Separately, this session shipped **MetaHarness** (`metaharness@0.1.15`): a tool that
|
|
||||||
*"mints a custom AI agent harness from any repo"*, runnable on **9 hosts**
|
|
||||||
(claude-code, codex, pi-dev, hermes, openclaw, rvm, copilot, opencode,
|
|
||||||
github-actions) over a wasm-primary / NAPI-RS-fallback **kernel**, with a
|
|
||||||
**cost-optimal model router** (`@metaharness/router`, the productized DRACO Phase-2
|
|
||||||
k-NN finding) and ed25519/SLSA/SBOM provenance baked in. Crucially, MetaHarness
|
|
||||||
**already ships a `vertical:ruview` template** in its template list. That template
|
|
||||||
is generic scaffolding; it is not wired to RuView's actual tools, agents, or the
|
|
||||||
"prove everything" guardrails.
|
|
||||||
|
|
||||||
The gap: **there is no single, host-portable, provenance-signed entry point that
|
|
||||||
gives any user an AI agent that actually knows how to operate RuView.** A user
|
|
||||||
should be able to run one command —
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx ruview
|
|
||||||
```
|
|
||||||
|
|
||||||
— in an empty directory (or alongside an ESP32) and get an agent harness that can
|
|
||||||
onboard them, configure firmware, drive a live capture, train a room model, and
|
|
||||||
**refuse to overstate accuracy** — on whichever coding host they already use.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**Mint a first-class RuView agent harness from this repo using MetaHarness, harden
|
|
||||||
its `vertical:ruview` template into a RuView-specific harness with a real MCP tool
|
|
||||||
surface and the project's honesty guardrails, and publish it as `npx ruview`.**
|
|
||||||
|
|
||||||
`npx ruview` is *not* a new runtime. It is a **thin, versioned distribution** of a
|
|
||||||
MetaHarness harness: the kernel + host adapters + a RuView "genome" (skills, agents,
|
|
||||||
MCP tools, guardrails) generated from and pinned against this monorepo. The harness
|
|
||||||
is the product; `npx ruview` is the front door.
|
|
||||||
|
|
||||||
### Why mint-from-repo instead of hand-writing a harness
|
|
||||||
|
|
||||||
MetaHarness's value here is exactly the work we would otherwise hand-roll across 9
|
|
||||||
hosts: host-specific config (`.claude/settings.json` MCP + hooks for claude-code,
|
|
||||||
the codex/copilot/opencode equivalents), the kernel that abstracts wasm-vs-native,
|
|
||||||
the cost router, and the provenance chain. We write the **RuView knowledge once** as
|
|
||||||
host-neutral genome assets; MetaHarness projects them onto each host adapter. This
|
|
||||||
also keeps the harness regenerable: when the CLI or an ADR changes, re-mint and
|
|
||||||
re-pin rather than maintaining 9 divergent copies.
|
|
||||||
|
|
||||||
### What the harness contains (the RuView genome)
|
|
||||||
|
|
||||||
1. **Skills / playbooks** (host-neutral markdown, projected to each host's skill
|
|
||||||
format):
|
|
||||||
- `onboard` — zero-to-sensing path picker (Docker demo / repo build / live
|
|
||||||
ESP32), the physics caveats, the hardware table. Port of
|
|
||||||
`ruview-onboarding-guide`.
|
|
||||||
- `provision-node` — ESP-IDF v5.4 Windows-subprocess build/flash/provision flow
|
|
||||||
(the exact MSYSTEM-stripped invocation from `CLAUDE.local.md`), firmware
|
|
||||||
variant selection (8MB display / 4MB no-display / C6), NVS + WiFi + channel /
|
|
||||||
MAC-filter overrides (ADR-060).
|
|
||||||
- `calibrate-room` — `baseline → enroll → extract → train` via the
|
|
||||||
`wifi-densepose` CLI (`calibrate`/`calibrate-serve`/`enroll`/`train-room`/
|
|
||||||
`room-watch`, ADR-151).
|
|
||||||
- `train-pose` — camera-supervised + camera-free training, the MEASURED-vs-CLAIMED
|
|
||||||
discipline, the mean-pose baseline check (ADR-079, ADR-152, ADR-181).
|
|
||||||
- `verify` — run the witness bundle + Python proof (`verify.py` → VERDICT: PASS),
|
|
||||||
ADR-028.
|
|
||||||
- Ports of `ruview-config-engineer` and `ruview-training-engineer`.
|
|
||||||
|
|
||||||
2. **MCP tool surface** (`@metaharness/kernel`-hosted MCP server, one schema per
|
|
||||||
capability — see "MCP tools" below). This is what makes the harness *operate*
|
|
||||||
RuView, not just talk about it.
|
|
||||||
|
|
||||||
3. **Guardrails** (the differentiator): the harness's system prompt and a
|
|
||||||
pre-output hook enforce the "prove everything" rule — accuracy numbers must be
|
|
||||||
tagged MEASURED (with a reproducer) or CLAIMED; the agent must run the mean-pose
|
|
||||||
baseline before quoting PCK; firmware fixes are never presented as
|
|
||||||
hardware-validated without a real boot log (the exact discipline this session
|
|
||||||
followed for `v0.8.1-esp32`).
|
|
||||||
|
|
||||||
4. **Host adapters** — claude-code first (P1), then codex / opencode / copilot /
|
|
||||||
pi-dev / hermes / rvm / github-actions (P3+), each via the published
|
|
||||||
`@metaharness/host-*` package.
|
|
||||||
|
|
||||||
5. **Router** — `@metaharness/router` routes each step to the cheapest adequate
|
|
||||||
model (e.g. a var-rename or a log-grep → Haiku; calibration-math reasoning or a
|
|
||||||
security review → Sonnet/Opus), mirroring the repo's 3-tier routing (ADR-026).
|
|
||||||
|
|
||||||
### MCP tools (the operational surface)
|
|
||||||
|
|
||||||
| Tool | Wraps | Purpose |
|
|
||||||
|------|-------|---------|
|
|
||||||
| `ruview.onboard` | docs + agent | Pick a setup path, print the next concrete command |
|
|
||||||
| `ruview.node.flash` | ESP-IDF subprocess (ADR `CLAUDE.local.md`) | Build + flash a firmware variant to a COM port |
|
|
||||||
| `ruview.node.provision` | `provision.py` | Set SSID/password/target-ip/channel/MAC-filter over serial |
|
|
||||||
| `ruview.node.monitor` | pyserial | Stream boot log; assert CSI is flowing (MGMT+DATA) |
|
|
||||||
| `ruview.server.up` | sensing-server | Start the Axum sensing-server (`:3000`/`:5005`/`:8765`) |
|
|
||||||
| `ruview.calibrate` | `wifi-densepose calibrate`/`enroll`/`train-room` | Run the ADR-151 room pipeline |
|
|
||||||
| `ruview.room.watch` | `wifi-densepose room-watch` | Live presence/vitals from a trained room |
|
|
||||||
| `ruview.verify` | `scripts/generate-witness-bundle.sh` + `verify.py` | Produce/verify the witness bundle (must be N/N PASS) |
|
|
||||||
| `ruview.claim.check` | static lint | Scan output for untagged accuracy claims; flag MEASURED-vs-CLAIMED |
|
|
||||||
|
|
||||||
Each tool returns structured JSON and is fail-closed: a tool that cannot prove its
|
|
||||||
result (e.g. `ruview.node.monitor` sees no CSI callbacks) returns an honest negative,
|
|
||||||
never a fabricated success — consistent with the RuField `map_privacy` fail-closed
|
|
||||||
posture (ADR-262 §3.3).
|
|
||||||
|
|
||||||
### The mint + pin flow (how the harness is produced)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# P1 — mint from this repo, claude-code host, RuView vertical
|
|
||||||
npx metaharness ruview --template vertical:ruview --host claude-code \
|
|
||||||
--from-existing . --description "RuView WiFi-sensing operator agent" \
|
|
||||||
--target ./harness/ruview
|
|
||||||
|
|
||||||
# readiness + fit/cost/safety scorecards (ADR-041) — gate before publish
|
|
||||||
npx metaharness genome . # 7-section repo readiness
|
|
||||||
npx metaharness score . --json # fit / cost / safety
|
|
||||||
npx metaharness analyze . # recommended harness plan (no-exec)
|
|
||||||
```
|
|
||||||
|
|
||||||
The minted harness is committed under `harness/ruview/` and **pinned** (kernel +
|
|
||||||
host-adapter + router versions locked) so `npx ruview` is reproducible. Re-minting on
|
|
||||||
a CLI/ADR change is a reviewed PR, not an implicit regeneration.
|
|
||||||
|
|
||||||
### Distribution: `npx ruview`
|
|
||||||
|
|
||||||
A small published package whose `bin` boots the pinned harness via the kernel:
|
|
||||||
|
|
||||||
- **Preferred name:** `ruview` (currently **free** on npm — verified 2026-06-17).
|
|
||||||
- **Risk:** npm's typosquat filter may reject `ruview` as too close to `review` /
|
|
||||||
`preview` (this session hit exactly that on `ruvn`→`levn`/`raven` and
|
|
||||||
`worldgraph`→`world-graph`). **Fallback:** publish scoped `@ruvnet/ruview` (also
|
|
||||||
free) and/or `npx ruvnet/ruview` straight from GitHub. Decide at publish time;
|
|
||||||
do not unpublish to rename (the 24-h name-lock lesson from `worldgraphs`).
|
|
||||||
- `bin: { "ruview": "bin/cli.js" }` — note **`bin/cli.js`, not `./bin/cli.js`** (npm
|
|
||||||
strips the `./` form; this broke `ruvn@0.1.0` this session).
|
|
||||||
- `npx ruview` with no args → `onboard` skill (interactive path picker).
|
|
||||||
`npx ruview <skill> [...]` → run a specific skill. `npx ruview --host codex` →
|
|
||||||
install the harness into an existing repo for that host.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
npx ruview (thin bin — boots the pinned harness)
|
|
||||||
│
|
|
||||||
@metaharness/kernel (wasm primary · NAPI-RS native fallback)
|
|
||||||
├── host adapter ── claude-code | codex | opencode | copilot | pi-dev | hermes | rvm | github-actions
|
|
||||||
├── @metaharness/router (k-NN cost-optimal model routing — DRACO P2 / ADR-026)
|
|
||||||
└── RuView genome (pinned)
|
|
||||||
├── skills onboard · provision-node · calibrate-room · train-pose · verify
|
|
||||||
├── mcp tools ruview.node.* · ruview.calibrate · ruview.room.watch · ruview.verify · ruview.claim.check
|
|
||||||
└── guardrails MEASURED-vs-CLAIMED · mean-pose baseline · no-unvalidated-firmware-claims
|
|
||||||
│
|
|
||||||
RuView assets (the real system the agent drives)
|
|
||||||
├── wifi-densepose CLI calibrate / enroll / train-room / room-watch
|
|
||||||
├── sensing-server :3000 / :5005 / :8765
|
|
||||||
├── ESP-IDF subprocess build / flash / provision / monitor (COM8/COM9/COM12)
|
|
||||||
└── witness bundle + verify.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Provenance: the harness ships an **ed25519 witness + SBOM (SPDX) + SLSA** chain
|
|
||||||
(MetaHarness already does this for minted harnesses), so a recipient can verify the
|
|
||||||
RuView harness was built from a specific monorepo commit — the agentic analogue of
|
|
||||||
the firmware witness bundle (ADR-028).
|
|
||||||
|
|
||||||
## Phases
|
|
||||||
|
|
||||||
- **P1 — Mint & pin (claude-code).** `npx metaharness ruview --template
|
|
||||||
vertical:ruview --from-existing . --host claude-code`. Port the three `ruview-*`
|
|
||||||
subagents into host-neutral genome skills. Commit under `harness/ruview/`, pin
|
|
||||||
versions. Acceptance: `npx metaharness score .` ≥ threshold; the harness can run
|
|
||||||
`onboard` and `verify` end-to-end locally.
|
|
||||||
- **P2 — MCP tool surface.** Implement the `ruview.*` MCP tools over the kernel
|
|
||||||
(start with `onboard`, `verify`, `claim.check`, `node.monitor` — the read-only /
|
|
||||||
proving tools), then the mutating ones (`node.flash`, `provision`, `calibrate`).
|
|
||||||
Acceptance: `ruview.verify` returns the witness bundle PASS as structured JSON;
|
|
||||||
`ruview.claim.check` flags a seeded untagged "100% accuracy" string.
|
|
||||||
- **P3 — Publish `npx ruview` + multi-host.** Publish the bin package (name decision
|
|
||||||
per Distribution). Add codex / opencode / copilot / pi-dev / hermes / rvm /
|
|
||||||
github-actions adapters. Acceptance: `npx ruview` cold-starts on ≥3 hosts and runs
|
|
||||||
`onboard`; provenance verifies.
|
|
||||||
- **P4 — Router + guardrail hardening.** Wire `@metaharness/router`; calibrate the
|
|
||||||
3-tier routing on a RuView task set. Make the MEASURED-vs-CLAIMED guardrail a hard
|
|
||||||
pre-output gate. Acceptance: a benchmark of RuView tasks shows cost reduction vs
|
|
||||||
all-Opus with no quality regression; the guardrail blocks an untagged accuracy
|
|
||||||
claim in a red-team prompt.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
**Positive**
|
|
||||||
- One reproducible, signed entry point (`npx ruview`) that operates RuView on the
|
|
||||||
host the user already has — onboarding goes from "clone a 15-crate monorepo" to a
|
|
||||||
single `npx`.
|
|
||||||
- The "prove everything" ethos becomes **executable**, not just documentation: the
|
|
||||||
harness *enforces* MEASURED-vs-CLAIMED and the mean-pose baseline.
|
|
||||||
- Knowledge written once (host-neutral genome) instead of 9× per host; regenerable
|
|
||||||
from the repo as the system evolves.
|
|
||||||
- Dogfoods MetaHarness on a hard real vertical, surfacing bugs back to
|
|
||||||
`agent-harness-generator` (this session already filed #9–#13 there).
|
|
||||||
|
|
||||||
**Negative / risks**
|
|
||||||
- **Drift:** a pinned harness goes stale as the CLI/ADRs move; mitigated by a
|
|
||||||
re-mint-on-change PR ritual and a CI check that the genome's referenced
|
|
||||||
CLI flags still exist.
|
|
||||||
- **Surface area:** mutating MCP tools (`node.flash`, `provision`) touch hardware and
|
|
||||||
the network — must be permission-gated and fail-closed; the firmware-flash tool
|
|
||||||
must never claim hardware validation without a captured boot log.
|
|
||||||
- **Name/typosquat:** `ruview` may be rejected at publish; scoped fallback decided in
|
|
||||||
P3. Do not unpublish-to-rename.
|
|
||||||
- **Host parity:** not all 9 hosts support MCP + hooks equally; the guardrail gate
|
|
||||||
may degrade to advisory on weaker hosts — must be disclosed in the badge, not
|
|
||||||
hidden (same honesty principle as ADR-181's backend badge).
|
|
||||||
- **Windows-coupled tooling:** the ESP-IDF flow is Windows-subprocess-specific
|
|
||||||
today; the `node.*` tools are gated to that environment until a cross-platform
|
|
||||||
path exists.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
1. **Keep the `ruview-*` subagents repo-local (status quo).** Zero new surface, but
|
|
||||||
stays Claude-Code-only and clone-gated; no portable front door. Rejected — it's
|
|
||||||
the gap this ADR exists to close.
|
|
||||||
2. **Hand-write a bespoke `npx ruview` harness (no MetaHarness).** Full control, but
|
|
||||||
re-implements the kernel, 9 host adapters, the router, and the provenance chain
|
|
||||||
we already ship — months of duplicated work and 9 divergent configs to maintain.
|
|
||||||
Rejected.
|
|
||||||
3. **Use the generic `vertical:ruview` template as-is.** It's scaffolding with no
|
|
||||||
real tools or guardrails — it would *talk about* RuView without being able to
|
|
||||||
*operate* it or enforce honesty. Rejected as insufficient; P2 is precisely the
|
|
||||||
hardening that makes it real.
|
|
||||||
4. **Ship only an MCP server (no harness/host adapters).** Covers tools but not the
|
|
||||||
skills, routing, guardrails, or multi-host projection — a strictly smaller subset
|
|
||||||
of this design. Folded in as the P2 layer rather than the whole.
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
- Final published name: bare `ruview` vs scoped `@ruvnet/ruview` vs GitHub-only
|
|
||||||
`npx ruvnet/ruview` — resolve against the typosquat filter at P3.
|
|
||||||
- Does the harness bundle the `wifi-densepose` binary, shell out to a user-installed
|
|
||||||
one, or offer both? (Leaning: shell out; print install guidance if absent.)
|
|
||||||
- Where do the `node.*` hardware tools live for non-Windows users — defer, or wrap
|
|
||||||
the rvCSI runtime (ADR-095/096) which is cross-platform Rust?
|
|
||||||
- Should `ruview.verify` gate `npx ruview` self-tests in CI (harness can't publish if
|
|
||||||
the witness bundle regresses)?
|
|
||||||
- Relationship to the RuField MFS harness surface (ADR-260/262) — one harness with a
|
|
||||||
RuField skill, or a sibling `npx rufield`?
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- MetaHarness: `metaharness@0.1.15` (`npx metaharness`, templates incl.
|
|
||||||
`vertical:ruview`; hosts: claude-code/codex/pi-dev/hermes/openclaw/rvm/copilot/
|
|
||||||
opencode/github-actions), `@metaharness/kernel`, `@metaharness/router`,
|
|
||||||
`@metaharness/host-*`, repo `github.com/ruvnet/agent-harness-generator`.
|
|
||||||
- RuView subagents: `ruview-onboarding-guide`, `ruview-config-engineer`,
|
|
||||||
`ruview-training-engineer` (`.claude/agents/`).
|
|
||||||
- ADR-026 (3-tier model routing), ADR-028 (witness verification), ADR-041
|
|
||||||
(MetaHarness scorecards), ADR-060 (channel / MAC-filter overrides), ADR-079
|
|
||||||
(camera ground-truth training), ADR-095/096 (rvCSI runtime), ADR-151 (per-room
|
|
||||||
calibration), ADR-152/181 (WiFlow / browser pose), ADR-260/262 (RuField bridge).
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# ADR-183: Onboard LED as a 40 Hz Gamma Stimulus, Colour-Mapped from Live CSI via `ruv-neural-viz`
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|-------|-------|
|
|
||||||
| **Status** | Accepted — implemented & hardware-confirmed on ESP32-S3 N16R8 (COM8) |
|
|
||||||
| **Date** | 2026-06-17 |
|
|
||||||
| **Deciders** | ruv |
|
|
||||||
| **Codename** | **GAMMA-VIZ** |
|
|
||||||
| **Builds on** | `ruv-neural-viz::ColorMap` (now `no_std` — ruvnet/ruv-neural#3 / RuView#1126), the ESP32 edge `motion_energy` metric (`edge_processing.c`), PR #962 (WS2812 on GPIO 48) |
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Two threads converged. (1) `ruv-neural-viz::ColorMap` — the viridis/cool-warm
|
|
||||||
palette the rUv-Neural stack uses to render brain-topology graphs — was `std`-only,
|
|
||||||
so it couldn't run on the ESP32. (2) The onboard WS2812 on the S3 CSI node was dead
|
|
||||||
weight: the firmware only cleared it on boot (and on the wrong pin for N16R8 — GPIO
|
|
||||||
38 vs the actual 48, see #962).
|
|
||||||
|
|
||||||
The ask: make the LED do something real and honest, using the project's own visual
|
|
||||||
capability — not a decorative blink. The natural fit is a **40 Hz gamma stimulus**
|
|
||||||
(the GENUS gamma-entrainment frequency from Alzheimer's light-therapy research)
|
|
||||||
whose **colour is driven by live sensed motion**, so the node's front panel is both
|
|
||||||
a known bio-stimulus waveform and a truthful readout of what the CSI is detecting.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
### Part A — make `ColorMap` `no_std`
|
|
||||||
|
|
||||||
`colormap.rs` is self-contained (no cross-crate deps), so expose it on `no_std`
|
|
||||||
targets. The only blockers were two `std`-only `f64` ops:
|
|
||||||
|
|
||||||
- `f64::round` / `f64::abs` → replaced with `core`+`alloc`-safe helpers `fround`
|
|
||||||
(round via `f64 as i64` truncation — a `core` cast, no `libm`) and `fabs`.
|
|
||||||
- `Vec`/`String`/`format!` → from `alloc`.
|
|
||||||
|
|
||||||
The graph-bound modules (`animation`/`ascii`/`export`/`layout`) and their heavy deps
|
|
||||||
move behind a default `std` feature; `--no-default-features` builds the crate `no_std`
|
|
||||||
and exposes only `colormap`. Output is **byte-identical** (8/8 colormap tests pass with
|
|
||||||
the same RGB values), so this is a pure portability change.
|
|
||||||
|
|
||||||
### Part B — the LED stimulus (firmware)
|
|
||||||
|
|
||||||
`firmware/esp32-csi-node/main/main.c`, on boot:
|
|
||||||
|
|
||||||
- WS2812 on **GPIO 48** (N16R8 / DevKitC-1 v1.1; GPIO 8 on C6).
|
|
||||||
- An `esp_timer` periodic at **12 500 µs toggles a square wave → 40 Hz, 50 % duty**
|
|
||||||
(full-on / full-off — a *perceptible* gamma flicker, not a colour drift).
|
|
||||||
- **ON-phase colour = live CSI motion.** Each ON phase reads `edge_get_vitals().motion_energy`,
|
|
||||||
normalises it (`/ LED_MOTION_FULLSCALE`, clamped `[0,1]`), and indexes a **60-step
|
|
||||||
viridis LUT generated from `ColorMap::viridis().map()`** — still = dark purple,
|
|
||||||
strong motion = yellow.
|
|
||||||
|
|
||||||
The LUT is baked from the real crate (Part A makes the same `ColorMap` embeddable
|
|
||||||
for a future direct FFI path once the ESP Rust toolchain is in CI). The colours are
|
|
||||||
therefore provably `ruv-neural-viz`'s, and the motion is provably real.
|
|
||||||
|
|
||||||
## Honesty (what it is and is not)
|
|
||||||
|
|
||||||
- **40 Hz is a real square-wave stimulus** (12.5 ms on / 12.5 ms off), not a label on
|
|
||||||
a colour sweep. It is *not* tied to any measured 40 Hz brain rhythm — it is an
|
|
||||||
*output* stimulus at the gamma frequency, not a readout of neural gamma.
|
|
||||||
- **Colour is a real CSI readout** — `motion_energy` is the on-device phase-variance
|
|
||||||
motion metric the node already computes; no fabrication. At rest the LED sits at the
|
|
||||||
purple (low) end and flickers there.
|
|
||||||
- No therapeutic claim is made. 40 Hz GENUS entrainment is cited as the *origin of the
|
|
||||||
frequency choice*, not as a validated medical effect of this device.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
**Positive**
|
|
||||||
- The LED is now an honest front-panel: gamma-frequency flicker + a live motion readout.
|
|
||||||
- `ColorMap` is embeddable (`no_std`), unblocking on-device use of the rUv-Neural
|
|
||||||
palette beyond this LED.
|
|
||||||
- Confirms #962's GPIO-48 fix visually (the LED lights on N16R8).
|
|
||||||
|
|
||||||
**Negative / risks**
|
|
||||||
- Changes the *default* firmware behaviour: the onboard LED animates instead of staying
|
|
||||||
off. Now **gated by `CONFIG_LED_GAMMA_VIZ`** (default `y`); set it `n` for a dark,
|
|
||||||
lower-power boot (the LED is just cleared) — no source change needed.
|
|
||||||
- A 40 Hz flicker can be an issue for photosensitive users; document on the enclosure
|
|
||||||
and disable `CONFIG_LED_GAMMA_VIZ` in those deployments.
|
|
||||||
- The saturation point is now `CONFIG_LED_MOTION_FULLSCALE_MILLI` (default 250 = 0.25),
|
|
||||||
operator-tunable; still not auto-calibrated per-environment.
|
|
||||||
- The colour uses a baked LUT, not the live Rust `ColorMap` (FFI path deferred — needs
|
|
||||||
the ESP Rust/xtensa toolchain, not yet in CI).
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
- `ruv-neural-viz`: `cargo build` (std) ✓, `cargo test colormap` 8/8 ✓ (identical RGB),
|
|
||||||
`cargo build --no-default-features` compiles `no_std` ✓.
|
|
||||||
- Firmware: built (1.13 MB), flashed to ESP32-S3 N16R8 (COM8). Boot log:
|
|
||||||
`Onboard WS2812: 40 Hz gamma flicker (GENUS), colour=CSI motion via ruv-neural-viz, GPIO 48`;
|
|
||||||
CSI continues (27–38 pps), `motion=0.00` at rest → purple flicker as designed.
|
|
||||||
- Full on-device (xtensa) Rust build of `ColorMap` not run — ESP Rust toolchain absent.
|
|
||||||
|
|
||||||
## References
|
|
||||||
- ruvnet/ruv-neural#3 (ColorMap no_std), RuView#1126 (submodule bump), #962 (GPIO 48).
|
|
||||||
- Singer/Tsai GENUS 40 Hz gamma entrainment (origin of the frequency, not a device claim).
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
# ADR-263: `@ruvnet/ruview` npm Harness — Deep Review + Optimization Strategy
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|-------|-------|
|
|
||||||
| **Status** | Accepted — **implemented** (O1–O9, `@ruvnet/ruview@0.2.0`): fail-closed `claim-check`, async MCP dispatch (ping answered mid-`verify`, pinned by e2e test), zero-dependency install, bounded output tails, argv-passed monitor port, package.json-sourced version, prepack skill sync, memoized `which()`, underscore-canonical tools with dotted aliases, word-boundary guardrail matching. 30/30 tests (MEASURED, `node --test test/*.test.mjs`); CI gate in ADR-265's `npm-packages.yml` |
|
|
||||||
| **Date** | 2026-07-02 |
|
|
||||||
| **Deciders** | ruv |
|
|
||||||
| **Codename** | **RUVIEW-NPM-REVIEW-1** |
|
|
||||||
| **Supersedes / amends** | none (records review of the ADR-182 P1+P2 artifact; feeds ADR-265 distribution strategy) |
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
ADR-182 minted and published **`@ruvnet/ruview@0.1.0`** (`harness/ruview/`) — the
|
|
||||||
`npx ruview` operator harness: a dependency-free ESM CLI + minimal MCP stdio server
|
|
||||||
exposing six `ruview.*` tools (onboard / claim_check / verify / node_monitor /
|
|
||||||
calibrate / node_flash), five skill playbooks, and the executable
|
|
||||||
MEASURED-vs-CLAIMED guardrail (`src/guardrails.js`). The package is live on npm
|
|
||||||
(0.1.0, 49.5 kB unpacked / 21 files — MEASURED, `npm view @ruvnet/ruview` +
|
|
||||||
`npm pack --dry-run`) and is the recommended MCP registration path
|
|
||||||
(`npx -y @ruvnet/ruview mcp start` in the bundled `.claude/settings.json`).
|
|
||||||
|
|
||||||
This ADR is the first dedicated deep review of that npm artifact: correctness,
|
|
||||||
fail-open/fail-closed posture, performance (cold start + request handling),
|
|
||||||
packaging hygiene, and security of the subprocess surface. All 17 bundled tests
|
|
||||||
pass on Node 22 (MEASURED, `node --test test/*.test.mjs`, 17/17, ~108 ms).
|
|
||||||
|
|
||||||
## Findings
|
|
||||||
|
|
||||||
Severity reflects impact on the package's stated contract: *fail-closed operator
|
|
||||||
tools + an honesty guardrail that must never fail open*.
|
|
||||||
|
|
||||||
### F1 (HIGH, fail-open): `claim-check` passes silently on empty input
|
|
||||||
|
|
||||||
`bin/cli.js` `claim-check` with **neither `--text` nor `--file`** sends
|
|
||||||
`text: undefined` → `claimCheck(String(args.text ?? ''))` → `''` → `ok: true`,
|
|
||||||
**exit 0**. A CI hook wired as `npx ruview claim-check --text "$BODY"` where
|
|
||||||
`$BODY` expands empty therefore reports PASS. This is the single tool whose whole
|
|
||||||
purpose is to fail closed; empty input must be an error, not a pass.
|
|
||||||
Reproducer: `node bin/cli.js claim-check` → `{"ok": true}`, exit 0.
|
|
||||||
|
|
||||||
### F2 (HIGH, head-of-line blocking): MCP server is fully synchronous
|
|
||||||
|
|
||||||
`src/mcp-server.js` dispatches `tools/call` inside the readline `line` handler,
|
|
||||||
and every heavyweight handler in `src/tools.js` uses **`spawnSync`**
|
|
||||||
(`ruview.verify` up to 180 s, `ruview.calibrate` up to 300–600 s,
|
|
||||||
`ruview.node_monitor` up to `seconds+10`). While one call runs, the event loop is
|
|
||||||
blocked: `ping`, `tools/list`, and concurrent `tools/call` requests are not even
|
|
||||||
read from stdin. Hosts that health-check with `ping` during a long `calibrate`
|
|
||||||
will conclude the server is dead and kill it mid-run.
|
|
||||||
|
|
||||||
### F3 (MEDIUM, cold start): optionalDependencies triple the `npx` install for a path that never uses them
|
|
||||||
|
|
||||||
`package.json` declares `optionalDependencies` on `@metaharness/kernel` and
|
|
||||||
`@metaharness/host-claude-code`. npm installs optional deps **by default**, so
|
|
||||||
every cold `npx -y @ruvnet/ruview mcp start` fetches 3 extra packages (kernel +
|
|
||||||
host + transitive `@ruvector/emergent-time`). MEASURED (npm 10.9.7, this
|
|
||||||
container): default install = **4 packages, 620 kB, 71 files**; with
|
|
||||||
`--omit=optional` = **1 package, 172 kB, 22 files**. The operator-tool and MCP
|
|
||||||
paths never import these — only `doctor`/`install` do, and both already
|
|
||||||
dynamic-import inside `try/catch` and degrade gracefully when absent
|
|
||||||
(`kernel/host: not installed (ok…)`). The optional deps buy nothing on the hot
|
|
||||||
path and cost 3 registry round-trips + ~450 kB on every cold start.
|
|
||||||
|
|
||||||
### F4 (MEDIUM, silent truncation): `spawnSync` default `maxBuffer` (1 MiB)
|
|
||||||
|
|
||||||
`run()` in `src/tools.js` never sets `maxBuffer`. `cargo run -p
|
|
||||||
wifi-densepose-cli` (the `calibrate` fallback path) and a chatty `verify.py` can
|
|
||||||
exceed 1 MiB of stdout, at which point the child is killed with `ENOBUFS` and the
|
|
||||||
tool reports a spawn error that looks like a proof/calibration failure. The
|
|
||||||
handlers only ever consume the last 8 kB/1.5 kB; buffering should be bounded but
|
|
||||||
generous (e.g. `maxBuffer: 16 MiB`) or streamed with a tail ring.
|
|
||||||
|
|
||||||
### F5 (MEDIUM, injection surface): `node_monitor` interpolates the port into Python source
|
|
||||||
|
|
||||||
The handler builds a `python -c` script by string interpolation:
|
|
||||||
`` `ser=serial.Serial(${JSON.stringify(port)},115200,…)` `` and
|
|
||||||
`` `while time.time()-t<${dur}:` ``. `JSON.stringify` produces a *JavaScript*
|
|
||||||
string literal; Python string-literal semantics differ at the edges (`\uXXXX` is
|
|
||||||
shared, but e.g. JS emits raw U+2028/U+2029 unescaped pre-ES2019 rules aside, and
|
|
||||||
any future non-JSON-safe field added the same way would be executable). `port`
|
|
||||||
arrives from the MCP caller (an agent), so this is an agent-controlled string
|
|
||||||
concatenated into an interpreter invocation. `dur` is `Number()`-guarded; `port`
|
|
||||||
should be passed out-of-band (`sys.argv`/env), never spliced into source.
|
|
||||||
|
|
||||||
### F6 (LOW, drift): server version hardcoded
|
|
||||||
|
|
||||||
`SERVER_INFO = { name: 'ruview', version: '0.1.0' }` in `src/mcp-server.js`
|
|
||||||
duplicates `package.json.version` (the CLI's `--version` already reads
|
|
||||||
package.json at runtime). First release bump will drift the MCP handshake
|
|
||||||
version.
|
|
||||||
|
|
||||||
### F7 (LOW, duplication): every skill ships twice
|
|
||||||
|
|
||||||
`skills/*.md` and `.claude/skills/*/SKILL.md` are byte-identical (same sha256 in
|
|
||||||
`.harness/manifest.json`). ~8 kB of the 49.5 kB unpacked payload is duplicate
|
|
||||||
content, and — worse than size — two copies must be kept in sync by hand.
|
|
||||||
|
|
||||||
### F8 (LOW, perf + portability): `which()` is uncached and shells out
|
|
||||||
|
|
||||||
`which()` runs up to twice per tool call (`python` then `python3`), each a
|
|
||||||
blocking `spawnSync`; the POSIX branch spawns a shell (`shell: true`). Results
|
|
||||||
are stable for the process lifetime and should be memoized; the lookup can be
|
|
||||||
done dep-free with a PATH scan instead of a shell.
|
|
||||||
|
|
||||||
### F9 (LOW, interop): dot-named tools + minimal protocol surface
|
|
||||||
|
|
||||||
Tool names (`ruview.onboard`, `ruview.claim_check`, …) contain dots. MCP itself
|
|
||||||
does not restrict names, but downstream host APIs commonly enforce
|
|
||||||
`^[a-zA-Z0-9_-]{1,64}$` for tool names; hosts must then sanitize or reject.
|
|
||||||
The server also answers `resources/list` / `prompts/list` with `-32601` (it does
|
|
||||||
not advertise those capabilities, so this is spec-legal, but empty-list stubs are
|
|
||||||
cheaper than every host's error path). Protocol version is pinned to
|
|
||||||
`2024-11-05` with no negotiation fallback. None of this breaks Claude Code today;
|
|
||||||
it narrows portability, which is the harness's whole pitch (9 hosts, ADR-182).
|
|
||||||
|
|
||||||
### F10 (LOW, CI gap): the published package has zero CI
|
|
||||||
|
|
||||||
No workflow under `.github/workflows/` runs `harness/ruview` tests (checked:
|
|
||||||
no workflow references `harness/ruview`, `ruview-mcp`, or `ruview-cli`), and
|
|
||||||
`ci.yml` pins `NODE_VERSION: '18'` while the package declares
|
|
||||||
`engines.node >= 20`. Note also `node --test test/` (directory form) fails on
|
|
||||||
Node 22 while the documented glob form passes — CI should pin the working
|
|
||||||
invocation. Consolidated CI/publish strategy is ADR-265.
|
|
||||||
|
|
||||||
### F11 (MEDIUM, guardrail precision): `METRIC_TERMS` substring matching false-positives on ordinary prose
|
|
||||||
|
|
||||||
Found by dogfooding this review: `claimCheck` matches metric terms with
|
|
||||||
`lower.includes(t)`, so the two-character terms `'map'` and `'f1'` fire inside
|
|
||||||
ordinary words and labels — "source **map**s", "the **map**s can never
|
|
||||||
resolve", finding IDs like "**F1** (HIGH…)". MEASURED reproducer: running
|
|
||||||
`npx ruview claim-check --file` over this ADR and ADR-264 yields 4 and 16
|
|
||||||
medium findings respectively, the majority of which are `map`/`F1`
|
|
||||||
false positives on lines carrying no accuracy claim. A guardrail that cries
|
|
||||||
wolf trains people to ignore it — precision is part of its fail-closed
|
|
||||||
contract. Short/ambiguous terms need word-boundary matching (`\bmap\b`,
|
|
||||||
`\bf1\b`, likewise `auc`, `iou`), and section-heading label patterns
|
|
||||||
(`F\d+`, `O\d+`) should not count as metric mentions.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Adopt the following optimization strategy, in priority order. Each item is
|
|
||||||
independently shippable; F-numbers map to findings.
|
|
||||||
|
|
||||||
- **O1 (F1):** `claim-check` with no `--text`/`--file` (or empty text after read)
|
|
||||||
exits 2 with a usage error. Add a regression test pinning exit ≠ 0.
|
|
||||||
- **O2 (F2):** make the MCP dispatch async: convert `run()`/`which()` to
|
|
||||||
promise-based `spawn`, make `tools/call` handlers `async`, and keep reading
|
|
||||||
stdin while calls run (respond to `ping`/`tools/list` concurrently; serialize
|
|
||||||
only same-tool hardware operations). Acceptance: `ping` round-trips < 50 ms
|
|
||||||
while a synthetic 30 s `calibrate` is in flight.
|
|
||||||
- **O3 (F3):** drop the two `optionalDependencies`; `doctor`/`install` already
|
|
||||||
degrade and should print the exact `npm i @metaharness/kernel
|
|
||||||
@metaharness/host-claude-code` hint on the miss path. Acceptance: cold
|
|
||||||
`npm i @ruvnet/ruview` installs exactly 1 package (MEASURED baseline above).
|
|
||||||
- **O4 (F4):** set `maxBuffer: 16 * 1024 * 1024` in `run()` (or stream + tail).
|
|
||||||
- **O5 (F5):** pass `port` to the monitor script via `sys.argv`
|
|
||||||
(`python -c script -- <port>`), never by source interpolation.
|
|
||||||
- **O6 (F6):** read the MCP `serverInfo.version` from `package.json` once at
|
|
||||||
startup (same pattern the CLI already uses).
|
|
||||||
- **O7 (F7):** make `skills/*.md` the single source and generate
|
|
||||||
`.claude/skills/*/SKILL.md` in a `prepack` script (or vice versa); manifest
|
|
||||||
hashes then pin one canonical set.
|
|
||||||
- **O8 (F8, F9):** memoize `which()`; add underscore aliases for the dot-named
|
|
||||||
tools (accept both in `tools/call`, advertise the underscore form) and add
|
|
||||||
empty `resources/list` / `prompts/list` stubs.
|
|
||||||
- **O9 (F11):** switch `METRIC_TERMS` matching to word-boundary regexes for
|
|
||||||
short terms (`map`, `f1`, `auc`, `iou`) and skip label tokens matching
|
|
||||||
`\b[FO]\d+\b`. Acceptance: `claim-check --file` over ADR-263/264/265 reports
|
|
||||||
only the genuinely tagged-or-taggable percentage lines, and the existing 17
|
|
||||||
guardrail tests still pass plus new false-positive pins ("source maps",
|
|
||||||
"F1 (HIGH)" → no finding).
|
|
||||||
|
|
||||||
Non-goals: no new runtime dependencies (the zero-dep MCP server is a feature,
|
|
||||||
not an accident — keep it), no build step, no change to the fail-closed tool
|
|
||||||
contracts.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- The honesty guardrail becomes fail-closed end-to-end (its current empty-input
|
|
||||||
pass is the exact failure mode the guardrail exists to prevent).
|
|
||||||
- `npx` cold start drops ~450 kB / 3 packages (MEASURED baseline in F3) with no
|
|
||||||
feature loss; `doctor` output already communicates the optional-dep story.
|
|
||||||
- Long-running `verify`/`calibrate` no longer starve the MCP channel — the
|
|
||||||
harness survives host health checks during real calibration runs.
|
|
||||||
- Two-copy skill drift becomes impossible at pack time.
|
|
||||||
- Costs: async conversion touches every handler signature in `src/tools.js`
|
|
||||||
(mechanical, ~6 handlers); alias tools add a small compatibility table.
|
|
||||||
- Verification for the implementing PR: bundled tests extended for O1/O2/O5
|
|
||||||
(target ≥ 20 tests), `npm pack --dry-run` file-count asserted, and the F3
|
|
||||||
install measurement re-run and quoted MEASURED in the PR body — which must
|
|
||||||
itself pass `npx ruview claim-check`.
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
# ADR-264: `@ruvnet/rvagent` MCP Server + `@ruv/ruview-cli` — Deep Review + Optimization Strategy
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|-------|-------|
|
|
||||||
| **Status** | Accepted — **implemented** (O1–O9, `@ruvnet/rvagent@0.2.0`): `exports` fixed (types-first, no phantom `.cjs`), map-free tarball (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, from 188 kB), Streamable HTTP **wired** behind `RVAGENT_HTTP_PORT` with per-session transports + 1 MiB body cap + port-aware origin gate, underscore tool names with dotted router aliases, single Zod validation gate with generated JSON Schemas, fd-leak fixed + persisted job records + bounded log tails, probing `detectCogBinary`, package.json-sourced version, `ruview-cli` bin renamed. 99/99 jest tests (MEASURED); both transports smoke-tested live |
|
|
||||||
| **Date** | 2026-07-02 |
|
|
||||||
| **Deciders** | ruv |
|
|
||||||
| **Codename** | **RUVIEW-NPM-REVIEW-2** |
|
|
||||||
| **Supersedes / amends** | none (reviews the ADR-104/ADR-124 artifacts; feeds ADR-265 distribution strategy) |
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Two TypeScript npm packages expose RuView sensing to agents and shells:
|
|
||||||
|
|
||||||
- **`@ruvnet/rvagent@0.1.0`** (`tools/ruview-mcp/`) — SENSE-BRIDGE, the MCP
|
|
||||||
server over the sensing-server HTTP API + cog binaries: 12 tools
|
|
||||||
(csi/pose/count/registry/train/job + ADR-124 BFLD/presence/vitals). Published
|
|
||||||
(188 kB unpacked — MEASURED, `npm view @ruvnet/rvagent`). Deps:
|
|
||||||
`@modelcontextprotocol/sdk` + `zod`.
|
|
||||||
- **`@ruv/ruview-cli@0.0.1`** (`tools/ruview-cli/`) — `private: true` yargs CLI
|
|
||||||
mirroring the same capabilities; intentionally duplicates `http.ts`/`cog.ts`/
|
|
||||||
`config.ts` (~150 lines) to stay standalone.
|
|
||||||
|
|
||||||
This ADR records a deep review of both: packaging correctness (verified against
|
|
||||||
the **published** tarball, not just the source tree), protocol/interop, resource
|
|
||||||
lifecycle, and the honesty of the package's own self-description — the same
|
|
||||||
MEASURED-vs-CLAIMED bar the project applies to accuracy numbers.
|
|
||||||
|
|
||||||
## Findings
|
|
||||||
|
|
||||||
### F1 (HIGH, broken export): `require` condition points at a file that does not exist
|
|
||||||
|
|
||||||
`package.json` `exports["."].require = "./dist/index.cjs"`, but the build is
|
|
||||||
plain `tsc` (ESM only) and **the published 0.1.0 tarball contains no
|
|
||||||
`index.cjs`** (verified by listing the registry tarball). Any CJS consumer doing
|
|
||||||
`require('@ruvnet/rvagent')` resolves to a nonexistent file →
|
|
||||||
`ERR_MODULE_NOT_FOUND`. Additionally the `types` condition is listed **after**
|
|
||||||
`import`/`require`; TypeScript requires `types` first or it may be ignored under
|
|
||||||
`moduleResolution: bundler/node16`.
|
|
||||||
|
|
||||||
### F2 (MEDIUM, tarball bloat): a third of the published package is dead source maps
|
|
||||||
|
|
||||||
The 0.1.0 tarball ships **44 `.map` files = 62,698 B** against 78,209 B of
|
|
||||||
actual `.js` (MEASURED, extracted registry tarball). `src/` is not published, so
|
|
||||||
every `sourceMappingURL` points at `../src/*.ts` that consumers do not have —
|
|
||||||
the maps can never resolve. Also `files` lists `CHANGELOG.md`, which does not
|
|
||||||
exist in `tools/ruview-mcp/` (npm silently skips it), so the advertised file set
|
|
||||||
is partly fictional.
|
|
||||||
|
|
||||||
### F3 (MEDIUM, honesty): the package description claims a transport it does not start
|
|
||||||
|
|
||||||
The description reads "**dual-transport MCP server (stdio + Streamable HTTP)**",
|
|
||||||
but `main()` in `src/index.ts` wires **stdio only**. `http-transport.ts` is a
|
|
||||||
complete, tested scaffold that nothing imports at runtime — there is no flag,
|
|
||||||
env var, or subcommand that starts it. By this project's own rule this is a
|
|
||||||
CLAIMED capability presented as shipped. Either wire it (`--http` /
|
|
||||||
`RVAGENT_HTTP_PORT` gate) or de-claim the description until it is.
|
|
||||||
|
|
||||||
### F4 (MEDIUM, interop + inconsistency): two tool-naming conventions, one of them dot-based
|
|
||||||
|
|
||||||
Six tools use `ruview_snake_case`; six (ADR-124 additions) use
|
|
||||||
`ruview.dotted.names`. Same interop caveat as ADR-263 F9 (host tool-name
|
|
||||||
regexes commonly `^[a-zA-Z0-9_-]{1,64}$`), plus the split convention makes the
|
|
||||||
tool surface look like two products. Standardize on underscores and accept the
|
|
||||||
dotted forms as aliases for one deprecation cycle.
|
|
||||||
|
|
||||||
### F5 (MEDIUM, double work + drift): every tool input is validated twice from two hand-maintained schemas
|
|
||||||
|
|
||||||
`CallToolRequestSchema` handler runs `TOOL_INPUT_SCHEMAS[name].safeParse(args)`,
|
|
||||||
then each tool handler runs its own `schema.parse(args)` again — two full Zod
|
|
||||||
passes per call. Separately, the `inputSchema` JSON advertised via `tools/list`
|
|
||||||
is **hand-written** and duplicates the Zod schema field-by-field (defaults,
|
|
||||||
min/max, descriptions) — schema drift between what is advertised and what is
|
|
||||||
enforced is a matter of time. Parse once at the gate, pass the typed result to
|
|
||||||
handlers, and generate the advertised JSON Schema from the Zod source
|
|
||||||
(`zod-to-json-schema` at build time, or Zod 4's native `z.toJSONSchema` when the
|
|
||||||
SDK's peer range allows).
|
|
||||||
|
|
||||||
### F6 (MEDIUM, resource lifecycle): `train_count` leaks 2 fds per job; job registry is process-local
|
|
||||||
|
|
||||||
`trainCount` opens `logFdOut`/`logFdErr` with `openSync` and never closes them
|
|
||||||
in the parent — the spawned cargo child inherits duplicates, but the parent's
|
|
||||||
descriptors stay open for the MCP server's lifetime: 2 leaked fds per training
|
|
||||||
job. `jobRegistry` is an in-memory `Map`, so `ruview_job_status` after a server
|
|
||||||
restart reports "not found" for a training run that is still burning GPU (the
|
|
||||||
source comments acknowledge this; the fix — persist `~/.ruview/jobs/<id>.json`,
|
|
||||||
already the documented layout — is small). Also `jobStatus` re-`import`s
|
|
||||||
`node:fs` on every poll and reads the entire log to return 20 lines.
|
|
||||||
|
|
||||||
### F7 (MEDIUM, security/robustness of the HTTP scaffold): unbounded body + one shared session transport
|
|
||||||
|
|
||||||
`http-transport.ts` buffers the request body with no size cap (memory DoS the
|
|
||||||
moment it is wired to a socket), reuses a **single**
|
|
||||||
`StreamableHTTPServerTransport` with `sessionIdGenerator` for all clients (the
|
|
||||||
SDK's stateful mode expects one transport per session — a second client's
|
|
||||||
`initialize` collides), and the Origin allowlist is exact-match
|
|
||||||
(`http://localhost` will not match a real browser origin `http://localhost:5173`).
|
|
||||||
Must be fixed **before** F3 wires it in; bearer-token + 127.0.0.1 defaults are
|
|
||||||
already right.
|
|
||||||
|
|
||||||
### F8 (LOW, dead/misleading code): `detectCogBinary` always returns the bare name
|
|
||||||
|
|
||||||
It builds a 4-candidate appliance-path array and then returns
|
|
||||||
`candidates[candidates.length - 1]` — i.e. always `name` — without checking
|
|
||||||
existence. The candidates are dead weight that reads as if path detection
|
|
||||||
happens. Either probe with `existsSync` or delete the array.
|
|
||||||
|
|
||||||
### F9 (LOW, drift + hygiene): hardcoded versions, unused/mismatched devDeps, bin-name collision
|
|
||||||
|
|
||||||
`PACKAGE_VERSION = "0.1.0"` (index.ts) duplicates package.json;
|
|
||||||
`@types/express` is unused (`http-transport` uses `node:http`); `@types/jest@30`
|
|
||||||
against `jest@29`; `ruview-cli` hardcodes `.version("0.0.1")`. And
|
|
||||||
`@ruv/ruview-cli` claims the **`ruview`** bin name, which collides with
|
|
||||||
`@ruvnet/ruview`'s bin (ADR-182) if both are ever installed globally —
|
|
||||||
ADR-263/265 give the `ruview` name to the harness; the CLI must rename or fold.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
- **O1 (F1):** fix `exports`: drop the `require` condition (ESM-only is fine for
|
|
||||||
a bin-first package) or add a real CJS build; put `types` first. Add a CI
|
|
||||||
smoke test that does `npm pack` + `node -e "import('<tarball install>')"`.
|
|
||||||
- **O2 (F2):** publish without maps: `declarationMap: false`, `sourceMap: false`
|
|
||||||
in a `tsconfig.build.json` used by `prepack` (or add `!dist/**/*.map` to
|
|
||||||
`files`). Remove the phantom `CHANGELOG.md` entry or create the file.
|
|
||||||
Acceptance: unpacked size ≤ ~125 kB (from 188 kB — MEASURED, `npm pack --dry-run`).
|
|
||||||
- **O3 (F3, F7):** wire the HTTP transport behind an explicit opt-in
|
|
||||||
(`RVAGENT_HTTP_PORT` or `--http`), after F7 fixes: per-session transport map
|
|
||||||
keyed by `mcp-session-id`, 1 MiB body cap, origin matching that honors ports
|
|
||||||
(compare `URL.origin` prefixes or document exact origins). Until then, change
|
|
||||||
the description to "stdio MCP server (Streamable HTTP scaffold, unwired)".
|
|
||||||
- **O4 (F4):** rename dotted tools to underscore (`ruview_bfld_last_scan`, …),
|
|
||||||
keep dotted aliases in the call router for one release, note it in the README.
|
|
||||||
- **O5 (F5):** single validation gate: the registry maps name → Zod schema →
|
|
||||||
typed handler; advertised `inputSchema` generated from Zod at build time.
|
|
||||||
- **O6 (F6):** close parent fds after spawn (`closeSync` post-`spawn` — the
|
|
||||||
child holds its own copies), persist job records to
|
|
||||||
`<jobsDir>/<id>.json`, and read log tails with a bounded read.
|
|
||||||
- **O7 (F8):** make `detectCogBinary` actually probe (`existsSync` over the
|
|
||||||
candidates) — it is the entire reason the function exists.
|
|
||||||
- **O8 (F9):** single-source versions from package.json; drop `@types/express`;
|
|
||||||
align `@types/jest` with jest 29 (or move to `node:test` like the harness and
|
|
||||||
drop the jest toolchain entirely — it is the heaviest devDep in both
|
|
||||||
packages).
|
|
||||||
- **O9 (F9, scope):** fold `@ruv/ruview-cli` into `rvagent` as a second bin
|
|
||||||
(`rvagent-cli`) sharing `http/cog/config`, or keep it private-forever and say
|
|
||||||
so in its README. Its `ruview` bin name is surrendered to `@ruvnet/ruview`
|
|
||||||
either way.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- CJS consumers stop hitting a guaranteed-broken export path (F1 is the only
|
|
||||||
finding that fails for every consumer of that entry point deterministically).
|
|
||||||
- The published artifact shrinks ~33% (MEASURED, F2 tarball listing: 62,698 B
|
|
||||||
of maps in a 188 kB unpacked payload) and stops advertising files/transports
|
|
||||||
it does not contain — the package description itself passes the project's
|
|
||||||
claim-check bar.
|
|
||||||
- One schema source ends advertised-vs-enforced drift and halves per-call
|
|
||||||
validation cost; naming unification makes the 12-tool surface read as one
|
|
||||||
product and survive strict host tool-name validation.
|
|
||||||
- Long-lived MCP servers stop accumulating fds during training campaigns, and
|
|
||||||
job polling survives restarts.
|
|
||||||
- Costs: the alias cycle (O4) briefly doubles the advertised tool count unless
|
|
||||||
aliases are router-only (recommended: router-only, advertise underscore names
|
|
||||||
exclusively); folding the CLI (O9) retires a package name already in use in
|
|
||||||
scripts, so it needs a deprecation note.
|
|
||||||
- Verification for the implementing PR: `npm pack --dry-run` asserted file list
|
|
||||||
(no `.map`, no phantom entries), pack-size budget in CI (ADR-265), jest/`node
|
|
||||||
--test` suite green, and a tarball-install smoke test for both `import` and
|
|
||||||
the `rvagent` bin.
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
# ADR-265: RuView npm Distribution Strategy — CI Gate, Provenance, Version Single-Sourcing, Namespace
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|-------|-------|
|
|
||||||
| **Status** | Accepted — **D1–D4 implemented**: `.github/workflows/npm-packages.yml` (matrix gate: tests, version-literal grep, pack-content/size gate, tarball-install smoke test, README claim-check), `.github/workflows/ruview-npm-release.yml` (publish-from-CI with `npm publish --provenance`), version single-sourcing (all three packages read package.json), `ruview` bin owned by `@ruvnet/ruview` (`@ruv/ruview-cli` bin renamed `ruview-cli`), `ci.yml` NODE_VERSION 18→20. D5 (no workspace) stands as recorded |
|
|
||||||
| **Date** | 2026-07-02 |
|
|
||||||
| **Deciders** | ruv |
|
|
||||||
| **Codename** | **RUVIEW-NPM-DIST** |
|
|
||||||
| **Supersedes / amends** | none (cross-cutting layer above ADR-263 and ADR-264; complements ADR-182 P3/P4) |
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The monorepo now ships (or stages) **three Node packages** with no shared
|
|
||||||
distribution engineering:
|
|
||||||
|
|
||||||
| Package | Dir | Published | Bin(s) | Tests in CI |
|
|
||||||
|---------|-----|-----------|--------|-------------|
|
|
||||||
| `@ruvnet/ruview` | `harness/ruview/` | 0.1.0 (live) | `ruview` | **none** |
|
|
||||||
| `@ruvnet/rvagent` | `tools/ruview-mcp/` | 0.1.0 (live) | `rvagent`, `ruview-mcp` | **none** |
|
|
||||||
| `@ruv/ruview-cli` | `tools/ruview-cli/` | private | `ruview` (collides) | **none** |
|
|
||||||
|
|
||||||
Cross-cutting facts established during the ADR-263/264 reviews:
|
|
||||||
|
|
||||||
- **Zero CI coverage.** No workflow under `.github/workflows/` references any of
|
|
||||||
the three directories. Two of the packages are *live on the registry* and were
|
|
||||||
published from a laptop state CI never saw. Meanwhile the Rust side has a
|
|
||||||
1,031+-test gate and a witness-bundle culture (ADR-028) — the npm surface is
|
|
||||||
the only shipped artifact class with no verification gate at all.
|
|
||||||
- **`ci.yml` pins `NODE_VERSION: '18'`** while all three packages declare
|
|
||||||
`engines.node >= 20`.
|
|
||||||
- **Version triplication.** Each package hardcodes its version in source at
|
|
||||||
least once beyond package.json (harness `SERVER_INFO`, rvagent
|
|
||||||
`PACKAGE_VERSION`, cli `.version("0.0.1")`).
|
|
||||||
- **Bin-name collision.** Two packages claim the `ruview` bin.
|
|
||||||
- **No provenance.** Neither published package carries npm provenance
|
|
||||||
attestations, in a project whose differentiator is signed, reproducible
|
|
||||||
evidence (ADR-028 witness bundles, ADR-182 P4 ed25519/SLSA design).
|
|
||||||
- **No pack-content gate.** ADR-264 F1/F2 (broken `require` target, 33% dead map weight — MEASURED, tarball listing — and a phantom
|
|
||||||
`CHANGELOG.md` in `files`) are exactly the defect class an
|
|
||||||
`npm pack --dry-run` assertion catches in seconds.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Adopt one distribution layer for all Node packages. Per-package code fixes live
|
|
||||||
in ADR-263/264; this ADR fixes the machinery around them.
|
|
||||||
|
|
||||||
### D1 — One `npm-packages.yml` CI workflow (the gate)
|
|
||||||
|
|
||||||
Matrix over `[harness/ruview, tools/ruview-mcp, tools/ruview-cli]` ×
|
|
||||||
Node `[20, 22]`:
|
|
||||||
|
|
||||||
1. `npm ci` where a lockfile is committed (the TS packages); the harness
|
|
||||||
installs with `npm install` — repo policy gitignores lockfiles under
|
|
||||||
`harness/`, and the package is dependency-free after ADR-263 O3 so there is
|
|
||||||
nothing to pin.
|
|
||||||
2. `npm test` (harness: `node --test test/*.test.mjs` — pin the glob form,
|
|
||||||
the directory form fails on Node 22; TS packages: build + jest or `node:test`
|
|
||||||
per ADR-264 O8).
|
|
||||||
3. **Pack gate:** `npm pack --dry-run --json` asserted against a checked-in
|
|
||||||
expected file list + a max unpacked-size budget per package (harness ≤ 60 kB;
|
|
||||||
rvagent ≤ 130 kB post ADR-264 O2). Any new/missing/renamed shipped file is a
|
|
||||||
reviewed diff, not a surprise.
|
|
||||||
4. **Tarball smoke test:** install the packed tarball into a temp dir; run
|
|
||||||
`ruview --version`, `ruview doctor`, `rvagent` `--help`-equivalent, and a
|
|
||||||
Node `import()` of each declared export condition — this is the test that
|
|
||||||
would have caught ADR-264 F1 (`require` → nonexistent `dist/index.cjs`).
|
|
||||||
5. Bump `ci.yml` `NODE_VERSION` to `'20'` (independent of the matrix above).
|
|
||||||
|
|
||||||
### D2 — Publish only from CI, with provenance
|
|
||||||
|
|
||||||
Manual `npm publish` from laptops stops. A tag-triggered workflow
|
|
||||||
(`ruview-npm-release.yml`, mirroring the firmware release discipline) runs the
|
|
||||||
D1 gate, then `npm publish --provenance --access public` under the GitHub OIDC
|
|
||||||
token. Consequence: every published version is attested to a public commit +
|
|
||||||
workflow run — the npm-side analogue of the ADR-028 witness bundle. The
|
|
||||||
`prepublishOnly` script in each package runs the pack gate locally as a
|
|
||||||
belt-and-braces (publishing outside CI fails loudly, not silently).
|
|
||||||
|
|
||||||
### D3 — Version single-sourcing
|
|
||||||
|
|
||||||
Rule: **package.json is the only place a version string lives.** Runtime code
|
|
||||||
reads it (`createRequire(import.meta.url)('./package.json').version` or a
|
|
||||||
build-time define for the TS packages). CI greps for `\d+\.\d+\.\d+` literals in
|
|
||||||
`src/` of each package and fails on match (allowlist: test fixtures). This
|
|
||||||
retires ADR-263 F6 and ADR-264 F9 permanently instead of per-incident.
|
|
||||||
|
|
||||||
### D4 — Namespace and bin ownership
|
|
||||||
|
|
||||||
- `@ruvnet/ruview` **owns the `ruview` bin** (it is the published front door,
|
|
||||||
ADR-182). `@ruv/ruview-cli` renames its bin or folds into `rvagent`
|
|
||||||
(ADR-264 O9) — decided here so neither package ADR relitigates it.
|
|
||||||
- New Node packages in this repo use the `@ruvnet/` scope (the `@ruv/` scope
|
|
||||||
holds `rvcsi` legacies; do not grow it).
|
|
||||||
- Every package README + description must pass
|
|
||||||
`npx ruview claim-check` — enforced in the D1 gate. The guardrail package
|
|
||||||
linting its sibling packages' claims is the cheapest dogfooding we have
|
|
||||||
(ADR-264 F3 is the standing example of why).
|
|
||||||
|
|
||||||
### D5 — Shared-code policy (bounded)
|
|
||||||
|
|
||||||
Do **not** introduce an npm workspace or a shared runtime package yet: three
|
|
||||||
packages, two of which may merge (ADR-264 O9), do not justify workspace
|
|
||||||
machinery, and the harness's zero-dep property is load-bearing. Revisit if a
|
|
||||||
fourth package appears or if the `http/cog/config` duplication survives the
|
|
||||||
ADR-264 O9 fold. Record the duplication as intentional in each file header (the
|
|
||||||
CLI already does this).
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- The npm artifacts get the same class of gate the Rust workspace has had since
|
|
||||||
ADR-028: no publish without tests, no shipped file set without an asserted
|
|
||||||
manifest, no version without provenance. The two defects that reached the
|
|
||||||
registry (broken `require` condition, dead maps) become CI-impossible.
|
|
||||||
- Cold-path costs stay near zero: the D1 matrix is 6 fast jobs (the harness
|
|
||||||
suite runs in ~108 ms MEASURED; TS builds dominate at a few tens of seconds).
|
|
||||||
- Publishing gains one constraint (must go through CI) and loses one failure
|
|
||||||
mode (laptop-state publishes) — the right trade for a project whose brand is
|
|
||||||
reproducible evidence.
|
|
||||||
- D3's grep gate is blunt but cheap; if it over-fires, scope it to
|
|
||||||
`version`-adjacent identifiers before weakening it.
|
|
||||||
- Follow-ups tracked elsewhere: per-package code fixes (ADR-263 O1–O8, ADR-264
|
|
||||||
O1–O9); ADR-182 P4 (metaharness router + ed25519 provenance chain) remains
|
|
||||||
the deeper provenance story that D2's npm attestations complement, not
|
|
||||||
replace.
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
# ADR-266: Multi-Actor PoseCode Scenes
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|-------|-------|
|
|
||||||
| Status | Accepted, crate implementation complete |
|
|
||||||
| Date | 2026-07-15 |
|
|
||||||
| Deciders | ruv |
|
|
||||||
| Crate | `wifi-densepose-posecode` |
|
|
||||||
| Related | ADR-029, ADR-037, ADR-082, ADR-101, ADR-170 |
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
RuView already maintains multiple persistent `PoseTrack` aggregates. Each track
|
|
||||||
contains 17 COCO keypoints, a lifecycle, an AETHER identity embedding and a
|
|
||||||
stable `TrackId`. The missing layer is a compact, agent-readable representation
|
|
||||||
of what those tracked people do together over time.
|
|
||||||
|
|
||||||
PoseCode 0.1 provides a useful vocabulary for one actor, sequential movement
|
|
||||||
phases, semantic joint actions, contacts and browser rendering. Its current
|
|
||||||
protocol does not represent multiple actors, shared world coordinates,
|
|
||||||
inter-person contact, observation confidence or track provenance.
|
|
||||||
|
|
||||||
Encoding every 20 Hz RuView frame as text would also be incorrect. It would
|
|
||||||
produce 1,200 steps per person per minute, discard observation uncertainty and
|
|
||||||
couple the sensing hot path to a presentation language.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Create the leaf crate `wifi-densepose-posecode` and define a backwards-minded
|
|
||||||
0.2 scene extension with these invariants:
|
|
||||||
|
|
||||||
1. A `Scene` owns named actors and synchronized phases.
|
|
||||||
2. Each actor can carry the originating RuView `TrackId` and confidence.
|
|
||||||
3. Every joint target and inter-person contact carries confidence.
|
|
||||||
4. Actor positions and travel use one room-calibrated three-dimensional frame.
|
|
||||||
5. Raw `SceneFrame` observations remain separate from compact semantic phases.
|
|
||||||
6. `PhaseSegmenter` emits a phase on actor membership change, motion settling
|
|
||||||
or a bounded maximum duration.
|
|
||||||
7. Serialization is canonical and deterministic.
|
|
||||||
8. The parser has hard document, line, actor, phase and target limits.
|
|
||||||
9. General range-of-motion violations are errors for authored scenes but only
|
|
||||||
warnings for observed WiFi scenes. RF pose estimates are not medical joint
|
|
||||||
measurements and must not create false safety claims.
|
|
||||||
|
|
||||||
The text form is:
|
|
||||||
|
|
||||||
```posecode
|
|
||||||
posecode scene "Assisted squat"
|
|
||||||
source observed_wifi_csi
|
|
||||||
actor patient:
|
|
||||||
rig humanoid
|
|
||||||
pose start = standing
|
|
||||||
track 7
|
|
||||||
confidence 0.82
|
|
||||||
position 0 0 0
|
|
||||||
actor therapist:
|
|
||||||
rig humanoid
|
|
||||||
pose start = standing
|
|
||||||
track 12
|
|
||||||
confidence 0.76
|
|
||||||
position 1.2 0 0
|
|
||||||
step "Lower" 1.5s flow:
|
|
||||||
patient.knee_left: flex 95 0.8
|
|
||||||
therapist.shoulder_left: flex 30 0.7
|
|
||||||
contact therapist.hand_left patient.shoulder_right 0.7
|
|
||||||
repeat 1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
`RuViewAdapter` consumes references to existing `PoseTrack` values. It does not
|
|
||||||
duplicate assignment or identity tracking. It calculates elbows and knees from
|
|
||||||
three-point interior angles, estimates hip and shoulder sagittal movement in a
|
|
||||||
calibrated coordinate frame and maps the hip midpoint to actor position.
|
|
||||||
The adapter is exposed by the crate's `ruview` feature so the protocol, parser
|
|
||||||
and segmenter remain usable without pulling the full signal and RuVector graph.
|
|
||||||
|
|
||||||
The adapter uses keypoint confidence when available. Current trackers that have
|
|
||||||
not populated that field receive an explicit configurable fallback confidence,
|
|
||||||
degraded by track staleness. Non-finite coordinates and terminated tracks fail
|
|
||||||
closed.
|
|
||||||
|
|
||||||
`PhaseSegmenter` consumes ordered frames. High-rate frames remain available for
|
|
||||||
live rendering and evidence storage while the resulting scene records only
|
|
||||||
meaningful synchronized targets. This preserves both fidelity and readability.
|
|
||||||
|
|
||||||
## Security and Resource Bounds
|
|
||||||
|
|
||||||
The public parser accepts at most 1 MiB per document and 4,096 bytes per line.
|
|
||||||
The scene validator defaults to 32 actors, 10,000 phases and 64 joint targets
|
|
||||||
per actor per phase. All coordinates, angles and confidence values must be
|
|
||||||
finite. References to undeclared actors are rejected. Durations, repeats and
|
|
||||||
timeline addition are bounded.
|
|
||||||
|
|
||||||
No network, filesystem, model or renderer capability is present in the crate.
|
|
||||||
It is a deterministic transformation leaf over owned data.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
RuView can now produce privacy-preserving multi-person replays, structured fall
|
|
||||||
and interaction evidence, exercise comparison inputs and agent-readable motion
|
|
||||||
records without video.
|
|
||||||
|
|
||||||
The crate does not solve RF source separation. Its multi-person accuracy is
|
|
||||||
bounded by the upstream pose model and `PoseTracker`. Contact inference is also
|
|
||||||
not fabricated; contacts must be observed by a future calibrated classifier or
|
|
||||||
authored explicitly.
|
|
||||||
|
|
||||||
The current COCO skeleton has no toe keypoints, so ankle rotation and precise
|
|
||||||
foot contact cannot be inferred honestly. Those targets are omitted rather
|
|
||||||
than guessed.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
1. A scene containing two actors, simultaneous targets and a cross-actor
|
|
||||||
contact parses and serializes deterministically.
|
|
||||||
2. Unknown actors, non-finite values, reversed timestamps and unbounded inputs
|
|
||||||
fail closed.
|
|
||||||
3. One RuView track converts to finite semantic targets with the same TrackId.
|
|
||||||
4. Observed out-of-range angles produce warnings, not medical errors.
|
|
||||||
5. Phase count is smaller than input frame count for a normal motion sequence.
|
|
||||||
6. Focused crate tests and the complete workspace test suite pass before merge.
|
|
||||||
|
|
||||||
## Measured Results
|
|
||||||
|
|
||||||
On the implementation container with Rust 1.88, Criterion measured the canonical
|
|
||||||
two-actor scene parser at 3.40 microseconds and serializer at 3.29 microseconds
|
|
||||||
per operation. The protocol-only dependency surface is three runtime crates:
|
|
||||||
`serde`, `serde_json` and `thiserror`.
|
|
||||||
|
|
||||||
The protocol-only configuration passes 13 unit tests, documentation tests,
|
|
||||||
Rustfmt and Clippy with warnings denied. The `ruview` feature passes 14 unit
|
|
||||||
tests, including native `PoseTrack` conversion, under the repository's RuVector
|
|
||||||
AVX512-capable build configuration.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
1. PoseCode repository and protocol, MIT licensed:
|
|
||||||
<https://github.com/posecode-dev/posecode>
|
|
||||||
2. RuView multi-person pose decision: ADR-037.
|
|
||||||
3. RuView persistent pose tracking: ADR-029 and `pose_tracker.rs`.
|
|
||||||
+1
-5
@@ -1,6 +1,6 @@
|
|||||||
# Architecture Decision Records
|
# Architecture Decision Records
|
||||||
|
|
||||||
This folder contains 183 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 contains 45 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project.
|
||||||
|
|
||||||
## Why ADRs?
|
## Why ADRs?
|
||||||
|
|
||||||
@@ -120,10 +120,6 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
|||||||
| [ADR-097](ADR-097-adopt-rvcsi-as-ruview-csi-runtime.md) | Adopt rvCSI as RuView's primary CSI runtime (phased adoption) | Proposed |
|
| [ADR-097](ADR-097-adopt-rvcsi-as-ruview-csi-runtime.md) | Adopt rvCSI as RuView's primary CSI runtime (phased adoption) | Proposed |
|
||||||
| [ADR-098](ADR-098-evaluate-midstream-fit.md) | Evaluate `ruvnet/midstream` for RuView's CSI / WebSocket / mesh pipeline | Rejected |
|
| [ADR-098](ADR-098-evaluate-midstream-fit.md) | Evaluate `ruvnet/midstream` for RuView's CSI / WebSocket / mesh pipeline | Rejected |
|
||||||
| [ADR-099](ADR-099-midstream-introspection-tap.md) | Adopt midstream as RuView's real-time introspection + low-latency tap | Proposed |
|
| [ADR-099](ADR-099-midstream-introspection-tap.md) | Adopt midstream as RuView's real-time introspection + low-latency tap | Proposed |
|
||||||
| [ADR-263](ADR-263-ruview-npm-harness-deep-review.md) | `@ruvnet/ruview` npm harness — deep review + optimization strategy | Proposed |
|
|
||||||
| [ADR-264](ADR-264-rvagent-mcp-and-cli-npm-deep-review.md) | `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` — deep review + optimization strategy | Proposed |
|
|
||||||
| [ADR-265](ADR-265-ruview-npm-distribution-strategy.md) | RuView npm distribution strategy — CI gate, provenance, version single-sourcing, namespace | Proposed |
|
|
||||||
| [ADR-266](ADR-266-multi-actor-posecode-scenes.md) | Multi-actor PoseCode scenes from persistent RuView tracks | Accepted |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
# WiFlow Browser Trainer (`wiflow_browser.html`)
|
|
||||||
|
|
||||||
A **single self-contained HTML page** that does the entire camera-supervised
|
|
||||||
WiFi-pose loop **in your browser, in your laptop camera's coordinate frame**, as
|
|
||||||
a **4-stage gated flow** with a progress stepper (each stage unlocks the next):
|
|
||||||
|
|
||||||
0. **CALIBRATE** *(ADR-151 empty-room baseline)* — you step OUT of the space; the
|
|
||||||
page captures ~10 s of the quiescent CSI and computes a per-feature running
|
|
||||||
**mean + std (Welford)** over the 410-d vector. Every CSI vector afterwards is
|
|
||||||
expressed as **deviation from baseline**
|
|
||||||
(`x_norm = (x − base_mean) / (base_std + ε)`), so a body's perturbation stands
|
|
||||||
out from the static channel. Persisted to IndexedDB. *Can't capture without it.*
|
|
||||||
1. **CAPTURE** — MediaPipe Pose runs on your laptop camera → 17 COCO keypoints
|
|
||||||
(the *label*), paired with the **baseline-normalized** 410-d ESP32 CSI vector
|
|
||||||
(the *input*). A **guided, balanced routine** cycles big on-screen prompts
|
|
||||||
(stand / turn / walk / arms / crouch / sit / reach) with a countdown, and a
|
|
||||||
**per-pose coverage meter** so you build a balanced dataset, not 2 000 frames
|
|
||||||
of standing.
|
|
||||||
2. **TRAIN** — a TensorFlow.js MLP learns `CSI → pose` in-browser. Honest
|
|
||||||
held-out PCK@0.10 / PCK@0.05 / MPJPE, plus a **mean-pose baseline** the model
|
|
||||||
must beat (the project's whole ethos — no baseline-beating signal, it says so).
|
|
||||||
*Can't train with <200 samples.*
|
|
||||||
3. **INFER** — the trained model drives a skeleton **from WiFi CSI only**
|
|
||||||
(baseline-normalized → standardized → model), drawn over the **same** camera
|
|
||||||
frame it trained in — so the inferred skeleton **aligns** with the camera
|
|
||||||
image. That alignment is the entire point of doing this in-browser instead of
|
|
||||||
with a separate Python camera. *Can't infer without a model.*
|
|
||||||
|
|
||||||
## Why in-browser
|
|
||||||
|
|
||||||
The Python pipeline (`wiflow_capture.py` → `wiflow_train.py` → `wiflow_infer.py`)
|
|
||||||
proved the signal is real (held-out PCK@0.10 ≈ 59.5% vs a 50% mean-pose baseline
|
|
||||||
= +9.4 pp). But it trained in a *different* camera's frame, so the inferred
|
|
||||||
skeleton never lined up with the laptop camera. Doing capture + train + infer all
|
|
||||||
in the browser with the **same** camera makes the training frame and the
|
|
||||||
inference frame identical → the skeleton aligns.
|
|
||||||
|
|
||||||
## Compute backends (WebGPU / WASM / WebGL)
|
|
||||||
|
|
||||||
Training and inference run on TensorFlow.js. The page selects the backend at
|
|
||||||
startup, preferring the fastest available:
|
|
||||||
|
|
||||||
- **WebGPU** (Chrome / Edge, secure context — `localhost` qualifies) — GPU compute.
|
|
||||||
- **WASM-SIMD** fallback (`tfjs-backend-wasm`, SIMD enabled, `.wasm` from the CDN).
|
|
||||||
- **WebGL** last-resort fallback (ships inside tfjs core).
|
|
||||||
|
|
||||||
The **active backend is shown as a badge in the header** (`compute: WebGPU` /
|
|
||||||
`WASM-SIMD` / `WebGL`) so it's honest about what's actually running. The model
|
|
||||||
code is backend-agnostic — tf.js abstracts the device.
|
|
||||||
|
|
||||||
## Honesty (baked in)
|
|
||||||
|
|
||||||
- The **CAPTURE** skeleton (blue) is the camera = ground truth, labeled as such.
|
|
||||||
- The **INFER** skeleton (green) is **CSI-only**, labeled, and **coarse** — the
|
|
||||||
real measured held-out PCK is shown, not a marketing number.
|
|
||||||
- The **mean-pose baseline** is always computed and shown in TRAIN; the verdict
|
|
||||||
states plainly whether the model **beats** it (real signal) or **does not**
|
|
||||||
(no usable signal). This guards against the project's retracted 92.9% that
|
|
||||||
failed exactly this check.
|
|
||||||
- Status banner is strict and mutually exclusive:
|
|
||||||
**LIVE** (real `source: "esp32"`) / **SIMULATED — not real** (any other source)
|
|
||||||
/ **NO-CSI-SERVER**. The page never invents frames.
|
|
||||||
|
|
||||||
## How to run
|
|
||||||
|
|
||||||
### 1. Start the real sensing-server (provides the CSI WebSocket on :8765)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd v2
|
|
||||||
cargo build -p wifi-densepose-sensing-server
|
|
||||||
./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005
|
|
||||||
```
|
|
||||||
|
|
||||||
A real ESP32-S3 must be provisioned and streaming for `source` to read `esp32`
|
|
||||||
(see `CLAUDE.local.md` for the firmware build/provision steps). The page expects
|
|
||||||
the verified live endpoint **`ws://localhost:8765/ws/sensing`** with
|
|
||||||
`source:"esp32"`, nodes `[9, 13]`, `features.*`, `node_features[].features.*`,
|
|
||||||
and `signal_field.values` (400 floats).
|
|
||||||
|
|
||||||
### 2. Serve this page over localhost (camera + WebGPU need a localhost/secure origin)
|
|
||||||
|
|
||||||
Any static localhost server works. For example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m http.server 8099
|
|
||||||
# then open: http://localhost:8099/examples/through-wall/wiflow_browser.html
|
|
||||||
```
|
|
||||||
|
|
||||||
(8099 is just the static file server — 8765 is a separate process, the CSI
|
|
||||||
WebSocket.) Allow camera access when the browser prompts.
|
|
||||||
|
|
||||||
Point at a CSI server on another host with `?ws=`:
|
|
||||||
|
|
||||||
```
|
|
||||||
http://localhost:8099/examples/through-wall/wiflow_browser.html?ws=ws://192.168.1.20:8765/ws/sensing
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Use it
|
|
||||||
|
|
||||||
1. **CAPTURE** tab → *enable laptop camera* → *start recording*. Follow the guided
|
|
||||||
routine (stand / turn / walk / arms / crouch / sit). A pair is stored only when
|
|
||||||
a confident pose AND a fresh live `esp32` CSI frame coexist. Aim for a few
|
|
||||||
thousand samples. Samples persist in IndexedDB across refreshes.
|
|
||||||
2. **TRAIN** tab → *train model*. Watch the live loss curve, held-out PCK, and the
|
|
||||||
baseline verdict. The model saves to IndexedDB.
|
|
||||||
3. **INFER** tab → the green skeleton is now driven by WiFi CSI only, aligned over
|
|
||||||
your camera. Toggle *hide camera* to see the CSI-only skeleton on black.
|
|
||||||
|
|
||||||
## The 410-d CSI vector (matches the Python pipeline exactly)
|
|
||||||
|
|
||||||
```
|
|
||||||
[ mean_rssi, variance, motion_band_power, breathing_band_power ] # 4 (features.*)
|
|
||||||
+ for node 9 then node 13: [ mean_rssi, variance, motion_band_power ] # 6 (node_features[].features.*)
|
|
||||||
+ signal_field.values, padded / truncated to 400 # 400
|
|
||||||
= 410-d
|
|
||||||
```
|
|
||||||
|
|
||||||
Verified against a real live frame: the in-browser `csiVector()` produces the
|
|
||||||
identical 410 vector as `wiflow_capture.py`'s `csi_vector()` (node 9 first, then
|
|
||||||
node 13; field zero-padded).
|
|
||||||
|
|
||||||
## Libraries (CDN only, no bundler)
|
|
||||||
|
|
||||||
| Library | CDN |
|
|
||||||
|---|---|
|
|
||||||
| TensorFlow.js core | `@tensorflow/tfjs@4.22.0/dist/tf.min.js` |
|
|
||||||
| TF.js WebGPU backend | `@tensorflow/tfjs-backend-webgpu@4.22.0/dist/tf-backend-webgpu.min.js` |
|
|
||||||
| TF.js WASM backend | `@tensorflow/tfjs-backend-wasm@4.22.0/dist/tf-backend-wasm.min.js` |
|
|
||||||
| MediaPipe Pose 0.5 (legacy solutions) | `@mediapipe/pose@0.5/pose.js` |
|
|
||||||
|
|
||||||
## Scope / honesty caveats
|
|
||||||
|
|
||||||
Same person, same room, same session. **Not** validated cross-day, cross-room, or
|
|
||||||
through-wall. The inferred pose is coarse (PCK@0.05 is typically weak). If the
|
|
||||||
model does not beat the mean-pose baseline, the page says so — that is a feature.
|
|
||||||
@@ -1,644 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>RuView · Through-Wall WiFi Sensing · LIVE CSI (no skeleton, no simulation)</title>
|
|
||||||
<!--
|
|
||||||
THROUGH-WALL WiFi-CSI SENSING DEMO — honest, real-data-only.
|
|
||||||
|
|
||||||
Renders ONLY what the running sensing-server actually streams over
|
|
||||||
ws://localhost:8765/ws/sensing :
|
|
||||||
- the 20x20 `signal_field` floor heatmap (real values)
|
|
||||||
- a coarse RF-localization puck from persons[0].position (NOT pose)
|
|
||||||
- live motion / presence / rssi / confidence meters
|
|
||||||
- the real `source` ("esp32" = LIVE) verbatim in the banner
|
|
||||||
|
|
||||||
It deliberately does NOT draw a skeleton. The server's
|
|
||||||
persons[].keypoints carry confidence:0.0 (image-pixel garbage, not
|
|
||||||
real 3D joints) so we never render them. WiFi CSI gives
|
|
||||||
motion/presence/coarse-position — that is the honest wow, and it
|
|
||||||
penetrates drywall. See README.md.
|
|
||||||
-->
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #050507; --bg-panel: rgba(8,10,14,0.80);
|
|
||||||
--amber: #ffb840; --amber-hot: #ffe09f;
|
|
||||||
--cyan: #4cf; --magenta: #ff4cc8;
|
|
||||||
--text: #d8c69a; --text-mute: #6b6155;
|
|
||||||
--green: #4f4; --red: #f64;
|
|
||||||
--border: rgba(255,184,64,0.18);
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0; background: var(--bg); color: var(--text); overflow: hidden;
|
|
||||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
|
||||||
-webkit-font-smoothing: antialiased; font-size: 12px;
|
|
||||||
}
|
|
||||||
canvas { display: block; }
|
|
||||||
.overlay-frame {
|
|
||||||
position: fixed; inset: 0; pointer-events: none; z-index: 5;
|
|
||||||
background:
|
|
||||||
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%),
|
|
||||||
linear-gradient(180deg, rgba(0,0,0,0.32) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.38) 100%);
|
|
||||||
}
|
|
||||||
.scanlines {
|
|
||||||
position: fixed; inset: 0; pointer-events: none; z-index: 6;
|
|
||||||
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.04) 0px, rgba(0,0,0,0.04) 1px, transparent 1px, transparent 3px);
|
|
||||||
mix-blend-mode: overlay; opacity: 0.5;
|
|
||||||
}
|
|
||||||
.panel {
|
|
||||||
position: absolute; background: var(--bg-panel); border: 1px solid var(--border);
|
|
||||||
border-radius: 4px; padding: 12px 14px; backdrop-filter: blur(8px);
|
|
||||||
box-shadow: 0 1px 0 rgba(255,184,64,0.04), 0 8px 32px rgba(0,0,0,0.55); z-index: 10;
|
|
||||||
}
|
|
||||||
.panel h2 {
|
|
||||||
margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
|
||||||
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Honest status banner (top-center, mutually exclusive states) ---- */
|
|
||||||
#banner {
|
|
||||||
position: fixed; top: 0; left: 0; right: 0; z-index: 30;
|
|
||||||
text-align: center; padding: 7px 12px; font-size: 12px; letter-spacing: 1px;
|
|
||||||
font-weight: 600; border-bottom: 1px solid rgba(0,0,0,0.4);
|
|
||||||
transition: background 0.3s, color 0.3s;
|
|
||||||
}
|
|
||||||
#banner.live { background: rgba(40,255,80,0.12); color: var(--green); border-bottom-color: rgba(80,255,120,0.4); }
|
|
||||||
#banner.sim { background: rgba(255,120,40,0.16); color: #ffae5a; border-bottom-color: rgba(255,140,60,0.5); }
|
|
||||||
#banner.noserver { background: rgba(255,80,80,0.16); color: var(--red); border-bottom-color: rgba(255,90,90,0.5); }
|
|
||||||
#banner .src { opacity: 0.8; font-weight: 400; }
|
|
||||||
#banner-caption {
|
|
||||||
position: fixed; top: 30px; left: 0; right: 0; z-index: 29;
|
|
||||||
text-align: center; font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px;
|
|
||||||
pointer-events: none; padding-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#info { top: 64px; left: 20px; min-width: 270px; }
|
|
||||||
#info h1 { margin: 0 0 1px 0; font-size: 13px; letter-spacing: 1px; color: var(--amber-hot); font-weight: 600; }
|
|
||||||
#info .sub { font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
|
||||||
#info .row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
|
|
||||||
#info .row .k { color: var(--text-mute); font-size: 11px; }
|
|
||||||
#info .row .v { color: var(--text); font-variant-numeric: tabular-nums; font-size: 11px; }
|
|
||||||
#info .row .v.amber { color: var(--amber); }
|
|
||||||
#info .row .v.cyan { color: var(--cyan); }
|
|
||||||
#info .row .v.green { color: var(--green); }
|
|
||||||
#info .row .v.red { color: var(--red); }
|
|
||||||
#info .row .v.mag { color: var(--magenta); }
|
|
||||||
#info .row .v.mute { color: var(--text-mute); }
|
|
||||||
|
|
||||||
#csi { top: 64px; right: 20px; min-width: 270px; }
|
|
||||||
#csi .bar-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
|
|
||||||
#csi .bar-row .label { width: 86px; color: var(--text-mute); }
|
|
||||||
#csi .bar-row .bar-track { flex: 1; height: 6px; background: rgba(255,184,64,0.08); border-radius: 2px; overflow: hidden; }
|
|
||||||
#csi .bar-row .bar-fill {
|
|
||||||
height: 100%; background: linear-gradient(90deg, var(--amber-hot), var(--amber));
|
|
||||||
box-shadow: 0 0 6px var(--amber); transition: width 0.1s linear;
|
|
||||||
}
|
|
||||||
#csi .bar-row .val { width: 44px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
|
|
||||||
#csi .spark { margin-top: 8px; }
|
|
||||||
#csi canvas { width: 100%; height: 38px; display: block; border: 1px solid var(--border); border-radius: 3px; background: rgba(0,0,0,0.3); }
|
|
||||||
#csi .legend { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 10px; color: var(--text-mute); line-height: 1.5; }
|
|
||||||
|
|
||||||
/* ---- waiting / no-server overlay ---- */
|
|
||||||
#waiting {
|
|
||||||
position: fixed; inset: 0; z-index: 25; display: none;
|
|
||||||
flex-direction: column; align-items: center; justify-content: center;
|
|
||||||
background: rgba(5,5,7,0.94); color: var(--amber); text-align: center; padding: 24px;
|
|
||||||
}
|
|
||||||
#waiting.show { display: flex; }
|
|
||||||
#waiting .big { font-size: 22px; letter-spacing: 2px; color: var(--red); margin-bottom: 16px; text-transform: uppercase; }
|
|
||||||
#waiting code {
|
|
||||||
display: block; text-align: left; max-width: 640px; margin: 8px auto;
|
|
||||||
background: rgba(255,184,64,0.06); border: 1px solid var(--border); border-radius: 4px;
|
|
||||||
padding: 10px 14px; color: var(--amber-hot); font-size: 12px; white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
#waiting .pulse { animation: pulse 1.4s ease-in-out infinite; }
|
|
||||||
@keyframes pulse { 0%,100% { opacity: 0.55; } 50% { opacity: 1; } }
|
|
||||||
|
|
||||||
/* ---- optional webcam ground-truth tile ---- */
|
|
||||||
#cam-tile {
|
|
||||||
position: absolute; bottom: 20px; right: 20px; width: 240px; z-index: 12;
|
|
||||||
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
|
|
||||||
padding: 8px; backdrop-filter: blur(8px);
|
|
||||||
}
|
|
||||||
#cam-tile h2 { margin: 0 0 6px 0; font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px;
|
|
||||||
color: var(--cyan); font-weight: 600; }
|
|
||||||
#cam-tile .gt-note { font-size: 9px; color: var(--text-mute); margin-top: 4px; line-height: 1.4; }
|
|
||||||
#cam-video { width: 100%; border-radius: 3px; display: none; background: #000; }
|
|
||||||
#cam-tile button {
|
|
||||||
width: 100%; margin-top: 6px; padding: 5px 8px; font-family: inherit; font-size: 11px;
|
|
||||||
background: transparent; color: var(--cyan); border: 1px solid var(--cyan); border-radius: 3px; cursor: pointer;
|
|
||||||
}
|
|
||||||
#cam-tile button:hover { background: rgba(68,204,255,0.12); }
|
|
||||||
#cam-tile button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
|
|
||||||
#legend-nodes {
|
|
||||||
position: absolute; bottom: 20px; left: 20px; min-width: 220px;
|
|
||||||
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
|
|
||||||
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
|
|
||||||
}
|
|
||||||
#legend-nodes h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
|
||||||
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
|
||||||
#legend-nodes .lr { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 11px; }
|
|
||||||
#legend-nodes .dot { width: 9px; height: 9px; border-radius: 50%; box-shadow: 0 0 6px currentColor; flex: 0 0 auto; }
|
|
||||||
#legend-nodes .muted { color: var(--text-mute); }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<!-- three.js r128 + addons (same CDN set as examples/three.js/demos/05) -->
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="banner" class="noserver">NO SERVER — start the sensing-server <span class="src"></span></div>
|
|
||||||
<div id="banner-caption">Real WiFi CSI motion / presence / coarse-localization — penetrates drywall. Not skeletal pose.</div>
|
|
||||||
|
|
||||||
<div class="overlay-frame"></div>
|
|
||||||
<div class="scanlines"></div>
|
|
||||||
|
|
||||||
<div class="panel" id="info">
|
|
||||||
<h1>THROUGH-WALL WiFi SENSING</h1>
|
|
||||||
<div class="sub">Live CSI · ws://localhost:8765/ws/sensing</div>
|
|
||||||
<div class="row"><span class="k">source</span><span class="v amber" id="m-source">—</span></div>
|
|
||||||
<div class="row"><span class="k">presence</span><span class="v" id="m-presence">—</span></div>
|
|
||||||
<div class="row"><span class="k">motion level</span><span class="v" id="m-motion">—</span></div>
|
|
||||||
<div class="row"><span class="k">confidence</span><span class="v cyan" id="m-conf">—</span></div>
|
|
||||||
<div class="row"><span class="k">est. persons</span><span class="v amber" id="m-persons">—</span></div>
|
|
||||||
<div class="row"><span class="k">active nodes</span><span class="v" id="m-nodes">—</span></div>
|
|
||||||
<div class="row"><span class="k">tick</span><span class="v" id="m-tick">—</span></div>
|
|
||||||
<div class="row"><span class="k">update rate</span><span class="v cyan" id="m-fps">—</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel" id="csi">
|
|
||||||
<h2>Live RF features</h2>
|
|
||||||
<div class="bar-row"><span class="label">motion</span><div class="bar-track"><div class="bar-fill" id="bar-motion"></div></div><span class="val" id="v-motion">—</span></div>
|
|
||||||
<div class="bar-row"><span class="label">breathing</span><div class="bar-track"><div class="bar-fill" id="bar-breath"></div></div><span class="val" id="v-breath">—</span></div>
|
|
||||||
<div class="bar-row"><span class="label">variance</span><div class="bar-track"><div class="bar-fill" id="bar-var"></div></div><span class="val" id="v-var">—</span></div>
|
|
||||||
<div class="bar-row"><span class="label">mean rssi</span><div class="bar-track"><div class="bar-fill" id="bar-rssi"></div></div><span class="val" id="v-rssi">—</span></div>
|
|
||||||
<div class="spark"><canvas id="spark" width="252" height="38"></canvas></div>
|
|
||||||
<div class="legend">motion sparkline (last ~6s of real motion_band_power)</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="legend-nodes">
|
|
||||||
<h2>Sensor nodes</h2>
|
|
||||||
<div class="lr"><span class="dot" style="color:#4cf"></span><span>ESP32-S3 office <span class="muted">(node 9)</span></span></div>
|
|
||||||
<div class="lr"><span class="dot" style="color:#ff4cc8"></span><span>ESP32-S3 hallway <span class="muted">(node 13)</span></span></div>
|
|
||||||
<div class="lr" style="margin-top:6px"><span class="dot" style="color:#4f4"></span><span>RF localization <span class="muted">(coarse)</span></span></div>
|
|
||||||
<div class="lr"><span class="muted" style="font-size:10px;line-height:1.4">Office & hallway split by a wall + doorway. WiFi motion still shows through drywall.</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="cam-tile">
|
|
||||||
<h2>camera — ground truth when visible</h2>
|
|
||||||
<video id="cam-video" autoplay muted playsinline></video>
|
|
||||||
<button id="cam-btn">▶ enable webcam (optional)</button>
|
|
||||||
<div class="gt-note">Independent of the CSI sensing. The WiFi works in the dark and through walls; the camera does not.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="waiting" class="show">
|
|
||||||
<div class="big pulse">Waiting for live sensing-server</div>
|
|
||||||
<div>No connection to <b>ws://localhost:8765/ws/sensing</b>. Start the real server, then this page connects automatically.</div>
|
|
||||||
<code>cd v2
|
|
||||||
cargo build -p wifi-densepose-sensing-server
|
|
||||||
./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005</code>
|
|
||||||
<div style="margin-top:10px; color:var(--text-mute); font-size:11px;">This demo renders ONLY real data. It never invents frames.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
"use strict";
|
|
||||||
// =====================================================================
|
|
||||||
// Config + WS endpoint (allow ?ws= override)
|
|
||||||
// =====================================================================
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
const WS_URL = params.get('ws') || 'ws://localhost:8765/ws/sensing';
|
|
||||||
const ROOM_HALF = 5; // half-extent of the floor plane in metres
|
|
||||||
const GRID_N = 20; // signal_field is 20 x 20
|
|
||||||
|
|
||||||
// Known node anchor positions (server sends node 9 @ [2,0,1.5]; node 13
|
|
||||||
// joins later from the hallway side once its firmware is flashed). These
|
|
||||||
// are anchors for the room model + labels, NOT fabricated sensing data.
|
|
||||||
const NODE_ANCHORS = {
|
|
||||||
9: { pos: [ 2.0, 0.0, 1.5], color: 0x44ccff, label: 'office (node 9)' },
|
|
||||||
13: { pos: [-2.0, 0.0, -3.0], color: 0xff4cc8, label: 'hallway (node 13)' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Three.js scene (reused pattern from demos/05-skinned-realtime.html)
|
|
||||||
// =====================================================================
|
|
||||||
const scene = new THREE.Scene();
|
|
||||||
scene.background = new THREE.Color(0x050507);
|
|
||||||
scene.fog = new THREE.FogExp2(0x050507, 0.045);
|
|
||||||
|
|
||||||
const camera = new THREE.PerspectiveCamera(50, window.innerWidth/window.innerHeight, 0.05, 100);
|
|
||||||
camera.position.set(4.5, 4.2, 6.0);
|
|
||||||
|
|
||||||
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
|
||||||
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
|
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
||||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
||||||
renderer.toneMappingExposure = 0.85;
|
|
||||||
renderer.outputEncoding = THREE.sRGBEncoding;
|
|
||||||
document.body.appendChild(renderer.domElement);
|
|
||||||
|
|
||||||
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
|
||||||
controls.target.set(0, 0.4, -0.5);
|
|
||||||
controls.enableDamping = true; controls.dampingFactor = 0.06;
|
|
||||||
controls.minDistance = 3; controls.maxDistance = 18;
|
|
||||||
controls.maxPolarAngle = Math.PI * 0.49;
|
|
||||||
|
|
||||||
scene.add(new THREE.HemisphereLight(0x553a18, 0x080606, 0.7));
|
|
||||||
const keyLight = new THREE.DirectionalLight(0xffc070, 0.9);
|
|
||||||
keyLight.position.set(3, 6, 4);
|
|
||||||
scene.add(keyLight);
|
|
||||||
|
|
||||||
// Post-processing — gentle bloom so the heatmap + puck glow.
|
|
||||||
const composer = new THREE.EffectComposer(renderer);
|
|
||||||
composer.addPass(new THREE.RenderPass(scene, camera));
|
|
||||||
const bloom = new THREE.UnrealBloomPass(
|
|
||||||
new THREE.Vector2(window.innerWidth, window.innerHeight), 0.55, 0.45, 0.82);
|
|
||||||
composer.addPass(bloom);
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Room: floor grid + wall + doorway dividing office / hallway
|
|
||||||
// =====================================================================
|
|
||||||
const gridHelper = new THREE.GridHelper(2*ROOM_HALF, GRID_N, 0x554a32, 0x2a2418);
|
|
||||||
gridHelper.position.y = 0.002;
|
|
||||||
scene.add(gridHelper);
|
|
||||||
|
|
||||||
// Dividing wall runs along world X near z = -1 (office z>-1, hallway z<-1),
|
|
||||||
// with a doorway gap. Two wall segments leave a gap in the middle.
|
|
||||||
const wallMat = new THREE.MeshStandardMaterial({
|
|
||||||
color: 0x1b2330, transparent: true, opacity: 0.55, roughness: 0.9,
|
|
||||||
side: THREE.DoubleSide,
|
|
||||||
});
|
|
||||||
const wallH = 1.4, wallZ = -1.0;
|
|
||||||
function addWallSeg(cx, w) {
|
|
||||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, 0.08), wallMat);
|
|
||||||
m.position.set(cx, wallH/2, wallZ);
|
|
||||||
scene.add(m);
|
|
||||||
// top edge highlight
|
|
||||||
const edge = new THREE.Mesh(new THREE.BoxGeometry(w, 0.02, 0.10),
|
|
||||||
new THREE.MeshBasicMaterial({ color: 0x4cf, transparent: true, opacity: 0.5 }));
|
|
||||||
edge.position.set(cx, wallH, wallZ);
|
|
||||||
scene.add(edge);
|
|
||||||
}
|
|
||||||
// left segment, doorway gap (-0.7..0.7), right segment
|
|
||||||
addWallSeg(-3.15, 3.7);
|
|
||||||
addWallSeg( 3.15, 3.7);
|
|
||||||
|
|
||||||
// Room labels (sprite text) for OFFICE / HALLWAY
|
|
||||||
function makeLabel(text, color) {
|
|
||||||
const c = document.createElement('canvas'); c.width = 256; c.height = 64;
|
|
||||||
const ctx = c.getContext('2d');
|
|
||||||
ctx.fillStyle = color; ctx.font = 'bold 30px Consolas, monospace';
|
|
||||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillText(text, 128, 34);
|
|
||||||
const tex = new THREE.CanvasTexture(c);
|
|
||||||
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
|
|
||||||
spr.scale.set(2.0, 0.5, 1);
|
|
||||||
return spr;
|
|
||||||
}
|
|
||||||
const officeLbl = makeLabel('OFFICE', '#ffb840'); officeLbl.position.set(2.6, 0.06, 2.6); scene.add(officeLbl);
|
|
||||||
const hallLbl = makeLabel('HALLWAY', '#ff4cc8'); hallLbl.position.set(-2.6, 0.06, -3.2); scene.add(hallLbl);
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Node markers (office / hallway). The hallway node is dimmed until it
|
|
||||||
// actually appears in the live `nodes` list.
|
|
||||||
// =====================================================================
|
|
||||||
const nodeMeshes = {};
|
|
||||||
function buildNode(id) {
|
|
||||||
const a = NODE_ANCHORS[id];
|
|
||||||
const g = new THREE.Group();
|
|
||||||
const post = new THREE.Mesh(
|
|
||||||
new THREE.CylinderGeometry(0.05, 0.07, 0.9, 12),
|
|
||||||
new THREE.MeshStandardMaterial({ color: a.color, emissive: a.color, emissiveIntensity: 0.4, roughness: 0.4 }));
|
|
||||||
post.position.y = 0.45; g.add(post);
|
|
||||||
const orb = new THREE.Mesh(
|
|
||||||
new THREE.SphereGeometry(0.12, 20, 16),
|
|
||||||
new THREE.MeshBasicMaterial({ color: a.color }));
|
|
||||||
orb.position.y = 0.95; g.add(orb);
|
|
||||||
const ring = new THREE.Mesh(
|
|
||||||
new THREE.RingGeometry(0.18, 0.24, 32),
|
|
||||||
new THREE.MeshBasicMaterial({ color: a.color, transparent: true, opacity: 0.6, side: THREE.DoubleSide }));
|
|
||||||
ring.rotation.x = -Math.PI/2; ring.position.y = 0.01; g.add(ring);
|
|
||||||
const lbl = makeLabel('ESP32-S3 ' + a.label, '#' + a.color.toString(16).padStart(6,'0'));
|
|
||||||
lbl.scale.set(2.6, 0.65, 1); lbl.position.set(0, 1.25, 0); g.add(lbl);
|
|
||||||
g.position.set(a.pos[0], 0, a.pos[2]);
|
|
||||||
g.userData.parts = { post, orb, ring };
|
|
||||||
scene.add(g);
|
|
||||||
return g;
|
|
||||||
}
|
|
||||||
Object.keys(NODE_ANCHORS).forEach(id => { nodeMeshes[id] = buildNode(+id); });
|
|
||||||
function setNodeActive(id, active) {
|
|
||||||
const g = nodeMeshes[id]; if (!g) return;
|
|
||||||
const o = active ? 1.0 : 0.22;
|
|
||||||
const parts = g.userData.parts;
|
|
||||||
parts.orb.material.opacity = o; parts.orb.material.transparent = true;
|
|
||||||
parts.ring.material.opacity = 0.6 * o;
|
|
||||||
parts.post.material.emissiveIntensity = active ? 0.5 : 0.12;
|
|
||||||
}
|
|
||||||
setNodeActive(9, false); setNodeActive(13, false);
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// signal_field 20x20 floor heatmap — instanced colored tiles.
|
|
||||||
// Driven ONLY by real `signal_field.values` (400 floats ~0..1).
|
|
||||||
// =====================================================================
|
|
||||||
const TILE = (2*ROOM_HALF) / GRID_N;
|
|
||||||
const heatGeo = new THREE.PlaneGeometry(TILE * 0.96, TILE * 0.96);
|
|
||||||
const heatMat = new THREE.MeshBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.85, side: THREE.DoubleSide });
|
|
||||||
const heatMesh = new THREE.InstancedMesh(heatGeo, heatMat, GRID_N * GRID_N);
|
|
||||||
heatMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
|
||||||
const heatColor = new THREE.InstancedBufferAttribute(new Float32Array(GRID_N * GRID_N * 3), 3);
|
|
||||||
heatMesh.instanceColor = heatColor;
|
|
||||||
const _m = new THREE.Matrix4();
|
|
||||||
const _q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0), -Math.PI/2);
|
|
||||||
const _s = new THREE.Vector3(1,1,1);
|
|
||||||
const _p = new THREE.Vector3();
|
|
||||||
// gridCell (gx,gz) -> world (x,z). gx,gz in [0,GRID_N).
|
|
||||||
function cellToWorld(gx, gz) {
|
|
||||||
return [ (gx + 0.5) * TILE - ROOM_HALF, (gz + 0.5) * TILE - ROOM_HALF ];
|
|
||||||
}
|
|
||||||
for (let gz = 0; gz < GRID_N; gz++) {
|
|
||||||
for (let gx = 0; gx < GRID_N; gx++) {
|
|
||||||
const i = gz * GRID_N + gx;
|
|
||||||
const [wx, wz] = cellToWorld(gx, gz);
|
|
||||||
_p.set(wx, 0.012, wz);
|
|
||||||
_m.compose(_p, _q, _s);
|
|
||||||
heatMesh.setMatrixAt(i, _m);
|
|
||||||
heatColor.setXYZ(i, 0.02, 0.02, 0.03);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
heatMesh.instanceMatrix.needsUpdate = true;
|
|
||||||
scene.add(heatMesh);
|
|
||||||
|
|
||||||
// amber→white heat ramp for a value in [0,1]
|
|
||||||
function heatRamp(v, out) {
|
|
||||||
v = Math.max(0, Math.min(1, v));
|
|
||||||
// dark -> amber -> hot white
|
|
||||||
const r = Math.min(1, 0.05 + 1.6 * v);
|
|
||||||
const g = Math.min(1, 0.02 + 1.1 * v * v);
|
|
||||||
const b = Math.min(1, 0.04 + 0.9 * Math.pow(v, 3));
|
|
||||||
out.set(r, g, b);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
const _c = new THREE.Color();
|
|
||||||
let lastFieldPeak = { gx: GRID_N/2|0, gz: GRID_N/2|0, v: 0 };
|
|
||||||
function updateHeatmap(field) {
|
|
||||||
if (!field || !Array.isArray(field.values)) return;
|
|
||||||
const vals = field.values;
|
|
||||||
// grid_size is [20,1,20]; values are row-major 400 floats.
|
|
||||||
let peakV = -1, peakGx = lastFieldPeak.gx, peakGz = lastFieldPeak.gz;
|
|
||||||
const n = Math.min(vals.length, GRID_N * GRID_N);
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const v = vals[i];
|
|
||||||
heatRamp(v, _c);
|
|
||||||
heatColor.setXYZ(i, _c.r, _c.g, _c.b);
|
|
||||||
if (v > peakV) { peakV = v; peakGx = i % GRID_N; peakGz = (i / GRID_N) | 0; }
|
|
||||||
}
|
|
||||||
heatColor.needsUpdate = true;
|
|
||||||
lastFieldPeak = { gx: peakGx, gz: peakGz, v: peakV };
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// RF-localization puck — from persons[0].position (coarse, NOT pose).
|
|
||||||
// Falls back to the signal_field peak cell when no person is present.
|
|
||||||
// =====================================================================
|
|
||||||
const puck = new THREE.Group();
|
|
||||||
const puckCore = new THREE.Mesh(
|
|
||||||
new THREE.SphereGeometry(0.16, 24, 18),
|
|
||||||
new THREE.MeshBasicMaterial({ color: 0x66ff88 }));
|
|
||||||
puckCore.position.y = 0.16; puck.add(puckCore);
|
|
||||||
const puckRing = new THREE.Mesh(
|
|
||||||
new THREE.RingGeometry(0.28, 0.36, 40),
|
|
||||||
new THREE.MeshBasicMaterial({ color: 0x66ff88, transparent: true, opacity: 0.7, side: THREE.DoubleSide }));
|
|
||||||
puckRing.rotation.x = -Math.PI/2; puckRing.position.y = 0.02; puck.add(puckRing);
|
|
||||||
const puckBeam = new THREE.Mesh(
|
|
||||||
new THREE.CylinderGeometry(0.03, 0.03, 1.2, 8),
|
|
||||||
new THREE.MeshBasicMaterial({ color: 0x66ff88, transparent: true, opacity: 0.35 }));
|
|
||||||
puckBeam.position.y = 0.6; puck.add(puckBeam);
|
|
||||||
puck.visible = false;
|
|
||||||
scene.add(puck);
|
|
||||||
const puckTarget = new THREE.Vector3(0, 0, 0);
|
|
||||||
|
|
||||||
function updatePuck(frame) {
|
|
||||||
let wx = null, wz = null, present = false;
|
|
||||||
const persons = frame.persons || [];
|
|
||||||
if (persons.length && Array.isArray(persons[0].position)) {
|
|
||||||
// server position is [x, 0, z] in metres, origin at room centre
|
|
||||||
wx = persons[0].position[0];
|
|
||||||
wz = persons[0].position[2];
|
|
||||||
present = true;
|
|
||||||
}
|
|
||||||
// If no person but the field has clear energy, show the peak cell
|
|
||||||
// (coarse) so the puck honestly tracks "where the RF energy is".
|
|
||||||
if (!present && lastFieldPeak.v > 0.55) {
|
|
||||||
const peak = cellToWorld(lastFieldPeak.gx, lastFieldPeak.gz);
|
|
||||||
wx = peak[0]; wz = peak[1]; present = true;
|
|
||||||
}
|
|
||||||
if (present && wx !== null) {
|
|
||||||
// clamp into the room so it never flies off the floor
|
|
||||||
wx = Math.max(-ROOM_HALF+0.3, Math.min(ROOM_HALF-0.3, wx));
|
|
||||||
wz = Math.max(-ROOM_HALF+0.3, Math.min(ROOM_HALF-0.3, wz));
|
|
||||||
puckTarget.set(wx, 0, wz);
|
|
||||||
puck.visible = true;
|
|
||||||
} else {
|
|
||||||
puck.visible = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// HUD updates
|
|
||||||
// =====================================================================
|
|
||||||
const $ = id => document.getElementById(id);
|
|
||||||
function clamp01(x){ return Math.max(0, Math.min(1, x)); }
|
|
||||||
function setBar(barId, valId, frac, text) {
|
|
||||||
$(barId).style.width = (clamp01(frac) * 100).toFixed(0) + '%';
|
|
||||||
$(valId).textContent = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// motion sparkline ring buffer
|
|
||||||
const sparkCtx = $('spark').getContext('2d');
|
|
||||||
const SPARK_N = 120;
|
|
||||||
const sparkBuf = new Array(SPARK_N).fill(0);
|
|
||||||
function pushSpark(v) {
|
|
||||||
sparkBuf.push(v); if (sparkBuf.length > SPARK_N) sparkBuf.shift();
|
|
||||||
const w = sparkCtx.canvas.width, h = sparkCtx.canvas.height;
|
|
||||||
sparkCtx.clearRect(0,0,w,h);
|
|
||||||
let maxV = 40; for (const x of sparkBuf) if (x > maxV) maxV = x;
|
|
||||||
sparkCtx.strokeStyle = '#ffb840'; sparkCtx.lineWidth = 1.5; sparkCtx.beginPath();
|
|
||||||
for (let i = 0; i < sparkBuf.length; i++) {
|
|
||||||
const x = (i / (SPARK_N-1)) * w;
|
|
||||||
const y = h - (sparkBuf[i] / maxV) * (h - 3) - 1.5;
|
|
||||||
i === 0 ? sparkCtx.moveTo(x, y) : sparkCtx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
sparkCtx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Honest status banner (strict, mutually exclusive)
|
|
||||||
// =====================================================================
|
|
||||||
const banner = $('banner');
|
|
||||||
function setBannerLive(source, nodeCount) {
|
|
||||||
if (source === 'esp32') {
|
|
||||||
banner.className = 'live';
|
|
||||||
banner.innerHTML = 'LIVE — real ESP32 CSI <span class="src">(source=' + source + ', ' + nodeCount + ' node' + (nodeCount === 1 ? '' : 's') + ')</span>';
|
|
||||||
} else {
|
|
||||||
// anything not esp32 = explicitly NOT real, badged
|
|
||||||
banner.className = 'sim';
|
|
||||||
banner.innerHTML = 'SIMULATED — not real <span class="src">(source=' + source + ' — start an ESP32 for live CSI)</span>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function setBannerNoServer() {
|
|
||||||
banner.className = 'noserver';
|
|
||||||
banner.innerHTML = 'NO SERVER — start the sensing-server <span class="src">(ws://localhost:8765/ws/sensing unreachable)</span>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// WebSocket — render ONLY real frames. Reconnect; never fabricate.
|
|
||||||
// =====================================================================
|
|
||||||
let ws = null, gotFrame = false;
|
|
||||||
let frameTimes = []; // for measured update rate (fps)
|
|
||||||
let lastFrame = null; // most recent real frame (render loop interpolates puck)
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
setBannerNoServer();
|
|
||||||
try { ws = new WebSocket(WS_URL); }
|
|
||||||
catch (e) { scheduleReconnect(); return; }
|
|
||||||
|
|
||||||
ws.onopen = () => { /* wait for first frame before claiming LIVE */ };
|
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
let d; try { d = JSON.parse(ev.data); } catch (e) { return; }
|
|
||||||
if (!d || d.type !== 'sensing_update') return;
|
|
||||||
onFrame(d);
|
|
||||||
};
|
|
||||||
ws.onclose = () => { gotFrame = false; $('waiting').classList.add('show'); setBannerNoServer(); scheduleReconnect(); };
|
|
||||||
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
|
||||||
}
|
|
||||||
let reconnectT = null;
|
|
||||||
function scheduleReconnect() {
|
|
||||||
if (reconnectT) return;
|
|
||||||
reconnectT = setTimeout(() => { reconnectT = null; connect(); }, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFrame(d) {
|
|
||||||
gotFrame = true;
|
|
||||||
lastFrame = d;
|
|
||||||
$('waiting').classList.remove('show');
|
|
||||||
|
|
||||||
const source = d.source || 'unknown';
|
|
||||||
const nodes = Array.isArray(d.nodes) ? d.nodes : [];
|
|
||||||
setBannerLive(source, nodes.length);
|
|
||||||
|
|
||||||
// measured update rate
|
|
||||||
const now = performance.now();
|
|
||||||
frameTimes.push(now);
|
|
||||||
while (frameTimes.length && now - frameTimes[0] > 2000) frameTimes.shift();
|
|
||||||
const fps = frameTimes.length > 1 ? (frameTimes.length - 1) / ((frameTimes[frameTimes.length-1] - frameTimes[0]) / 1000) : 0;
|
|
||||||
|
|
||||||
const cls = d.classification || {};
|
|
||||||
const feat = d.features || {};
|
|
||||||
|
|
||||||
// info panel
|
|
||||||
$('m-source').textContent = source.toUpperCase();
|
|
||||||
$('m-source').className = 'v ' + (source === 'esp32' ? 'green' : 'red');
|
|
||||||
const presence = !!cls.presence;
|
|
||||||
$('m-presence').textContent = presence ? (cls.motion_level === 'present_moving' ? 'PRESENT · MOVING' : 'PRESENT') : 'CLEAR';
|
|
||||||
$('m-presence').className = 'v ' + (presence ? 'green' : 'mute');
|
|
||||||
$('m-motion').textContent = cls.motion_level || '—';
|
|
||||||
$('m-conf').textContent = (cls.confidence != null) ? cls.confidence.toFixed(2) : '—';
|
|
||||||
$('m-persons').textContent = (d.estimated_persons != null) ? d.estimated_persons : '—';
|
|
||||||
$('m-nodes').textContent = nodes.length + ' (' + nodes.map(n => n.node_id).join(', ') + ')';
|
|
||||||
$('m-tick').textContent = (d.tick != null) ? d.tick : '—';
|
|
||||||
$('m-fps').textContent = fps ? fps.toFixed(1) + ' Hz' : '—';
|
|
||||||
|
|
||||||
// feature bars (real values, scaled into 0..1 for the bar width only)
|
|
||||||
const motion = feat.motion_band_power || 0;
|
|
||||||
const breath = feat.breathing_band_power || 0;
|
|
||||||
const variance = feat.variance || 0;
|
|
||||||
const rssi = feat.mean_rssi != null ? feat.mean_rssi : -100;
|
|
||||||
setBar('bar-motion', 'v-motion', motion / 100, motion.toFixed(1));
|
|
||||||
setBar('bar-breath', 'v-breath', breath / 100, breath.toFixed(1));
|
|
||||||
setBar('bar-var', 'v-var', variance / 80, variance.toFixed(1));
|
|
||||||
// rssi: map -90..-30 dBm -> 0..1
|
|
||||||
setBar('bar-rssi', 'v-rssi', (rssi + 90) / 60, rssi.toFixed(0));
|
|
||||||
pushSpark(motion);
|
|
||||||
|
|
||||||
// node activity
|
|
||||||
const activeIds = new Set(nodes.map(n => n.node_id));
|
|
||||||
[9, 13].forEach(id => setNodeActive(id, activeIds.has(id)));
|
|
||||||
|
|
||||||
// heatmap + puck
|
|
||||||
updateHeatmap(d.signal_field);
|
|
||||||
updatePuck(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Optional webcam ground-truth tile (reused from demos/05). Camera is
|
|
||||||
// separate from CSI sensing — labeled "ground truth when visible".
|
|
||||||
// =====================================================================
|
|
||||||
let camStream = null;
|
|
||||||
$('cam-btn').addEventListener('click', async () => {
|
|
||||||
const btn = $('cam-btn');
|
|
||||||
if (camStream) { // toggle off
|
|
||||||
camStream.getTracks().forEach(t => t.stop());
|
|
||||||
$('cam-video').style.display = 'none'; camStream = null;
|
|
||||||
btn.textContent = '▶ enable webcam (optional)';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
btn.disabled = true; btn.textContent = 'requesting camera…';
|
|
||||||
try {
|
|
||||||
camStream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' }, audio: false,
|
|
||||||
});
|
|
||||||
const v = $('cam-video'); v.srcObject = camStream; v.style.display = 'block';
|
|
||||||
btn.textContent = '■ stop webcam'; btn.disabled = false;
|
|
||||||
} catch (e) {
|
|
||||||
btn.textContent = '✗ camera unavailable'; btn.disabled = false; console.error(e);
|
|
||||||
setTimeout(() => { if (!camStream) btn.textContent = '▶ enable webcam (optional)'; }, 2000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Render loop — smooth the puck toward its real target; pulse rings.
|
|
||||||
// =====================================================================
|
|
||||||
const clock = new THREE.Clock();
|
|
||||||
function animate() {
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
const t = clock.getElapsedTime();
|
|
||||||
controls.update();
|
|
||||||
|
|
||||||
if (puck.visible) {
|
|
||||||
puck.position.lerp(puckTarget, 0.18);
|
|
||||||
const pulse = 0.8 + 0.25 * Math.sin(t * 3.0);
|
|
||||||
puckRing.scale.set(pulse, pulse, pulse);
|
|
||||||
puckRing.material.opacity = 0.5 + 0.25 * Math.sin(t * 3.0);
|
|
||||||
}
|
|
||||||
// node rings breathe when active
|
|
||||||
[9,13].forEach(id => {
|
|
||||||
const g = nodeMeshes[id]; if (!g) return;
|
|
||||||
const r = g.userData.parts.ring;
|
|
||||||
const s = 1 + 0.08 * Math.sin(t * 2 + id);
|
|
||||||
r.scale.set(s, s, s);
|
|
||||||
});
|
|
||||||
|
|
||||||
composer.render();
|
|
||||||
}
|
|
||||||
animate();
|
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
camera.aspect = window.innerWidth / window.innerHeight;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
||||||
composer.setSize(window.innerWidth, window.innerHeight);
|
|
||||||
});
|
|
||||||
|
|
||||||
// kick off
|
|
||||||
connect();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
||||||
<title>WiFlow · live WiFi-inferred pose</title>
|
|
||||||
<style>
|
|
||||||
:root{--bg:#0a0c10;--panel:#11151c;--amber:#ffb840;--green:#46e08a;--red:#ff5a5a;--mute:#7d8796;--line:#1d2430}
|
|
||||||
*{box-sizing:border-box}
|
|
||||||
body{margin:0;background:var(--bg);color:#dfe6ee;font:14px/1.5 'JetBrains Mono',ui-monospace,Menlo,monospace}
|
|
||||||
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;flex-wrap:wrap}
|
|
||||||
h1{font-size:15px;margin:0;letter-spacing:1px;text-transform:uppercase;font-weight:600}
|
|
||||||
h1 span{color:var(--amber)}
|
|
||||||
#banner{margin-left:auto;padding:5px 12px;border-radius:5px;font-weight:600;font-size:12px;letter-spacing:.5px}
|
|
||||||
.live{background:rgba(70,224,138,.15);color:var(--green);border:1px solid var(--green)}
|
|
||||||
.sim{background:rgba(255,184,64,.15);color:var(--amber);border:1px solid var(--amber)}
|
|
||||||
.down{background:rgba(255,90,90,.15);color:var(--red);border:1px solid var(--red)}
|
|
||||||
main{display:flex;gap:18px;padding:18px;flex-wrap:wrap}
|
|
||||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
|
|
||||||
canvas{background:#070a0e;border-radius:8px;display:block}
|
|
||||||
.label{font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:var(--mute);margin-bottom:8px}
|
|
||||||
.stats{min-width:240px}
|
|
||||||
.row{display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px dashed var(--line)}
|
|
||||||
.row .k{color:var(--mute)} .row .v{color:var(--amber);font-variant-numeric:tabular-nums}
|
|
||||||
.v.green{color:var(--green)}
|
|
||||||
.note{margin-top:12px;font-size:11px;color:var(--mute);line-height:1.6;max-width:300px}
|
|
||||||
.note b{color:#dfe6ee}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<h1>WiFlow · <span>live WiFi-inferred pose</span></h1>
|
|
||||||
<div id="banner" class="down">CONNECTING…</div>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<div class="card">
|
|
||||||
<div class="label">CSI → pose (skeleton) overlaid on your laptop camera</div>
|
|
||||||
<div id="stage" style="width:420px;height:560px;border-radius:8px;overflow:hidden;background:#070a0e">
|
|
||||||
<video id="cam" autoplay muted playsinline style="position:absolute;width:2px;height:2px;opacity:0;pointer-events:none"></video>
|
|
||||||
<canvas id="cv" width="420" height="560"></canvas>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
|
||||||
<button id="camBtn" style="background:var(--amber);color:#0a0c10;border:0;border-radius:6px;padding:7px 14px;font:inherit;font-weight:600;cursor:pointer">enable laptop camera</button>
|
|
||||||
<select id="camSel" style="display:none;background:var(--panel);color:#dfe6ee;border:1px solid var(--line);border-radius:6px;padding:6px;font:inherit;max-width:220px"></select>
|
|
||||||
</div>
|
|
||||||
<div id="camStatus" style="margin-top:6px;font-size:11px;color:var(--mute)">camera: off</div>
|
|
||||||
<div class="note" style="margin-top:8px">Camera is a <b>visual reference only</b> — it is NOT fed to the model. Overlay alignment is approximate (model trained in a different camera's frame).</div>
|
|
||||||
</div>
|
|
||||||
<div class="card stats">
|
|
||||||
<div class="label">live</div>
|
|
||||||
<div class="row"><span class="k">CSI source</span><span class="v" id="src">—</span></div>
|
|
||||||
<div class="row"><span class="k">nodes</span><span class="v" id="nodes">—</span></div>
|
|
||||||
<div class="row"><span class="k">presence</span><span class="v" id="pres">—</span></div>
|
|
||||||
<div class="row"><span class="k">motion</span><span class="v" id="motion">—</span></div>
|
|
||||||
<div class="row"><span class="k">pose fps</span><span class="v" id="fps">—</span></div>
|
|
||||||
<div class="note">
|
|
||||||
This skeleton is inferred <b>from WiFi CSI only</b> — no camera in the loop here. A model was
|
|
||||||
trained on paired (camera-pose, CSI) data in this room (ADR-079/180).
|
|
||||||
<br/><br/>
|
|
||||||
<b>Honest accuracy:</b> ~<b>59.5% PCK@0.10</b> on held-out data (vs a 50% mean-pose baseline →
|
|
||||||
<b>+9.4 pp real signal</b>). It captures <b>coarse</b> pose; fine detail is weak (PCK@0.05 ≈ 24%).
|
|
||||||
Same person / room / session — not validated cross-day or through-wall.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<script>
|
|
||||||
const POSE_WS = (new URLSearchParams(location.search)).get('ws') || `ws://${location.hostname||'localhost'}:8770/pose`;
|
|
||||||
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
|
|
||||||
const $ = id => document.getElementById(id);
|
|
||||||
let edges = [[5,7],[7,9],[6,8],[8,10],[5,6],[11,12],[5,11],[6,12],[11,13],[13,15],[12,14],[14,16],[0,1],[0,2],[1,3],[2,4],[0,5],[0,6]];
|
|
||||||
let last = null, frames = 0, t0 = performance.now();
|
|
||||||
|
|
||||||
function banner(state, txt){ const b=$('banner'); b.className=state; b.textContent=txt; }
|
|
||||||
|
|
||||||
// per-joint smoothing (EMA) so dropped/jittery CSI frames render fluidly (ADR-180 dead-reckoning, lite)
|
|
||||||
let sm = null;
|
|
||||||
function smooth(kps){
|
|
||||||
if(!sm){ sm = kps.map(p=>[p[0],p[1]]); return sm; }
|
|
||||||
const a=0.35; for(let i=0;i<kps.length;i++){ sm[i][0]+=a*(kps[i][0]-sm[i][0]); sm[i][1]+=a*(kps[i][1]-sm[i][1]); }
|
|
||||||
return sm;
|
|
||||||
}
|
|
||||||
const camEl=document.getElementById('cam');
|
|
||||||
function draw(p){
|
|
||||||
const W=cv.width, H=cv.height;
|
|
||||||
// paint the live camera frame onto the canvas (robust — no z-index/overlay tricks)
|
|
||||||
if(camEl && camEl.videoWidth>0){
|
|
||||||
ctx.save(); ctx.globalAlpha=0.9;
|
|
||||||
// cover-fit the camera frame into the canvas
|
|
||||||
const vr=camEl.videoWidth/camEl.videoHeight, cr=W/H;
|
|
||||||
let dw=W, dh=H, dx=0, dy=0;
|
|
||||||
if(vr>cr){ dh=H; dw=H*vr; dx=(W-dw)/2; } else { dw=W; dh=W/vr; dy=(H-dh)/2; }
|
|
||||||
ctx.drawImage(camEl, dx, dy, dw, dh); ctx.restore();
|
|
||||||
} else {
|
|
||||||
ctx.fillStyle='#070a0e'; ctx.fillRect(0,0,W,H);
|
|
||||||
}
|
|
||||||
if(!p || !p.kps){ return; }
|
|
||||||
const s = smooth(p.kps);
|
|
||||||
const k = s.map(([x,y])=>[x*W, y*H]);
|
|
||||||
ctx.lineWidth=5; ctx.strokeStyle=p.presence?'rgba(70,224,138,.95)':'rgba(125,135,150,.8)'; ctx.lineCap='round';
|
|
||||||
ctx.shadowColor='rgba(70,224,138,.6)'; ctx.shadowBlur=8;
|
|
||||||
for(const [a,b] of edges){ ctx.beginPath(); ctx.moveTo(k[a][0],k[a][1]); ctx.lineTo(k[b][0],k[b][1]); ctx.stroke(); }
|
|
||||||
ctx.shadowBlur=0;
|
|
||||||
for(const [x,y] of k){ ctx.beginPath(); ctx.arc(x,y,5,0,7); ctx.fillStyle=p.presence?'#ffb840':'#667'; ctx.fill(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- laptop webcam (visual reference only; NOT fed to the model) ----
|
|
||||||
let camStream=null;
|
|
||||||
async function startCam(deviceId){
|
|
||||||
if(camStream){ camStream.getTracks().forEach(t=>t.stop()); }
|
|
||||||
const constraints = deviceId ? {video:{deviceId:{exact:deviceId}}} : {video:true};
|
|
||||||
const st=document.getElementById('camStatus');
|
|
||||||
try{
|
|
||||||
st.textContent='camera: requesting…';
|
|
||||||
camStream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
||||||
const v=document.getElementById('cam'); v.muted=true; v.srcObject=camStream;
|
|
||||||
v.onloadedmetadata=()=>{ v.play().catch(err=>st.textContent='camera: play() blocked '+err.name); };
|
|
||||||
await v.play().catch(()=>{});
|
|
||||||
const tr=camStream.getVideoTracks()[0]; const ss=tr.getSettings();
|
|
||||||
// live readout: shows if real frames are flowing (videoWidth>0) and which device
|
|
||||||
const tick=()=>{ st.textContent = `camera: "${tr.label}" ${v.videoWidth}x${v.videoHeight} ${tr.readyState} ${v.paused?'PAUSED':'playing'}`; };
|
|
||||||
tick(); setInterval(tick, 1000);
|
|
||||||
document.getElementById('camBtn').textContent='switch camera ↻';
|
|
||||||
// populate the picker now that we have permission (labels need permission)
|
|
||||||
const devs = (await navigator.mediaDevices.enumerateDevices()).filter(d=>d.kind==='videoinput');
|
|
||||||
const sel=document.getElementById('camSel'); sel.style.display = devs.length>1?'inline-block':'none';
|
|
||||||
sel.innerHTML = devs.map((d,i)=>`<option value="${d.deviceId}">${d.label||('camera '+(i+1))}</option>`).join('');
|
|
||||||
const cur = camStream.getVideoTracks()[0].getSettings().deviceId; if(cur) sel.value=cur;
|
|
||||||
}catch(e){
|
|
||||||
document.getElementById('camBtn').textContent = 'camera error: '+e.name+(e.name==='NotReadableError'?' (in use by Zoom/Teams?)':'');
|
|
||||||
console.error('getUserMedia', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.getElementById('camBtn').addEventListener('click', ()=>startCam());
|
|
||||||
document.getElementById('camSel').addEventListener('change', e=>startCam(e.target.value));
|
|
||||||
|
|
||||||
function connect(){
|
|
||||||
banner('down','CONNECTING…');
|
|
||||||
const ws = new WebSocket(POSE_WS);
|
|
||||||
ws.onopen = ()=> banner('sim','WAITING FOR POSE…');
|
|
||||||
ws.onmessage = ev => {
|
|
||||||
const d = JSON.parse(ev.data);
|
|
||||||
if(d.type==='meta'){ edges = d.edges; return; }
|
|
||||||
if(d.type!=='pose') return;
|
|
||||||
last=d; frames++;
|
|
||||||
if(d.src==='esp32') banner('live','LIVE — WiFi-inferred pose (real ESP32 CSI)');
|
|
||||||
else banner('sim','SIMULATED CSI — not real ('+d.src+')');
|
|
||||||
$('src').textContent=d.src; $('src').className = d.src==='esp32'?'v green':'v';
|
|
||||||
$('nodes').textContent=(d.nodes||[]).join(', ')||'—';
|
|
||||||
$('pres').textContent=d.presence?'PRESENT':'—';
|
|
||||||
$('motion').textContent=(d.motion!=null?Math.round(d.motion):'—');
|
|
||||||
};
|
|
||||||
ws.onclose = ()=>{ banner('down','NO BRIDGE — start wiflow_infer.py'); setTimeout(connect,1500); };
|
|
||||||
ws.onerror = ()=> ws.close();
|
|
||||||
}
|
|
||||||
function loop(){ draw(last); const now=performance.now(); if(now-t0>1000){ $('fps').textContent=frames; frames=0; t0=now; } requestAnimationFrame(loop); }
|
|
||||||
connect(); loop();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"""Tiny threaded static server for the through-wall WiFi-CSI sensing demo.
|
|
||||||
|
|
||||||
Adapted from examples/three.js/server/serve-demo.py. Serves the
|
|
||||||
`examples/through-wall/` page so a browser can fetch index.html, then the
|
|
||||||
page connects directly to the LIVE sensing-server WebSocket at
|
|
||||||
ws://localhost:8765/ws/sensing (NOT proxied through here).
|
|
||||||
|
|
||||||
Why a threaded server (not `python -m http.server`)?
|
|
||||||
The stdlib SimpleHTTPServer is single-threaded; a browser opens several
|
|
||||||
parallel connections (HTML + the three.js CDN tags fetch in parallel),
|
|
||||||
the first eats the worker, the rest can stall. ThreadingHTTPServer fixes it.
|
|
||||||
|
|
||||||
IMPORTANT: this serves on port 8080 — port 8765 is taken by the
|
|
||||||
sensing-server's WebSocket. They are two different processes.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
# 1) start the REAL sensing-server (separate terminal):
|
|
||||||
# cd v2
|
|
||||||
# cargo build -p wifi-densepose-sensing-server
|
|
||||||
# ./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005
|
|
||||||
# 2) start this static server:
|
|
||||||
python examples/through-wall/serve.py
|
|
||||||
# 3) open:
|
|
||||||
# http://localhost:8080/examples/through-wall/index.html
|
|
||||||
|
|
||||||
Override the WS endpoint with a query param, e.g.:
|
|
||||||
http://localhost:8080/examples/through-wall/index.html?ws=ws://192.168.1.20:8765/ws/sensing
|
|
||||||
"""
|
|
||||||
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
PORT = int(os.environ.get("PORT", 8080))
|
|
||||||
|
|
||||||
# Serve from the repo root regardless of where this script is launched.
|
|
||||||
# This file lives at examples/through-wall/serve.py — two levels deep.
|
|
||||||
os.chdir(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
|
||||||
|
|
||||||
|
|
||||||
class NoCacheHandler(SimpleHTTPRequestHandler):
|
|
||||||
def end_headers(self):
|
|
||||||
# Aggressive no-cache so the browser ALWAYS fetches the latest
|
|
||||||
# index.html after edits, even on a soft refresh.
|
|
||||||
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
|
||||||
self.send_header("Pragma", "no-cache")
|
|
||||||
self.send_header("Expires", "0")
|
|
||||||
super().end_headers()
|
|
||||||
|
|
||||||
def log_message(self, fmt, *args): # quieter logs
|
|
||||||
sys.stderr.write("[serve] " + (fmt % args) + "\n")
|
|
||||||
|
|
||||||
|
|
||||||
PAGE = "examples/through-wall/index.html"
|
|
||||||
|
|
||||||
with ThreadingHTTPServer(("127.0.0.1", PORT), NoCacheHandler) as srv:
|
|
||||||
print(f"serving {os.getcwd()} on http://127.0.0.1:{PORT}/")
|
|
||||||
print(f" open http://localhost:{PORT}/{PAGE}")
|
|
||||||
print("")
|
|
||||||
print(" The page connects to the LIVE sensing-server at")
|
|
||||||
print(" ws://localhost:8765/ws/sensing (start it first — see README.md).")
|
|
||||||
print(" Override with ?ws=ws://HOST:PORT/ws/sensing")
|
|
||||||
try:
|
|
||||||
srv.serve_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
sys.exit(0)
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Rigorous A/B for WiFlow CSI->pose: is the held-out PCK real signal or split leakage?
|
|
||||||
|
|
||||||
For a dataset of {csi:[D], kps:17x[x,y,vis]} pairs, train the SAME small MLP under
|
|
||||||
several train/val SPLITS and report held-out PCK@0.10 vs the mean-pose baseline:
|
|
||||||
|
|
||||||
- chronological_80_20 : last 20% in time (val temporally ADJACENT to train -> leaks
|
|
||||||
via CSI/pose autocorrelation; this is what gave us +9.4)
|
|
||||||
- random_80_20 : shuffled (val frames interleaved with train -> MAX leak)
|
|
||||||
- blocked_gap : hold out a contiguous MIDDLE block with a time GAP buffer on
|
|
||||||
each side so val is NOT adjacent to any train frame -> the
|
|
||||||
honest, leakage-controlled test
|
|
||||||
|
|
||||||
If the model beats baseline on chronological/random but COLLAPSES to ~baseline on
|
|
||||||
blocked_gap, the apparent signal was temporal leakage, not generalizable CSI->pose.
|
|
||||||
|
|
||||||
Usage (ruvultra venv): python wiflow_ab.py --data ~/wiflow-room/dataset.jsonl
|
|
||||||
"""
|
|
||||||
import argparse, json, sys
|
|
||||||
import numpy as np, torch, torch.nn as nn
|
|
||||||
|
|
||||||
def _rec(r, X, Y, V, B):
|
|
||||||
X.append(r["csi"]); kp=r["kps"]
|
|
||||||
if kp and isinstance(kp[0], (list,tuple)): # 17 x [x,y(,vis)]
|
|
||||||
Y.append([c for k in kp for c in (k[0],k[1])]); V.append([(k[2] if len(k)>2 else 1.0) for k in kp])
|
|
||||||
else: # flat 34 (browser export, no vis)
|
|
||||||
Y.append(list(kp)); V.append([1.0]*17)
|
|
||||||
B.append(r.get("bucket"))
|
|
||||||
|
|
||||||
def load(path):
|
|
||||||
X,Y,V,B=[],[],[],[]
|
|
||||||
txt=open(path).read().strip()
|
|
||||||
if txt[:1] in "[{": # JSON (browser export: dict{samples:[]} or bare array)
|
|
||||||
d=json.loads(txt)
|
|
||||||
rows = d if isinstance(d,list) else d.get("samples", d.get("data", []))
|
|
||||||
for r in rows: _rec(r,X,Y,V,B)
|
|
||||||
else: # JSONL (python capture)
|
|
||||||
for line in txt.splitlines():
|
|
||||||
if line.strip(): _rec(json.loads(line),X,Y,V,B)
|
|
||||||
return np.array(X,np.float32), np.array(Y,np.float32), np.array(V,np.float32), B
|
|
||||||
|
|
||||||
class Net(nn.Module):
|
|
||||||
def __init__(s,din,dout):
|
|
||||||
super().__init__()
|
|
||||||
s.n=nn.Sequential(nn.Linear(din,384),nn.ReLU(),nn.Dropout(.35),
|
|
||||||
nn.Linear(384,192),nn.ReLU(),nn.Dropout(.35),
|
|
||||||
nn.Linear(192,96),nn.ReLU(),nn.Linear(96,dout),nn.Sigmoid())
|
|
||||||
def forward(s,x): return s.n(x)
|
|
||||||
|
|
||||||
def pck(pred,gt,vis,thr=0.10):
|
|
||||||
p=pred.reshape(-1,17,2); g=gt.reshape(-1,17,2)
|
|
||||||
d=np.linalg.norm(p-g,axis=2); m=vis>0.5
|
|
||||||
return float((d[m]<thr).mean()) if m.any() else 0.0
|
|
||||||
|
|
||||||
def split_idx(n, kind, B=None):
|
|
||||||
idx=np.arange(n)
|
|
||||||
if kind=="chronological_80_20":
|
|
||||||
c=int(n*.8); return idx[:c], idx[c:]
|
|
||||||
if kind=="random_80_20":
|
|
||||||
rng=np.random.default_rng(0); p=rng.permutation(n); c=int(n*.8); return p[:c], p[c:]
|
|
||||||
if kind=="blocked_gap":
|
|
||||||
# val = contiguous middle 20%; a WIDE 10% time gap each side guarantees no train
|
|
||||||
# frame is temporally adjacent to a val frame (kills frame-autocorrelation leakage).
|
|
||||||
v0=int(n*.4); v1=int(n*.6); gap=int(n*.10)
|
|
||||||
val=idx[v0:v1]; train=np.concatenate([idx[:max(0,v0-gap)], idx[min(n,v1+gap):]])
|
|
||||||
return train, val
|
|
||||||
if kind=="grouped_bucket":
|
|
||||||
# hold out ENTIRE activity buckets -> val poses/activities never seen in train.
|
|
||||||
# the strictest leakage-free test (only when bucket labels exist).
|
|
||||||
b=np.array([x if x is not None else -1 for x in B])
|
|
||||||
uniq=[u for u in sorted(set(b.tolist())) if u!=-1]
|
|
||||||
if len(uniq)<3: raise ValueError("too few buckets")
|
|
||||||
hold=set(uniq[::max(1,len(uniq)//3)][:max(1,len(uniq)//3)]) # ~1/3 of activities held out
|
|
||||||
val=idx[np.isin(b,list(hold))]; train=idx[~np.isin(b,list(hold))]
|
|
||||||
return train, val
|
|
||||||
raise ValueError(kind)
|
|
||||||
|
|
||||||
def run(X,Y,V,tr,va,epochs=250,seed=0):
|
|
||||||
torch.manual_seed(seed); np.random.seed(seed) # seed weight init + batch shuffle
|
|
||||||
dev="cuda" if torch.cuda.is_available() else "cpu"
|
|
||||||
mu,sd=X[tr].mean(0),X[tr].std(0)+1e-6
|
|
||||||
Xtr=torch.tensor((X[tr]-mu)/sd).to(dev); Ytr=torch.tensor(Y[tr]).to(dev)
|
|
||||||
Xva=torch.tensor((X[va]-mu)/sd).to(dev)
|
|
||||||
net=Net(X.shape[1],Y.shape[1]).to(dev)
|
|
||||||
opt=torch.optim.Adam(net.parameters(),lr=1e-3,weight_decay=1e-4); lf=nn.MSELoss()
|
|
||||||
best=(1e9,None)
|
|
||||||
for ep in range(epochs):
|
|
||||||
net.train(); perm=torch.randperm(len(Xtr),device=dev)
|
|
||||||
for i in range(0,len(Xtr),64):
|
|
||||||
j=perm[i:i+64]; opt.zero_grad(); loss=lf(net(Xtr[j]),Ytr[j]); loss.backward(); opt.step()
|
|
||||||
net.eval()
|
|
||||||
with torch.no_grad(): pv=net(Xva).cpu().numpy()
|
|
||||||
vl=float(((pv-Y[va])**2).mean())
|
|
||||||
if vl<best[0]: best=(vl,pv)
|
|
||||||
base=np.tile(Y[tr].mean(0),(len(va),1))
|
|
||||||
return pck(best[1],Y[va],V[va]), pck(base,Y[va],V[va])
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap=argparse.ArgumentParser(); ap.add_argument("--data",required=True)
|
|
||||||
ap.add_argument("--epochs",type=int,default=250); ap.add_argument("--seeds",type=int,default=3)
|
|
||||||
a=ap.parse_args()
|
|
||||||
X,Y,V,B=load(a.data); n=len(X)
|
|
||||||
has_buckets=any(x is not None for x in B)
|
|
||||||
print(f"[ab] {n} samples, X={X.shape}, buckets={'yes' if has_buckets else 'no'}, "
|
|
||||||
f"seeds={a.seeds}, epochs={a.epochs}\n")
|
|
||||||
print(f"{'split':<22}{'model PCK@0.10':>16}{'baseline':>11}{'delta (mean±sd)':>20} verdict")
|
|
||||||
print("-"*86)
|
|
||||||
splits=["chronological_80_20","random_80_20","blocked_gap"]+(["grouped_bucket"] if has_buckets else [])
|
|
||||||
for kind in splits:
|
|
||||||
try:
|
|
||||||
tr,va=split_idx(n,kind,B)
|
|
||||||
ms=[]; bs=[]
|
|
||||||
for s in range(a.seeds):
|
|
||||||
m,b=run(X,Y,V,tr,va,a.epochs,seed=s); ms.append(m); bs.append(b)
|
|
||||||
ms=np.array(ms)*100; bs=np.array(bs)*100; ds=ms-bs
|
|
||||||
dm,dsd=ds.mean(),ds.std()
|
|
||||||
# REAL only if the mean delta minus 1 sd still clears the 1.5pp threshold (robust to seed variance)
|
|
||||||
verdict = "REAL signal" if dm-dsd>1.5 else ("weak/uncertain" if dm>1.5 else "no signal (==baseline)")
|
|
||||||
print(f"{kind:<22}{ms.mean():>13.1f}±{ms.std():>3.1f}{bs.mean():>10.1f}%{dm:>+12.1f}±{dsd:>4.1f}pp {verdict}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"{kind:<22} skipped: {e}")
|
|
||||||
print(f"\nmean±sd over {a.seeds} seeds (weight init + batch order). blocked_gap = 10% time gap each")
|
|
||||||
print("side; grouped_bucket holds out ENTIRE activities (strictest). If only the LEAKY splits")
|
|
||||||
print("(chronological/random) beat baseline, the apparent signal is leakage, not generalizable pose.")
|
|
||||||
|
|
||||||
if __name__=="__main__": main()
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,161 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""WiFlow-style camera-supervised capture (ADR-079 / ADR-180).
|
|
||||||
|
|
||||||
Runs on a box with BOTH a camera (ground truth) and reachable live CSI:
|
|
||||||
- opens a camera, runs MediaPipe Pose -> 17 COCO keypoints (the LABEL),
|
|
||||||
- subscribes to the sensing-server /ws/sensing (the INPUT: CSI features +
|
|
||||||
20x20 signal-field),
|
|
||||||
- writes timestamp-aligned (csi -> pose) pairs to a JSONL dataset.
|
|
||||||
|
|
||||||
This is the *collect* phase of camera-supervised CSI->pose training. The camera
|
|
||||||
and the CSI nodes MUST see the same person in the same space at the same time,
|
|
||||||
or the pairs are meaningless. Honest by construction: we only emit a pair when
|
|
||||||
BOTH a confident camera pose AND a live (source=esp32) CSI frame are present in
|
|
||||||
the same ~100 ms window.
|
|
||||||
|
|
||||||
Usage (on ruvultra, with the CSI tunneled to localhost:8765):
|
|
||||||
python3 wiflow_capture.py --ws ws://localhost:8765/ws/sensing \
|
|
||||||
--cam 0 --out ~/wiflow-room/dataset.jsonl --seconds 180
|
|
||||||
"""
|
|
||||||
import argparse, asyncio, json, time, threading, sys, os
|
|
||||||
from collections import deque
|
|
||||||
|
|
||||||
import urllib.request
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
import mediapipe as mp
|
|
||||||
from mediapipe.tasks.python import BaseOptions
|
|
||||||
from mediapipe.tasks.python.vision import PoseLandmarker, PoseLandmarkerOptions, RunningMode
|
|
||||||
import websockets
|
|
||||||
|
|
||||||
_MODEL_URL = ("https://storage.googleapis.com/mediapipe-models/pose_landmarker/"
|
|
||||||
"pose_landmarker_lite/float16/latest/pose_landmarker_lite.task")
|
|
||||||
|
|
||||||
def ensure_model(path: str) -> str:
|
|
||||||
if not os.path.exists(path):
|
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
||||||
print(f"[capture] downloading pose model -> {path}", flush=True)
|
|
||||||
urllib.request.urlretrieve(_MODEL_URL, path)
|
|
||||||
return path
|
|
||||||
|
|
||||||
# MediaPipe Pose (33 landmarks) -> 17 COCO keypoints (same mapping as
|
|
||||||
# scripts/collect-ground-truth.py, ADR-079).
|
|
||||||
COCO_FROM_MP = [0, 2, 5, 7, 8, 11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28]
|
|
||||||
COCO_NAMES = ["nose","l_eye","r_eye","l_ear","r_ear","l_sho","r_sho","l_elb",
|
|
||||||
"r_elb","l_wri","r_wri","l_hip","r_hip","l_knee","r_knee","l_ank","r_ank"]
|
|
||||||
|
|
||||||
# ---- shared state between the CSI (async) thread and the camera (sync) loop ----
|
|
||||||
_latest_csi = {"t": 0.0, "frame": None}
|
|
||||||
_csi_lock = threading.Lock()
|
|
||||||
_stop = threading.Event()
|
|
||||||
|
|
||||||
|
|
||||||
def csi_thread(ws_url: str):
|
|
||||||
"""Background thread: keep the most recent LIVE csi frame in _latest_csi."""
|
|
||||||
async def run():
|
|
||||||
while not _stop.is_set():
|
|
||||||
try:
|
|
||||||
async with websockets.connect(ws_url, open_timeout=8, ping_interval=20) as ws:
|
|
||||||
while not _stop.is_set():
|
|
||||||
msg = await asyncio.wait_for(ws.recv(), timeout=8)
|
|
||||||
d = json.loads(msg)
|
|
||||||
with _csi_lock:
|
|
||||||
_latest_csi["t"] = time.time()
|
|
||||||
_latest_csi["frame"] = d
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[csi] reconnect ({e})", flush=True)
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
asyncio.new_event_loop().run_until_complete(run())
|
|
||||||
|
|
||||||
|
|
||||||
def csi_vector(frame: dict):
|
|
||||||
"""Flatten a csi frame to a fixed-length input vector: features + field."""
|
|
||||||
f = frame.get("features", {}) or {}
|
|
||||||
feats = [f.get("mean_rssi", 0.0), f.get("variance", 0.0),
|
|
||||||
f.get("motion_band_power", 0.0), f.get("breathing_band_power", 0.0)]
|
|
||||||
# per-node mean_rssi/variance/motion for up to the 2 nodes (9, 13)
|
|
||||||
pernode = {nf.get("node_id"): (nf.get("features") or {}) for nf in (frame.get("node_features") or [])}
|
|
||||||
for nid in (9, 13):
|
|
||||||
nf = pernode.get(nid, {})
|
|
||||||
feats += [nf.get("mean_rssi", 0.0), nf.get("variance", 0.0), nf.get("motion_band_power", 0.0)]
|
|
||||||
field = (frame.get("signal_field", {}) or {}).get("values") or []
|
|
||||||
field = (field + [0.0] * 400)[:400]
|
|
||||||
return feats + field # 4 + 6 + 400 = 410-d
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description="WiFlow camera-supervised CSI<->pose capture (ADR-180).")
|
|
||||||
ap.add_argument("--ws", default="ws://localhost:8765/ws/sensing")
|
|
||||||
ap.add_argument("--cam", type=int, default=0)
|
|
||||||
ap.add_argument("--out", default=os.path.expanduser("~/wiflow-room/dataset.jsonl"))
|
|
||||||
ap.add_argument("--seconds", type=int, default=180)
|
|
||||||
ap.add_argument("--min-vis", type=float, default=0.5, help="min mean landmark visibility to accept a pose label")
|
|
||||||
ap.add_argument("--max-skew-ms", type=float, default=150, help="max csi/pose time skew to pair")
|
|
||||||
ap.add_argument("--require-esp32", action="store_true", default=True,
|
|
||||||
help="only pair when csi source==esp32 (real). Default on.")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(args.out), exist_ok=True)
|
|
||||||
th = threading.Thread(target=csi_thread, args=(args.ws,), daemon=True)
|
|
||||||
th.start()
|
|
||||||
|
|
||||||
cap = cv2.VideoCapture(args.cam)
|
|
||||||
if not cap.isOpened():
|
|
||||||
print(f"ERROR: cannot open camera {args.cam}", file=sys.stderr); sys.exit(2)
|
|
||||||
W = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 640
|
|
||||||
H = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 480
|
|
||||||
model_path = ensure_model(os.path.expanduser("~/wiflow-room/pose_landmarker_lite.task"))
|
|
||||||
landmarker = PoseLandmarker.create_from_options(PoseLandmarkerOptions(
|
|
||||||
base_options=BaseOptions(model_asset_path=model_path),
|
|
||||||
running_mode=RunningMode.IMAGE, min_pose_detection_confidence=0.5))
|
|
||||||
|
|
||||||
n_pairs = 0; n_nopose = 0; n_nocsi = 0; n_skew = 0; n_sim = 0
|
|
||||||
t0 = time.time()
|
|
||||||
print(f"[capture] camera {args.cam} {W}x{H} -> {args.out} for {args.seconds}s")
|
|
||||||
print("[capture] stand in view AND in the CSI field; move/walk so poses vary. Ctrl-C to stop.")
|
|
||||||
with open(args.out, "a") as out:
|
|
||||||
try:
|
|
||||||
while time.time() - t0 < args.seconds:
|
|
||||||
ok, frame = cap.read()
|
|
||||||
if not ok:
|
|
||||||
continue
|
|
||||||
now = time.time()
|
|
||||||
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
||||||
res = landmarker.detect(mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb))
|
|
||||||
if not res.pose_landmarks:
|
|
||||||
n_nopose += 1; continue
|
|
||||||
lm = res.pose_landmarks[0]
|
|
||||||
kps = [[lm[i].x, lm[i].y, lm[i].visibility] for i in COCO_FROM_MP]
|
|
||||||
vis = float(np.mean([k[2] for k in kps]))
|
|
||||||
if vis < args.min_vis:
|
|
||||||
n_nopose += 1; continue
|
|
||||||
with _csi_lock:
|
|
||||||
ct = _latest_csi["t"]; cf = _latest_csi["frame"]
|
|
||||||
if cf is None:
|
|
||||||
n_nocsi += 1; continue
|
|
||||||
if (now - ct) * 1000.0 > args.max_skew_ms:
|
|
||||||
n_skew += 1; continue
|
|
||||||
if args.require_esp32 and cf.get("source") != "esp32":
|
|
||||||
n_sim += 1; continue
|
|
||||||
rec = {"t": now, "vis": round(vis, 3),
|
|
||||||
"kps": [[round(x, 4), round(y, 4), round(v, 3)] for x, y, v in kps],
|
|
||||||
"csi": csi_vector(cf),
|
|
||||||
"src": cf.get("source"),
|
|
||||||
"nodes": sorted(n.get("node_id") for n in cf.get("nodes", []) if n.get("node_id") is not None)}
|
|
||||||
out.write(json.dumps(rec) + "\n")
|
|
||||||
n_pairs += 1
|
|
||||||
if n_pairs % 30 == 0:
|
|
||||||
out.flush()
|
|
||||||
el = int(now - t0)
|
|
||||||
print(f"[capture] t+{el:3d}s pairs={n_pairs} (skip: nopose={n_nopose} nocsi={n_nocsi} skew={n_skew} sim={n_sim})", flush=True)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n[capture] stopped by user")
|
|
||||||
_stop.set(); cap.release()
|
|
||||||
print(f"[capture] DONE. wrote {n_pairs} paired samples to {args.out}")
|
|
||||||
print(f"[capture] skipped: no-pose={n_nopose} no-csi={n_nocsi} skew={n_skew} simulated={n_sim}")
|
|
||||||
if n_pairs == 0:
|
|
||||||
print("[capture] WARNING: 0 pairs — check camera sees you AND csi source==esp32 (live).")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Live CSI->pose inference bridge (ADR-180).
|
|
||||||
|
|
||||||
Runs on the box with the live CSI. Loads the camera-supervised model (numpy,
|
|
||||||
no torch needed), subscribes to /ws/sensing, runs a forward pass per frame, and
|
|
||||||
broadcasts the predicted 17-keypoint pose to HTML clients on ws://:8770/pose.
|
|
||||||
|
|
||||||
python wiflow_infer.py --model model/model.npz \
|
|
||||||
--in ws://localhost:8765/ws/sensing --port 8770
|
|
||||||
"""
|
|
||||||
import argparse, asyncio, json, os
|
|
||||||
import numpy as np
|
|
||||||
import websockets
|
|
||||||
|
|
||||||
# COCO skeleton edges (for the client; sent once in 'meta')
|
|
||||||
EDGES = [[5,7],[7,9],[6,8],[8,10],[5,6],[11,12],[5,11],[6,12],
|
|
||||||
[11,13],[13,15],[12,14],[14,16],[0,1],[0,2],[1,3],[2,4],[0,5],[0,6]]
|
|
||||||
|
|
||||||
def csi_vector(frame):
|
|
||||||
f = frame.get("features", {}) or {}
|
|
||||||
feats = [f.get("mean_rssi",0.0), f.get("variance",0.0),
|
|
||||||
f.get("motion_band_power",0.0), f.get("breathing_band_power",0.0)]
|
|
||||||
pernode = {nf.get("node_id"): (nf.get("features") or {}) for nf in (frame.get("node_features") or [])}
|
|
||||||
for nid in (9,13):
|
|
||||||
nf = pernode.get(nid,{}); feats += [nf.get("mean_rssi",0.0), nf.get("variance",0.0), nf.get("motion_band_power",0.0)]
|
|
||||||
field = (frame.get("signal_field",{}) or {}).get("values") or []
|
|
||||||
field = (field + [0.0]*400)[:400]
|
|
||||||
return np.array(feats + field, np.float32)
|
|
||||||
|
|
||||||
class Model:
|
|
||||||
def __init__(self, path):
|
|
||||||
z = np.load(path)
|
|
||||||
self.mu, self.sd = z["mu"], z["sd"]
|
|
||||||
self.W = [z["net_0_weight"], z["net_3_weight"], z["net_6_weight"], z["net_8_weight"]]
|
|
||||||
self.b = [z["net_0_bias"], z["net_3_bias"], z["net_6_bias"], z["net_8_bias"]]
|
|
||||||
def __call__(self, x):
|
|
||||||
h = (x - self.mu) / self.sd
|
|
||||||
for i in range(3):
|
|
||||||
h = np.maximum(0.0, h @ self.W[i].T + self.b[i]) # Linear+ReLU
|
|
||||||
out = 1.0/(1.0+np.exp(-(h @ self.W[3].T + self.b[3]))) # Linear+Sigmoid -> 34
|
|
||||||
return out.reshape(17,2)
|
|
||||||
|
|
||||||
CLIENTS = set()
|
|
||||||
LATEST = {"pose": None}
|
|
||||||
|
|
||||||
async def serve_client(ws):
|
|
||||||
CLIENTS.add(ws)
|
|
||||||
try:
|
|
||||||
await ws.send(json.dumps({"type":"meta","edges":EDGES}))
|
|
||||||
async for _ in ws: # client is read-only; just keep alive
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
CLIENTS.discard(ws)
|
|
||||||
|
|
||||||
async def infer_loop(model, in_url):
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
async with websockets.connect(in_url, open_timeout=8, ping_interval=20) as ws:
|
|
||||||
async for msg in ws:
|
|
||||||
d = json.loads(msg)
|
|
||||||
kp = model(csi_vector(d))
|
|
||||||
cls = d.get("classification",{})
|
|
||||||
payload = {"type":"pose","src":d.get("source"),
|
|
||||||
"presence":bool(cls.get("presence")),
|
|
||||||
"motion":(d.get("features",{}) or {}).get("motion_band_power"),
|
|
||||||
"kps":[[round(float(x),4),round(float(y),4)] for x,y in kp],
|
|
||||||
"nodes":sorted(n.get("node_id") for n in d.get("nodes",[]) if n.get("node_id") is not None)}
|
|
||||||
LATEST["pose"]=payload
|
|
||||||
if CLIENTS:
|
|
||||||
dead=[]
|
|
||||||
for c in list(CLIENTS):
|
|
||||||
try: await c.send(json.dumps(payload))
|
|
||||||
except Exception: dead.append(c)
|
|
||||||
for c in dead: CLIENTS.discard(c)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[infer] reconnect ({e})", flush=True); await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--model", default=os.path.join(os.path.dirname(__file__),"model","model.npz"))
|
|
||||||
ap.add_argument("--in", dest="in_url", default="ws://localhost:8765/ws/sensing")
|
|
||||||
ap.add_argument("--port", type=int, default=8770)
|
|
||||||
args = ap.parse_args()
|
|
||||||
model = Model(args.model)
|
|
||||||
print(f"[infer] model {args.model} loaded; serving predicted poses on ws://0.0.0.0:{args.port}/pose")
|
|
||||||
async with websockets.serve(serve_client, "0.0.0.0", args.port):
|
|
||||||
await infer_loop(model, args.in_url)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Train a CSI->pose model on the camera-supervised dataset (ADR-079/180).
|
|
||||||
|
|
||||||
Input : 410-d CSI vector (4 global feats + 6 per-node + 400 signal-field).
|
|
||||||
Target : 17 COCO keypoints (x,y), normalized 0..1 from the camera (ground truth).
|
|
||||||
Reports HONEST held-out PCK@k + MPJPE on a chronological val split (the last
|
|
||||||
20% of the session — never trained on), so the number is not leaked.
|
|
||||||
|
|
||||||
Usage (ruvultra venv):
|
|
||||||
python wiflow_train.py --data ~/wiflow-room/dataset.jsonl --out ~/wiflow-room/model.pt
|
|
||||||
"""
|
|
||||||
import argparse, json, math, os, sys
|
|
||||||
import numpy as np
|
|
||||||
import torch, torch.nn as nn
|
|
||||||
|
|
||||||
|
|
||||||
def load(path):
|
|
||||||
X, Y, V = [], [], []
|
|
||||||
with open(path) as f:
|
|
||||||
for line in f:
|
|
||||||
r = json.loads(line)
|
|
||||||
X.append(r["csi"]) # 410
|
|
||||||
kp = r["kps"] # 17 x [x,y,vis]
|
|
||||||
Y.append([c for k in kp for c in (k[0], k[1])]) # 34
|
|
||||||
V.append([k[2] for k in kp]) # 17 visibilities
|
|
||||||
return np.array(X, np.float32), np.array(Y, np.float32), np.array(V, np.float32)
|
|
||||||
|
|
||||||
|
|
||||||
class Net(nn.Module):
|
|
||||||
def __init__(self, din, dout):
|
|
||||||
super().__init__()
|
|
||||||
self.net = nn.Sequential(
|
|
||||||
nn.Linear(din, 512), nn.ReLU(), nn.Dropout(0.3),
|
|
||||||
nn.Linear(512, 256), nn.ReLU(), nn.Dropout(0.3),
|
|
||||||
nn.Linear(256, 128), nn.ReLU(),
|
|
||||||
nn.Linear(128, dout), nn.Sigmoid()) # coords in 0..1
|
|
||||||
def forward(self, x): return self.net(x)
|
|
||||||
|
|
||||||
|
|
||||||
def pck(pred, gt, vis, thr):
|
|
||||||
# pred/gt: [N,34] -> [N,17,2]; PCK@thr in normalized image units, visible kps only
|
|
||||||
p = pred.reshape(-1, 17, 2); g = gt.reshape(-1, 17, 2)
|
|
||||||
d = np.linalg.norm(p - g, axis=2) # [N,17]
|
|
||||||
m = vis > 0.5
|
|
||||||
return float((d[m] < thr).mean()) if m.any() else 0.0, float(d[m].mean()) if m.any() else float("nan")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--data", required=True)
|
|
||||||
ap.add_argument("--out", default=os.path.expanduser("~/wiflow-room/model.pt"))
|
|
||||||
ap.add_argument("--epochs", type=int, default=300)
|
|
||||||
ap.add_argument("--bs", type=int, default=64)
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
X, Y, V = load(args.data)
|
|
||||||
n = len(X)
|
|
||||||
print(f"[train] {n} samples, X={X.shape} Y={Y.shape}")
|
|
||||||
if n < 200:
|
|
||||||
print("[train] too few samples"); sys.exit(2)
|
|
||||||
|
|
||||||
# chronological split (NOT shuffled) so val is a held-out time segment -> honest
|
|
||||||
cut = int(n * 0.8)
|
|
||||||
mu, sd = X[:cut].mean(0), X[:cut].std(0) + 1e-6 # standardize on train only
|
|
||||||
Xn = (X - mu) / sd
|
|
||||||
dev = "cuda" if torch.cuda.is_available() else "cpu"
|
|
||||||
Xtr = torch.tensor(Xn[:cut]).to(dev); Ytr = torch.tensor(Y[:cut]).to(dev)
|
|
||||||
Xva = torch.tensor(Xn[cut:]).to(dev); Yva = Y[cut:]; Vva = V[cut:]
|
|
||||||
|
|
||||||
# mean-pose baseline (predict the train-mean pose for everything) — the bar to beat
|
|
||||||
mean_pose = Y[:cut].mean(0)
|
|
||||||
base_pck, base_mpjpe = pck(np.tile(mean_pose, (len(Yva), 1)), Yva, Vva, 0.10)
|
|
||||||
|
|
||||||
net = Net(X.shape[1], Y.shape[1]).to(dev)
|
|
||||||
opt = torch.optim.Adam(net.parameters(), lr=1e-3, weight_decay=1e-4)
|
|
||||||
lossf = nn.MSELoss()
|
|
||||||
best = (1e9, None)
|
|
||||||
for ep in range(args.epochs):
|
|
||||||
net.train(); perm = torch.randperm(len(Xtr), device=dev)
|
|
||||||
for i in range(0, len(Xtr), args.bs):
|
|
||||||
idx = perm[i:i+args.bs]
|
|
||||||
opt.zero_grad(); out = net(Xtr[idx]); loss = lossf(out, Ytr[idx]); loss.backward(); opt.step()
|
|
||||||
if (ep + 1) % 20 == 0 or ep == args.epochs - 1:
|
|
||||||
net.eval()
|
|
||||||
with torch.no_grad(): pv = net(Xva).cpu().numpy()
|
|
||||||
p10, mpj = pck(pv, Yva, Vva, 0.10); p05, _ = pck(pv, Yva, Vva, 0.05)
|
|
||||||
vloss = float(((pv - Yva) ** 2).mean())
|
|
||||||
print(f"[train] ep{ep+1:3d} val_mse={vloss:.4f} PCK@0.10={p10*100:.1f}% PCK@0.05={p05*100:.1f}% MPJPE={mpj:.4f}")
|
|
||||||
if vloss < best[0]: best = (vloss, {"sd": net.state_dict(), "p10": p10, "p05": p05, "mpj": mpj})
|
|
||||||
|
|
||||||
torch.save({"model": best[1]["sd"], "mu": mu, "sd": sd, "din": X.shape[1]}, args.out)
|
|
||||||
print("\n==================== HONEST RESULT (held-out 20%, never trained) ====================")
|
|
||||||
print(f" MEAN-POSE BASELINE : PCK@0.10 = {base_pck*100:.1f}% MPJPE = {base_mpjpe:.4f} (the bar to beat)")
|
|
||||||
print(f" CSI->POSE MODEL : PCK@0.10 = {best[1]['p10']*100:.1f}% PCK@0.05 = {best[1]['p05']*100:.1f}% MPJPE = {best[1]['mpj']:.4f}")
|
|
||||||
delta = (best[1]['p10'] - base_pck) * 100
|
|
||||||
print(f" VERDICT: model {'BEATS' if delta>1 else 'does NOT beat'} mean-pose baseline by {delta:+.1f} pp "
|
|
||||||
f"-> {'real CSI->pose signal' if delta>1 else 'NO usable CSI->pose signal (honest negative)'}")
|
|
||||||
print(f" saved -> {args.out}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (production) or ESP32-C6 (research target — Wi-Fi 6 / 802.15.4 / TWT / LP-core hibernation, see [ADR-110](../../docs/adr/ADR-110-esp32-c6-firmware-extension.md)) and transforms it into real-time presence detection, vital sign monitoring, and programmable sensing -- all without cameras or wearables. Part of the [WiFi-DensePose](../../README.md) project.
|
This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (production) or ESP32-C6 (research target — Wi-Fi 6 / 802.15.4 / TWT / LP-core hibernation, see [ADR-110](../../docs/adr/ADR-110-esp32-c6-firmware-extension.md)) and transforms it into real-time presence detection, vital sign monitoring, and programmable sensing -- all without cameras or wearables. Part of the [WiFi-DensePose](../../README.md) project.
|
||||||
|
|
||||||
[](https://docs.espressif.com/projects/esp-idf/en/v5.4/)
|
[](https://docs.espressif.com/projects/esp-idf/en/v5.2/)
|
||||||
[](https://www.espressif.com/en/products/socs/esp32-s3)
|
[](https://www.espressif.com/en/products/socs/esp32-s3)
|
||||||
[](../../LICENSE)
|
[](../../LICENSE)
|
||||||
[](#memory-budget)
|
[](#memory-budget)
|
||||||
@@ -48,18 +48,10 @@ with `--flash_size 4MB`.
|
|||||||
# From the repository root:
|
# From the repository root:
|
||||||
MSYS_NO_PATHCONV=1 docker run --rm \
|
MSYS_NO_PATHCONV=1 docker run --rm \
|
||||||
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
||||||
espressif/idf:v5.4 bash -c \
|
espressif/idf:v5.2 bash -c \
|
||||||
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
|
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Display-less boards (ESP32-S3-DevKitC-1 and similar):** build with the
|
|
||||||
> `sdkconfig.defaults.devkitc` overlay instead — the default build compiles
|
|
||||||
> display support in, and the runtime panel probe false-positives on boards
|
|
||||||
> with no panel, which disables the RuView#893 MGMT+DATA CSI upgrade and
|
|
||||||
> collapses CSI yield to 0 pps. See the header of
|
|
||||||
> [`sdkconfig.defaults.devkitc`](sdkconfig.defaults.devkitc) for the exact
|
|
||||||
> build command.
|
|
||||||
|
|
||||||
### 2. Flash
|
### 2. Flash
|
||||||
|
|
||||||
Offsets must match `partitions_display.csv` (8 MB) or `partitions_4mb.csv` (4 MB):
|
Offsets must match `partitions_display.csv` (8 MB) or `partitions_4mb.csv` (4 MB):
|
||||||
@@ -258,7 +250,7 @@ Offset Size Field
|
|||||||
# From the repository root:
|
# From the repository root:
|
||||||
MSYS_NO_PATHCONV=1 docker run --rm \
|
MSYS_NO_PATHCONV=1 docker run --rm \
|
||||||
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
||||||
espressif/idf:v5.4 bash -c \
|
espressif/idf:v5.2 bash -c \
|
||||||
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
|
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -276,7 +268,7 @@ To change Kconfig settings before building:
|
|||||||
```bash
|
```bash
|
||||||
MSYS_NO_PATHCONV=1 docker run --rm -it \
|
MSYS_NO_PATHCONV=1 docker run --rm -it \
|
||||||
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
||||||
espressif/idf:v5.4 bash -c \
|
espressif/idf:v5.2 bash -c \
|
||||||
"idf.py set-target esp32s3 && idf.py menuconfig"
|
"idf.py set-target esp32s3 && idf.py menuconfig"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -468,29 +468,3 @@ menu "Mock CSI (QEMU Testing)"
|
|||||||
depends on CSI_MOCK_ENABLED
|
depends on CSI_MOCK_ENABLED
|
||||||
default n
|
default n
|
||||||
endmenu
|
endmenu
|
||||||
|
|
||||||
menu "Onboard LED (ADR-183)"
|
|
||||||
|
|
||||||
config LED_GAMMA_VIZ
|
|
||||||
bool "Onboard WS2812: 40 Hz gamma flicker + CSI-motion colour"
|
|
||||||
default y
|
|
||||||
help
|
|
||||||
Drive the onboard WS2812 as a GENUS-style 40 Hz gamma square wave
|
|
||||||
(12.5 ms on / 12.5 ms off, 50% duty). The ON-phase colour is live
|
|
||||||
CSI motion (edge motion_energy) mapped through the ruv-neural-viz
|
|
||||||
viridis colormap (still=purple, moving=yellow).
|
|
||||||
|
|
||||||
Disable to leave the LED off at boot — lower power, no flicker.
|
|
||||||
NOTE: a 40 Hz flicker can affect photosensitive users; disable or
|
|
||||||
shield the LED in those environments. Not a medical device.
|
|
||||||
|
|
||||||
config LED_MOTION_FULLSCALE_MILLI
|
|
||||||
int "Motion value (x1000) that saturates the colormap to yellow"
|
|
||||||
depends on LED_GAMMA_VIZ
|
|
||||||
default 250
|
|
||||||
range 1 100000
|
|
||||||
help
|
|
||||||
edge motion_energy that maps to the top (yellow) of the viridis
|
|
||||||
colormap, in milli-units (250 = 0.25). Lower = more sensitive
|
|
||||||
(reaches yellow with less motion).
|
|
||||||
endmenu
|
|
||||||
|
|||||||
@@ -319,9 +319,7 @@ static void emit_feature_state(void)
|
|||||||
(uint64_t)esp_timer_get_time(),
|
(uint64_t)esp_timer_get_time(),
|
||||||
profile);
|
profile);
|
||||||
|
|
||||||
/* feature_state is ~1 Hz and small — priority path so the CSI ENOMEM
|
int sent = stream_sender_send((const uint8_t *)&pkt, sizeof(pkt));
|
||||||
* backoff can't starve it (#1183). */
|
|
||||||
int sent = stream_sender_send_priority((const uint8_t *)&pkt, sizeof(pkt));
|
|
||||||
if (sent < 0) {
|
if (sent < 0) {
|
||||||
ESP_LOGW(TAG, "feature_state emit failed");
|
ESP_LOGW(TAG, "feature_state emit failed");
|
||||||
}
|
}
|
||||||
@@ -335,14 +333,11 @@ static void slow_loop_cb(TimerHandle_t t)
|
|||||||
* detect sync-error drift. */
|
* detect sync-error drift. */
|
||||||
uint8_t nid[8];
|
uint8_t nid[8];
|
||||||
node_id_bytes(nid);
|
node_id_bytes(nid);
|
||||||
/* #1183: report the actual send result — the old log printed "HEALTH sent"
|
rv_mesh_send_health(s_role, s_mesh_epoch, nid);
|
||||||
* unconditionally even when rv_mesh_send returned ESP_FAIL. */
|
|
||||||
esp_err_t health_rc = rv_mesh_send_health(s_role, s_mesh_epoch, nid);
|
|
||||||
|
|
||||||
ESP_LOGI(TAG, "slow tick (state=%u, feature_state_seq=%u, role=%u, epoch=%u) HEALTH %s",
|
ESP_LOGI(TAG, "slow tick (state=%u, feature_state_seq=%u, role=%u, epoch=%u) HEALTH sent",
|
||||||
(unsigned)s_state, (unsigned)s_feature_state_seq,
|
(unsigned)s_state, (unsigned)s_feature_state_seq,
|
||||||
(unsigned)s_role, (unsigned)s_mesh_epoch,
|
(unsigned)s_role, (unsigned)s_mesh_epoch);
|
||||||
health_rc == ESP_OK ? "sent" : "FAILED");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Public API ---- */
|
/* ---- Public API ---- */
|
||||||
|
|||||||
@@ -341,9 +341,7 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
|
|||||||
memcpy(&sync[24], &s_sequence, 4); /* high-water seq for pairing */
|
memcpy(&sync[24], &s_sequence, 4); /* high-water seq for pairing */
|
||||||
uint32_t zero32 = 0;
|
uint32_t zero32 = 0;
|
||||||
memcpy(&sync[28], &zero32, 4); /* reserved (room for leader_id low32) */
|
memcpy(&sync[28], &zero32, 4); /* reserved (room for leader_id low32) */
|
||||||
/* Sync packets are 32 B at ~0.5 Hz — priority path so the CSI
|
int sr = stream_sender_send(sync, sizeof(sync));
|
||||||
* ENOMEM backoff can't starve cross-node time alignment (#1183). */
|
|
||||||
int sr = stream_sender_send_priority(sync, sizeof(sync));
|
|
||||||
static uint32_t s_sync_count = 0;
|
static uint32_t s_sync_count = 0;
|
||||||
s_sync_count++;
|
s_sync_count++;
|
||||||
if (s_sync_count <= 3 || (s_sync_count % 60) == 0) {
|
if (s_sync_count <= 3 || (s_sync_count % 60) == 0) {
|
||||||
|
|||||||
@@ -114,19 +114,6 @@ esp_err_t display_task_start(void)
|
|||||||
/* Init touch (optional) */
|
/* Init touch (optional) */
|
||||||
esp_err_t touch_ret = display_hal_init_touch();
|
esp_err_t touch_ret = display_hal_init_touch();
|
||||||
|
|
||||||
/* The SH8601 QSPI panel is write-only — display_hal_init_panel() above "succeeds"
|
|
||||||
* even on a bare board with no panel attached, so it cannot detect absence. The
|
|
||||||
* FT3168 touch controller is an I2C device with readback and is always present on
|
|
||||||
* the Touch-AMOLED board. If touch is absent, the panel "success" was a false-
|
|
||||||
* positive on a display-less DevKit: bail to headless so display_is_active() stays
|
|
||||||
* false and CSI upgrades to MGMT+DATA capture instead of starving at MGMT-only
|
|
||||||
* (RuView#1000). */
|
|
||||||
if (touch_ret != ESP_OK) {
|
|
||||||
ESP_LOGW(TAG, "No FT3168 touch readback — SH8601 probe was a false-positive on a "
|
|
||||||
"display-less board; running headless so CSI captures (#1000)");
|
|
||||||
return ESP_OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Initialize LVGL */
|
/* Initialize LVGL */
|
||||||
lv_init();
|
lv_init();
|
||||||
|
|
||||||
|
|||||||
@@ -144,54 +144,6 @@ static void wifi_init_sta(void)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if CONFIG_LED_GAMMA_VIZ
|
|
||||||
/* Viridis colormap (60 steps), generated from ruv-neural-viz::ColorMap::viridis()
|
|
||||||
* — the rUv-Neural brain-topology colormap, now no_std (ruvnet/ruv-neural#3 /
|
|
||||||
* RuView#1126). Used as the ON-phase colour of the 40 Hz gamma flicker below:
|
|
||||||
* dark-purple (still) -> teal -> green -> yellow (strong motion). */
|
|
||||||
static const uint8_t VIRIDIS_LUT[60][3] = {
|
|
||||||
{ 68, 1, 84},{ 67, 6, 88},{ 67, 12, 91},{ 66, 17, 95},{ 66, 23, 99},
|
|
||||||
{ 65, 28,103},{ 64, 34,106},{ 64, 39,110},{ 63, 45,114},{ 63, 50,118},
|
|
||||||
{ 62, 56,121},{ 61, 61,125},{ 61, 67,129},{ 60, 72,132},{ 59, 78,136},
|
|
||||||
{ 59, 83,139},{ 57, 87,139},{ 55, 92,139},{ 53, 96,139},{ 52,100,139},
|
|
||||||
{ 50,104,139},{ 48,109,139},{ 46,113,139},{ 44,117,140},{ 43,122,140},
|
|
||||||
{ 41,126,140},{ 39,130,140},{ 37,134,140},{ 36,139,140},{ 34,143,140},
|
|
||||||
{ 35,147,139},{ 39,151,136},{ 43,154,133},{ 47,158,130},{ 52,162,127},
|
|
||||||
{ 56,166,124},{ 60,170,121},{ 64,173,119},{ 68,177,116},{ 72,181,113},
|
|
||||||
{ 76,185,110},{ 81,189,107},{ 85,192,104},{ 89,196,102},{ 93,200, 99},
|
|
||||||
{102,203, 95},{113,205, 91},{124,207, 87},{134,209, 82},{145,211, 78},
|
|
||||||
{156,213, 74},{167,215, 70},{178,217, 66},{188,219, 62},{199,221, 58},
|
|
||||||
{210,223, 54},{221,225, 49},{231,227, 45},{242,229, 41},{253,231, 37},
|
|
||||||
};
|
|
||||||
static led_strip_handle_t s_viz_led;
|
|
||||||
|
|
||||||
/* motion_energy that saturates the colormap to yellow (CONFIG, milli-units). */
|
|
||||||
#define LED_MOTION_FULLSCALE ((float)CONFIG_LED_MOTION_FULLSCALE_MILLI / 1000.0f)
|
|
||||||
|
|
||||||
/* GENUS-style 40 Hz gamma flicker: full on/off square wave, 50% duty (toggled
|
|
||||||
* every 12.5 ms → 40 Hz). The ON colour is live CSI motion (edge motion_energy)
|
|
||||||
* mapped through the ruv-neural-viz viridis LUT — still=purple, moving=yellow.
|
|
||||||
* So the LED is a real 40 Hz gamma stimulus whose hue tracks sensed motion. */
|
|
||||||
static void led_gamma_40hz_cb(void *arg)
|
|
||||||
{
|
|
||||||
static bool on = false;
|
|
||||||
on = !on;
|
|
||||||
if (on) {
|
|
||||||
edge_vitals_pkt_t v;
|
|
||||||
float m = edge_get_vitals(&v) ? v.motion_energy : 0.0f;
|
|
||||||
float norm = m / LED_MOTION_FULLSCALE;
|
|
||||||
if (norm < 0.0f) norm = 0.0f;
|
|
||||||
if (norm > 1.0f) norm = 1.0f;
|
|
||||||
int idx = (int)(norm * 59.0f + 0.5f);
|
|
||||||
const uint8_t *c = VIRIDIS_LUT[idx];
|
|
||||||
led_strip_set_pixel(s_viz_led, 0, c[0], c[1], c[2]); /* R,G,B (driver maps to GRB) */
|
|
||||||
} else {
|
|
||||||
led_strip_set_pixel(s_viz_led, 0, 0, 0, 0); /* off phase */
|
|
||||||
}
|
|
||||||
led_strip_refresh(s_viz_led);
|
|
||||||
}
|
|
||||||
#endif /* CONFIG_LED_GAMMA_VIZ */
|
|
||||||
|
|
||||||
void app_main(void)
|
void app_main(void)
|
||||||
{
|
{
|
||||||
/* Initialize NVS */
|
/* Initialize NVS */
|
||||||
@@ -221,16 +173,15 @@ void app_main(void)
|
|||||||
ESP_LOGI(TAG, "%s CSI Node (ADR-018 / ADR-110) — v%s — Node ID: %d",
|
ESP_LOGI(TAG, "%s CSI Node (ADR-018 / ADR-110) — v%s — Node ID: %d",
|
||||||
target_name, app_desc->version, g_nvs_config.node_id);
|
target_name, app_desc->version, g_nvs_config.node_id);
|
||||||
|
|
||||||
/* Onboard WS2812. C6 wires the LED to GPIO 8; S3 to GPIO 38 (DevKitC-1 v1.0)
|
/* Turn off onboard WS2812 LED.
|
||||||
* or GPIO 48 (DevKitC-1 v1.1 / N16R8 — see #962). On S3 we drive 48 (the
|
* S3 dev boards put the LED on GPIO 38; C6 dev boards on GPIO 8.
|
||||||
* common module). On C6, GPIO 38/48 don't exist (only 0-30) — gate by target.
|
* On C6, GPIO 38 doesn't exist (only 0-30) — gate the init by target. */
|
||||||
* Behaviour is set by CONFIG_LED_GAMMA_VIZ (ADR-183): on = 40 Hz gamma flicker
|
|
||||||
* coloured by CSI motion; off = clear the LED at boot. */
|
|
||||||
#if defined(CONFIG_IDF_TARGET_ESP32C6)
|
#if defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||||
const int led_gpio = 8;
|
const int led_gpio = 8;
|
||||||
#else
|
#else
|
||||||
const int led_gpio = 48;
|
const int led_gpio = 38;
|
||||||
#endif
|
#endif
|
||||||
|
led_strip_handle_t led_strip;
|
||||||
led_strip_config_t strip_config = {
|
led_strip_config_t strip_config = {
|
||||||
.strip_gpio_num = led_gpio,
|
.strip_gpio_num = led_gpio,
|
||||||
.max_leds = 1,
|
.max_leds = 1,
|
||||||
@@ -242,26 +193,9 @@ void app_main(void)
|
|||||||
.resolution_hz = 10 * 1000 * 1000, // 10MHz
|
.resolution_hz = 10 * 1000 * 1000, // 10MHz
|
||||||
.flags.with_dma = false,
|
.flags.with_dma = false,
|
||||||
};
|
};
|
||||||
#if CONFIG_LED_GAMMA_VIZ
|
|
||||||
if (led_strip_new_rmt_device(&strip_config, &rmt_config, &s_viz_led) == ESP_OK) {
|
|
||||||
const esp_timer_create_args_t viz_args = {
|
|
||||||
.callback = &led_gamma_40hz_cb,
|
|
||||||
.name = "led_gamma_40hz",
|
|
||||||
};
|
|
||||||
esp_timer_handle_t viz_timer;
|
|
||||||
if (esp_timer_create(&viz_args, &viz_timer) == ESP_OK) {
|
|
||||||
esp_timer_start_periodic(viz_timer, 12500); // 12.5 ms toggle → 40 Hz square wave
|
|
||||||
ESP_LOGI(TAG, "Onboard WS2812: 40 Hz gamma flicker (GENUS), colour=CSI motion via ruv-neural-viz, GPIO %d", led_gpio);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
/* Viz disabled — clear the onboard LED at boot and release the RMT channel. */
|
|
||||||
led_strip_handle_t led_strip;
|
|
||||||
if (led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip) == ESP_OK) {
|
if (led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip) == ESP_OK) {
|
||||||
led_strip_clear(led_strip);
|
led_strip_clear(led_strip);
|
||||||
led_strip_del(led_strip);
|
|
||||||
}
|
}
|
||||||
#endif /* CONFIG_LED_GAMMA_VIZ */
|
|
||||||
|
|
||||||
/* ADR-110 P4: 802.15.4 mesh time-sync (C6 only).
|
/* ADR-110 P4: 802.15.4 mesh time-sync (C6 only).
|
||||||
* Initialized BEFORE WiFi so it's available even when WiFi STA can't
|
* Initialized BEFORE WiFi so it's available even when WiFi STA can't
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file mmwave_detect.h
|
|
||||||
* @brief Pure (host-testable) mmWave frame-validation predicates for probe-time
|
|
||||||
* sensor detection. No ESP-IDF deps — safe to #include in a host unit test.
|
|
||||||
*
|
|
||||||
* Detection must validate a *full* frame, never a bare header byte/pattern: a
|
|
||||||
* floating UART with no sensor reads line noise that can contain header-looking
|
|
||||||
* bytes, which the old loose checks mistook for a real sensor (#1107 MR60,
|
|
||||||
* #1135 LD2410). These predicates are the validate-before-trust gate.
|
|
||||||
*/
|
|
||||||
#ifndef MMWAVE_DETECT_H
|
|
||||||
#define MMWAVE_DETECT_H
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
#include <stdbool.h>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True iff buf[i..] begins a *validated* LD2410 report frame within [0,len):
|
|
||||||
* F4 F3 F2 F1 | len(LE,2) | data[len] | F8 F7 F6 F5
|
|
||||||
* Requires the head magic, a sane intra-frame length, AND the matching tail at
|
|
||||||
* head+6+len. Pure noise that merely contains 0xF4F3F2F1 fails the tail check.
|
|
||||||
*/
|
|
||||||
static inline bool mmwave_ld2410_valid_at(const uint8_t *buf, int i, int len)
|
|
||||||
{
|
|
||||||
if (i < 0 || i + 5 >= len) return false;
|
|
||||||
if (!(buf[i] == 0xF4 && buf[i+1] == 0xF3 && buf[i+2] == 0xF2 && buf[i+3] == 0xF1))
|
|
||||||
return false;
|
|
||||||
uint16_t flen = (uint16_t)buf[i+4] | ((uint16_t)buf[i+5] << 8);
|
|
||||||
/* Real LD2410 report frames are small (basic=13, engineering=35). */
|
|
||||||
if (flen < 1 || flen > 64) return false;
|
|
||||||
int tail = i + 6 + (int)flen;
|
|
||||||
if (tail + 3 >= len) return false;
|
|
||||||
return buf[tail] == 0xF8 && buf[tail+1] == 0xF7
|
|
||||||
&& buf[tail+2] == 0xF6 && buf[tail+3] == 0xF5;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif /* MMWAVE_DETECT_H */
|
|
||||||
@@ -26,7 +26,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
#include "mmwave_sensor.h"
|
#include "mmwave_sensor.h"
|
||||||
#include "mmwave_detect.h"
|
|
||||||
|
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <math.h>
|
#include <math.h>
|
||||||
@@ -388,26 +387,14 @@ static mmwave_type_t probe_at_baud(uint32_t baud)
|
|||||||
if (len <= 0) continue;
|
if (len <= 0) continue;
|
||||||
|
|
||||||
for (int i = 0; i < len; i++) {
|
for (int i = 0; i < len; i++) {
|
||||||
/* MR60BHA2: require a *validated* 8-byte header — SOF (0x01) + a valid
|
/* MR60BHA2: SOF = 0x01, followed by valid-looking frame_id bytes */
|
||||||
* header checksum (over bytes 0..6) + a known frame type (0x0A__ or
|
if (buf[i] == MR60_SOF && baud == MMWAVE_MR60_BAUD) {
|
||||||
* 0x0F09) — NOT a bare 0x01 byte. A floating UART1 with no sensor reads
|
mr60_sof_seen++;
|
||||||
* noise full of 0x01s, which the old `buf[i] == MR60_SOF` check mistook
|
|
||||||
* for a real sensor (false "Detected MR60BHA2", #1107). */
|
|
||||||
if (buf[i] == MR60_SOF && baud == MMWAVE_MR60_BAUD && i + 7 < len) {
|
|
||||||
const uint8_t *h = &buf[i];
|
|
||||||
if (mr60_calc_checksum(h, 7) == h[7]) {
|
|
||||||
uint16_t type = ((uint16_t)h[5] << 8) | h[6];
|
|
||||||
if ((type >> 8) == 0x0A || type == 0x0F09) {
|
|
||||||
mr60_sof_seen++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
/* LD2410: require a *full validated* report frame, not just the
|
/* LD2410: 4-byte header 0xF4F3F2F1 */
|
||||||
* 4-byte head. A floating UART1 at 256000 baud can emit the head
|
if (i + 3 < len && buf[i] == 0xF4 && buf[i+1] == 0xF3
|
||||||
* pattern 0xF4F3F2F1 from line noise (#1135 bug #2). The shared
|
&& buf[i+2] == 0xF2 && buf[i+3] == 0xF1
|
||||||
* predicate (host-unit-tested in mmwave_detect.h) demands a sane
|
&& baud == MMWAVE_LD2410_BAUD) {
|
||||||
* intra-frame length AND the matching tail 0xF8F7F6F5. */
|
|
||||||
if (baud == MMWAVE_LD2410_BAUD && mmwave_ld2410_valid_at(buf, i, len)) {
|
|
||||||
ld2410_header_seen++;
|
ld2410_header_seen++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -416,8 +403,9 @@ static mmwave_type_t probe_at_baud(uint32_t baud)
|
|||||||
if (ld2410_header_seen >= 2) return MMWAVE_TYPE_LD2410;
|
if (ld2410_header_seen >= 2) return MMWAVE_TYPE_LD2410;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* No weak single-hit fallback: line noise can produce a stray match, so a real
|
if (mr60_sof_seen > 0) return MMWAVE_TYPE_MR60BHA2;
|
||||||
* sensor must clear the ≥3 (MR60) / ≥2 (LD2410) validated-frame thresholds. */
|
if (ld2410_header_seen > 0) return MMWAVE_TYPE_LD2410;
|
||||||
|
|
||||||
return MMWAVE_TYPE_NONE;
|
return MMWAVE_TYPE_NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,9 +188,7 @@ size_t rv_mesh_encode_calibration_start(uint8_t sender_role,
|
|||||||
esp_err_t rv_mesh_send(const uint8_t *frame, size_t len)
|
esp_err_t rv_mesh_send(const uint8_t *frame, size_t len)
|
||||||
{
|
{
|
||||||
if (frame == NULL || len == 0) return ESP_ERR_INVALID_ARG;
|
if (frame == NULL || len == 0) return ESP_ERR_INVALID_ARG;
|
||||||
/* Mesh control packets (HEALTH, anomaly) are low-rate and tiny — send them
|
int sent = stream_sender_send(frame, len);
|
||||||
* on the priority path so the CSI ENOMEM backoff can't starve them (#1183). */
|
|
||||||
int sent = stream_sender_send_priority(frame, len);
|
|
||||||
if (sent < 0) {
|
if (sent < 0) {
|
||||||
ESP_LOGW(TAG, "rv_mesh_send: stream_sender failed (len=%u)",
|
ESP_LOGW(TAG, "rv_mesh_send: stream_sender failed (len=%u)",
|
||||||
(unsigned)len);
|
(unsigned)len);
|
||||||
|
|||||||
@@ -26,16 +26,9 @@ static struct sockaddr_in s_dest_addr;
|
|||||||
* rapid-fire CSI callbacks can exhaust the pbuf pool and crash the device.
|
* rapid-fire CSI callbacks can exhaust the pbuf pool and crash the device.
|
||||||
*/
|
*/
|
||||||
static int64_t s_backoff_until_us = 0; /* esp_timer timestamp to resume */
|
static int64_t s_backoff_until_us = 0; /* esp_timer timestamp to resume */
|
||||||
#define ENOMEM_COOLDOWN_MS 100 /* base backoff; doubles per streak */
|
#define ENOMEM_COOLDOWN_MS 100 /* suppress sends for 100 ms */
|
||||||
#define ENOMEM_COOLDOWN_MAX_MS 2000 /* cap on the exponential backoff */
|
|
||||||
#define ENOMEM_LOG_INTERVAL 50 /* log every Nth suppressed send */
|
#define ENOMEM_LOG_INTERVAL 50 /* log every Nth suppressed send */
|
||||||
static uint32_t s_enomem_suppressed = 0;
|
static uint32_t s_enomem_suppressed = 0;
|
||||||
/* Consecutive ENOMEM episodes without an intervening successful send. A fixed
|
|
||||||
* 100 ms backoff is too short to drain sustained lwIP/WiFi buffer pressure
|
|
||||||
* (#1135 bug #1: tier-2 + concurrent TX keeps the node stuck), so the backoff
|
|
||||||
* grows 100→200→400→…→2000 ms per streak and resets on the first send that
|
|
||||||
* succeeds. */
|
|
||||||
static uint32_t s_enomem_streak = 0;
|
|
||||||
|
|
||||||
static int sender_init_internal(const char *ip, uint16_t port)
|
static int sender_init_internal(const char *ip, uint16_t port)
|
||||||
{
|
{
|
||||||
@@ -100,52 +93,16 @@ int stream_sender_send(const uint8_t *data, size_t len)
|
|||||||
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
|
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
|
||||||
if (sent < 0) {
|
if (sent < 0) {
|
||||||
if (errno == ENOMEM) {
|
if (errno == ENOMEM) {
|
||||||
/* Exponential backoff: double the cooldown each consecutive ENOMEM
|
/* Start backoff to let lwIP reclaim buffers */
|
||||||
* (capped) so sustained buffer pressure actually drains instead of
|
s_backoff_until_us = esp_timer_get_time() +
|
||||||
* the node re-failing every 100 ms forever (#1135 bug #1). */
|
(int64_t)ENOMEM_COOLDOWN_MS * 1000;
|
||||||
uint32_t shift = s_enomem_streak < 5 ? s_enomem_streak : 5;
|
ESP_LOGW(TAG, "sendto ENOMEM — backing off for %d ms", ENOMEM_COOLDOWN_MS);
|
||||||
uint32_t cooldown = ENOMEM_COOLDOWN_MS << shift;
|
|
||||||
if (cooldown > ENOMEM_COOLDOWN_MAX_MS) cooldown = ENOMEM_COOLDOWN_MAX_MS;
|
|
||||||
s_enomem_streak++;
|
|
||||||
s_backoff_until_us = esp_timer_get_time() + (int64_t)cooldown * 1000;
|
|
||||||
ESP_LOGW(TAG, "sendto ENOMEM — backing off for %lu ms (streak %lu)",
|
|
||||||
(unsigned long)cooldown, (unsigned long)s_enomem_streak);
|
|
||||||
} else {
|
} else {
|
||||||
ESP_LOGW(TAG, "sendto failed: errno %d", errno);
|
ESP_LOGW(TAG, "sendto failed: errno %d", errno);
|
||||||
}
|
}
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A send got through — buffer pressure cleared; reset the backoff streak. */
|
|
||||||
s_enomem_streak = 0;
|
|
||||||
return sent;
|
|
||||||
}
|
|
||||||
|
|
||||||
int stream_sender_send_priority(const uint8_t *data, size_t len)
|
|
||||||
{
|
|
||||||
if (s_sock < 0) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Priority path (#1183): low-rate control packets (feature_state, HEALTH,
|
|
||||||
* mesh sync) bypass the global ENOMEM backoff gate so the high-rate CSI
|
|
||||||
* stream cannot starve them. These are ≤48 B at ≤1 Hz — negligible pbuf
|
|
||||||
* pressure, so they won't re-trigger the crash cascade that the backoff
|
|
||||||
* (driven by the 50 Hz CSI flood) exists to prevent.
|
|
||||||
*
|
|
||||||
* Crucially, an ENOMEM here is reported quietly and does NOT extend the
|
|
||||||
* global streak/backoff: a tiny control packet failing is a symptom of
|
|
||||||
* the bulk-stream pressure, not a cause, so it must not feed the cooldown
|
|
||||||
* that suppresses the next CSI frame. Likewise a success does not reset
|
|
||||||
* the streak — the bulk path owns that signal. */
|
|
||||||
int sent = sendto(s_sock, data, len, 0,
|
|
||||||
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
|
|
||||||
if (sent < 0) {
|
|
||||||
if (errno != ENOMEM) {
|
|
||||||
ESP_LOGW(TAG, "priority sendto failed: errno %d", errno);
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return sent;
|
return sent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,20 +36,6 @@ int stream_sender_init_with(const char *ip, uint16_t port);
|
|||||||
*/
|
*/
|
||||||
int stream_sender_send(const uint8_t *data, size_t len);
|
int stream_sender_send(const uint8_t *data, size_t len);
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a low-rate control packet, bypassing the ENOMEM backoff gate (#1183).
|
|
||||||
*
|
|
||||||
* Intended for ≤48 B, ≤1 Hz control traffic (feature_state, HEALTH, mesh
|
|
||||||
* sync) that must not be starved by the global backoff the high-rate CSI
|
|
||||||
* stream triggers. An ENOMEM on this path is reported quietly and does NOT
|
|
||||||
* extend or reset the global backoff streak.
|
|
||||||
*
|
|
||||||
* @param data Frame data buffer.
|
|
||||||
* @param len Length of data to send.
|
|
||||||
* @return Number of bytes sent, or -1 on error.
|
|
||||||
*/
|
|
||||||
int stream_sender_send_priority(const uint8_t *data, size_t len);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close the UDP sender socket.
|
* Close the UDP sender socket.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# DevKitC-1 (display-less) production overlay.
|
|
||||||
#
|
|
||||||
# The stock ESP32-S3-DevKitC-1 has no AMOLED panel, but the ADR-045 runtime
|
|
||||||
# probe false-positives on it: with no TCA9554 and floating QSPI pins, the
|
|
||||||
# SH8601 init sequence reports success, display_is_active() returns true, and
|
|
||||||
# main.c skips the RuView#893 MGMT+DATA promiscuous upgrade — CSI yield
|
|
||||||
# collapses to 0 pps (the exact symptom #893 fixed). Compiling display support
|
|
||||||
# out makes has_display constant-false so the upgrade always applies.
|
|
||||||
#
|
|
||||||
# Build (from repo root, per README "Docker — the only reliable method"):
|
|
||||||
# MSYS_NO_PATHCONV=1 docker run --rm \
|
|
||||||
# -v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
|
||||||
# espressif/idf:v5.4 bash -c \
|
|
||||||
# "rm -rf build sdkconfig && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' set-target esp32s3 && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' build"
|
|
||||||
|
|
||||||
# CONFIG_DISPLAY_ENABLE is not set
|
|
||||||
@@ -44,9 +44,9 @@ FUZZ_DURATION ?= 30
|
|||||||
FUZZ_JOBS ?= 1
|
FUZZ_JOBS ?= 1
|
||||||
|
|
||||||
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \
|
.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_vitals run_vitals host_tests
|
||||||
|
|
||||||
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect
|
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals
|
||||||
|
|
||||||
# --- ADR-110 encoding unit tests ---
|
# --- ADR-110 encoding unit tests ---
|
||||||
# Host-side, no libFuzzer needed — plain C99 deterministic table tests
|
# Host-side, no libFuzzer needed — plain C99 deterministic table tests
|
||||||
@@ -69,19 +69,8 @@ test_vitals: test_vitals_count_presence.c $(MAIN_DIR)/edge_processing.h
|
|||||||
run_vitals: test_vitals
|
run_vitals: test_vitals
|
||||||
./test_vitals
|
./test_vitals
|
||||||
|
|
||||||
# --- mmWave LD2410 detection predicate (#1135 bug #2) ---
|
host_tests: run_adr110 run_vitals
|
||||||
# Host-side, no libFuzzer. Proves a floating-UART head pattern (0xF4F3F2F1)
|
@echo "Host tests passed (ADR-110 + vitals #998/#996)"
|
||||||
# without a valid frame length+tail is REJECTED, so a phantom LD2410 is never
|
|
||||||
# detected on a node with no sensor wired. Tests the real predicate the
|
|
||||||
# firmware uses (../main/mmwave_detect.h) — test and firmware can't disagree.
|
|
||||||
test_mmwave_detect: test_mmwave_detect.c $(MAIN_DIR)/mmwave_detect.h
|
|
||||||
cc -std=c99 -Wall -Wextra -I$(MAIN_DIR) -o $@ $<
|
|
||||||
|
|
||||||
run_mmwave_detect: test_mmwave_detect
|
|
||||||
./test_mmwave_detect
|
|
||||||
|
|
||||||
host_tests: run_adr110 run_vitals run_mmwave_detect
|
|
||||||
@echo "Host tests passed (ADR-110 + vitals #998/#996 + mmwave detect #1135)"
|
|
||||||
|
|
||||||
# --- Serialize fuzzer ---
|
# --- Serialize fuzzer ---
|
||||||
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
|
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file test_mmwave_detect.c
|
|
||||||
* @brief Host-side unit tests for the LD2410 frame-validation predicate (#1135).
|
|
||||||
*
|
|
||||||
* Proves the phantom-detection fix: a floating UART can emit the 4-byte head
|
|
||||||
* 0xF4F3F2F1, but the predicate rejects it unless a sane length + matching tail
|
|
||||||
* 0xF8F7F6F5 are also present. Tests the REAL predicate from mmwave_detect.h
|
|
||||||
* (the same code the firmware's probe_at_baud calls).
|
|
||||||
*
|
|
||||||
* cc -std=c99 -Wall -I../main -o test_mmwave_detect test_mmwave_detect.c && ./test_mmwave_detect
|
|
||||||
*
|
|
||||||
* Exits 0 on all-pass; prints the failing case otherwise.
|
|
||||||
*/
|
|
||||||
#include <stdint.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include "mmwave_detect.h"
|
|
||||||
|
|
||||||
static int failures = 0;
|
|
||||||
#define CHECK(cond, msg) do { \
|
|
||||||
if (!(cond)) { printf("FAIL: %s\n", msg); failures++; } \
|
|
||||||
else { printf("ok: %s\n", msg); } \
|
|
||||||
} while (0)
|
|
||||||
|
|
||||||
/* Build a valid LD2410 report frame: F4F3F2F1 | len(LE) | data[len] | F8F7F6F5 */
|
|
||||||
static int make_frame(uint8_t *out, uint16_t dlen)
|
|
||||||
{
|
|
||||||
int n = 0;
|
|
||||||
out[n++] = 0xF4; out[n++] = 0xF3; out[n++] = 0xF2; out[n++] = 0xF1;
|
|
||||||
out[n++] = (uint8_t)(dlen & 0xFF); out[n++] = (uint8_t)(dlen >> 8);
|
|
||||||
for (uint16_t k = 0; k < dlen; k++) out[n++] = (uint8_t)(0xAA ^ k);
|
|
||||||
out[n++] = 0xF8; out[n++] = 0xF7; out[n++] = 0xF6; out[n++] = 0xF5;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(void)
|
|
||||||
{
|
|
||||||
uint8_t buf[256];
|
|
||||||
|
|
||||||
/* 1. A real basic-report frame (data len 13) validates. */
|
|
||||||
int n = make_frame(buf, 13);
|
|
||||||
CHECK(mmwave_ld2410_valid_at(buf, 0, n), "valid basic frame (len=13) accepted");
|
|
||||||
|
|
||||||
/* 2. A real engineering-report frame (data len 35) validates. */
|
|
||||||
n = make_frame(buf, 35);
|
|
||||||
CHECK(mmwave_ld2410_valid_at(buf, 0, n), "valid engineering frame (len=35) accepted");
|
|
||||||
|
|
||||||
/* 3. Head magic present but NO valid tail — the #1135 phantom case. */
|
|
||||||
memset(buf, 0x00, sizeof(buf));
|
|
||||||
buf[0]=0xF4; buf[1]=0xF3; buf[2]=0xF2; buf[3]=0xF1; buf[4]=13; buf[5]=0;
|
|
||||||
/* data present but tail is zeros, not F8F7F6F5 */
|
|
||||||
CHECK(!mmwave_ld2410_valid_at(buf, 0, 64), "head magic without valid tail REJECTED (#1135)");
|
|
||||||
|
|
||||||
/* 4. Head magic with insane length is rejected. */
|
|
||||||
memset(buf, 0xFF, sizeof(buf));
|
|
||||||
buf[0]=0xF4; buf[1]=0xF3; buf[2]=0xF2; buf[3]=0xF1; buf[4]=0xFF; buf[5]=0xFF; /* len=65535 */
|
|
||||||
CHECK(!mmwave_ld2410_valid_at(buf, 0, 200), "head magic with oversized length REJECTED");
|
|
||||||
|
|
||||||
/* 5. Pure noise (no head) is rejected. */
|
|
||||||
for (int k = 0; k < 64; k++) buf[k] = (uint8_t)(0x5A + k);
|
|
||||||
CHECK(!mmwave_ld2410_valid_at(buf, 0, 64), "non-header noise REJECTED");
|
|
||||||
|
|
||||||
/* 6. Truncated frame (tail would run past the buffer) is rejected. */
|
|
||||||
n = make_frame(buf, 13);
|
|
||||||
CHECK(!mmwave_ld2410_valid_at(buf, 0, n - 2), "truncated frame (tail past buffer) REJECTED");
|
|
||||||
|
|
||||||
/* 7. Valid frame at a non-zero offset still validates. */
|
|
||||||
memset(buf, 0x00, sizeof(buf));
|
|
||||||
n = make_frame(buf + 7, 13);
|
|
||||||
CHECK(mmwave_ld2410_valid_at(buf, 7, 7 + n), "valid frame at offset 7 accepted");
|
|
||||||
|
|
||||||
/* 8. Repeated head bytes without a frame (worst-case noise) rejected. */
|
|
||||||
for (int k = 0; k + 3 < 64; k += 4) {
|
|
||||||
buf[k]=0xF4; buf[k+1]=0xF3; buf[k+2]=0xF2; buf[k+3]=0xF1;
|
|
||||||
}
|
|
||||||
CHECK(!mmwave_ld2410_valid_at(buf, 0, 64), "repeated bare head bytes REJECTED");
|
|
||||||
|
|
||||||
printf("\n%s (%d failures)\n", failures ? "FAILED" : "ALL PASS", failures);
|
|
||||||
return failures ? 1 : 0;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(npx ruview*)",
|
|
||||||
"mcp__ruview__*"
|
|
||||||
],
|
|
||||||
"deny": [
|
|
||||||
"Read(./.env)",
|
|
||||||
"Read(./.env.*)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"mcpServers": {
|
|
||||||
"ruview": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["-y", "@ruvnet/ruview", "mcp", "start"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
---
|
|
||||||
name: calibrate-room
|
|
||||||
description: Run the ADR-151 per-room calibration pipeline — baseline → enroll → extract → train → a bank of small specialists (presence/posture/breathing/heartbeat/restlessness/anomaly).
|
|
||||||
---
|
|
||||||
|
|
||||||
# calibrate-room
|
|
||||||
|
|
||||||
Turn a provisioned node + sensing-server into a working room model. Pure-Rust,
|
|
||||||
edge-deployable (ADR-151). Use the `ruview_calibrate` tool (installed
|
|
||||||
`wifi-densepose` binary, else `cargo run -p wifi-densepose-cli`).
|
|
||||||
|
|
||||||
## Sequence
|
|
||||||
|
|
||||||
1. **baseline** — capture the empty room (Welford amplitude + von Mises phase). Leave
|
|
||||||
the room empty.
|
|
||||||
`ruview_calibrate {step: "baseline"}`
|
|
||||||
2. **enroll** — record the occupant(s) doing the target activities.
|
|
||||||
`ruview_calibrate {step: "enroll"}`
|
|
||||||
3. **train-room** — train the bank of small specialists from baseline + enrollment.
|
|
||||||
`ruview_calibrate {step: "train-room"}`
|
|
||||||
4. **room-watch** — live presence/posture/breathing from the trained room.
|
|
||||||
`ruview_calibrate {step: "room-watch"}` (or the `room-watch` skill)
|
|
||||||
|
|
||||||
## Honesty
|
|
||||||
|
|
||||||
The specialists are calibrated to *this* room; cross-room transfer is a separate
|
|
||||||
problem (LoRA recalibration, ADR-079 P9). Report which room a number came from, and
|
|
||||||
tag presence/vitals accuracy MEASURED only with a held-out check — run
|
|
||||||
`ruview_claim_check` on the writeup.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
---
|
|
||||||
name: onboard
|
|
||||||
description: Zero-to-sensing path picker for RuView (WiFi-DensePose) — pick docker-demo, repo-build, or live-esp32 and run the next concrete step.
|
|
||||||
---
|
|
||||||
|
|
||||||
# onboard
|
|
||||||
|
|
||||||
Get a newcomer from nothing to a working RuView setup. **First fact to set:** WiFi
|
|
||||||
sensing infers *coarse* pose/presence/breathing from Channel State Information — it
|
|
||||||
is **not a camera**, and any accuracy number must be MEASURED against a baseline
|
|
||||||
(use the `verify` skill / `ruview_claim_check` tool). Never present WiFi output as
|
|
||||||
camera-grade.
|
|
||||||
|
|
||||||
## Pick a path
|
|
||||||
|
|
||||||
Run `ruview_onboard {path}` or decide from:
|
|
||||||
|
|
||||||
1. **docker-demo** — fastest, no hardware. Replays sample CSI into the dashboard.
|
|
||||||
`docker run -p 8000:8000 ruvnet/wifi-densepose` → open `http://localhost:8000`.
|
|
||||||
Use to see what it looks like.
|
|
||||||
2. **repo-build** — for developers. `cd v2 && cargo test --workspace --no-default-features`
|
|
||||||
(1,031+ tests pass), then `cargo run -p wifi-densepose-cli -- --help`.
|
|
||||||
3. **live-esp32** — a real install. Flash a node (`provision-node` skill), point it at
|
|
||||||
the sensing-server, then `calibrate-room`. This is the only path that senses a real room.
|
|
||||||
|
|
||||||
## Then
|
|
||||||
|
|
||||||
- Live sensing → go to **provision-node**, then **calibrate-room**.
|
|
||||||
- Evaluating a model/claim → go to **verify** and run `ruview_claim_check` on any
|
|
||||||
report before you quote a number.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
---
|
|
||||||
|
|
||||||
# provision-node
|
|
||||||
|
|
||||||
Bring an ESP32 sensing node online.
|
|
||||||
|
|
||||||
## 1. Pick a firmware variant
|
|
||||||
|
|
||||||
- **s3-8mb** (display build) — ESP32-S3 N16R8 / 16MB; AMOLED optional. The display-detect
|
|
||||||
fix (#1000) means a *bare* board still captures CSI (MGMT+DATA).
|
|
||||||
- **s3-4mb** (no-display) — ESP32-S3 4MB; dual-OTA, display disabled.
|
|
||||||
- **c6** — ESP32-C6 + Seeed MR60BHA2 (60 GHz mmWave + WiFi CSI). The mmwave probe
|
|
||||||
requires a validated MR60 header (#1107) so an empty UART never false-detects.
|
|
||||||
|
|
||||||
Prebuilt binaries: GitHub release `v0.8.1-esp32` (hardware-validated on S3 QFN56 rev v0.2).
|
|
||||||
|
|
||||||
## 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:
|
|
||||||
|
|
||||||
```
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
(`ruview_node_flash` returns the exact pinned command rather than running an
|
|
||||||
unattended flash.)
|
|
||||||
|
|
||||||
## 3. Provision
|
|
||||||
|
|
||||||
```
|
|
||||||
python firmware/esp32-csi-node/provision.py --port <PORT> \
|
|
||||||
--ssid "<SSID>" --password "<secret>" --target-ip <server-ip> --target-port 5005
|
|
||||||
# optional ADR-060 overrides:
|
|
||||||
python firmware/esp32-csi-node/provision.py --port <PORT> --channel 6 --filter-mac AA:BB:CC:DD:EE:FF
|
|
||||||
```
|
|
||||||
|
|
||||||
Never echo or commit the WiFi password.
|
|
||||||
|
|
||||||
## 4. Confirm CSI is flowing
|
|
||||||
|
|
||||||
`ruview_node_monitor {port}` — PASS criteria: serial shows `CSI cb #...` callbacks and
|
|
||||||
(on a bare board) `CSI filter upgraded to MGMT+DATA`. No callbacks → the node isn't
|
|
||||||
capturing; do not proceed to calibration.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
name: train-pose
|
|
||||||
description: Train/evaluate WiFi pose models honestly — camera-supervised (MediaPipe + CSI) and camera-free (WiFlow), always checked against the mean-pose baseline before any PCK is quoted.
|
|
||||||
---
|
|
||||||
|
|
||||||
# train-pose
|
|
||||||
|
|
||||||
Build a CSI→pose model without overstating it. The project has a **retracted 92.9%/100%**
|
|
||||||
history — the discipline below exists so it never recurs.
|
|
||||||
|
|
||||||
## The non-negotiable: mean-pose baseline first
|
|
||||||
|
|
||||||
A pose model that always predicts the dataset's *mean pose* already scores ~50% PCK.
|
|
||||||
**Quote PCK only as a delta over that baseline**, on a held-out split with no subject
|
|
||||||
or temporal leakage. Example honest result (ADR-181):
|
|
||||||
|
|
||||||
> Held-out PCK@20 **59.5%** vs a 50% mean-pose baseline = **+9.4 pp real signal** — MEASURED.
|
|
||||||
|
|
||||||
## Paths
|
|
||||||
|
|
||||||
- **camera-supervised** (ADR-079) — MediaPipe Pose labels the camera frame; paired CSI
|
|
||||||
trains the net. Train/infer in one camera frame so the skeleton aligns.
|
|
||||||
- **camera-free** (WiFlow, ADR-152) — no camera at inference; geometry-conditioned.
|
|
||||||
- **in-browser** (ADR-181) — WebGPU/WASM trainer; the active backend is shown as a badge
|
|
||||||
(honest about what's executing).
|
|
||||||
|
|
||||||
## Before you publish a number
|
|
||||||
|
|
||||||
1. Run the mean-pose baseline on the same split.
|
|
||||||
2. Report `(model − baseline)` in pp, with the split definition (chronological /
|
|
||||||
blocked-gap / grouped-bucket; no leakage).
|
|
||||||
3. `ruview_claim_check` the writeup — it flags any untagged or 100%/perfect claim.
|
|
||||||
4. If it's a benchmark vs SOTA, tag MEASURED-EQUIVALENT only with the reproducer.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
---
|
|
||||||
name: verify
|
|
||||||
description: Prove a RuView result is real — run the deterministic SHA-256 proof and the witness bundle (ADR-028), and lint any claim for MEASURED-vs-CLAIMED honesty.
|
|
||||||
---
|
|
||||||
|
|
||||||
# verify
|
|
||||||
|
|
||||||
The "prove everything" skill. Nothing ships as validated without this.
|
|
||||||
|
|
||||||
## Deterministic proof (Trust Kill Switch)
|
|
||||||
|
|
||||||
`ruview_verify` runs `archive/v1/data/proof/verify.py`: it feeds a reference signal
|
|
||||||
through the production pipeline and hashes the output against
|
|
||||||
`expected_features.sha256`. Must print **VERDICT: PASS**. If numpy/scipy changed the
|
|
||||||
hash, regenerate with `verify.py --generate-hash` then re-verify.
|
|
||||||
|
|
||||||
## Witness bundle (ADR-028)
|
|
||||||
|
|
||||||
For a release-grade attestation:
|
|
||||||
|
|
||||||
```
|
|
||||||
bash scripts/generate-witness-bundle.sh
|
|
||||||
cd dist/witness-bundle-ADR028-*/ && bash VERIFY.sh # must be 7/7 PASS
|
|
||||||
```
|
|
||||||
|
|
||||||
Contains the Rust test log, the proof + expected hash, firmware SHA-256 manifest, and
|
|
||||||
crate versions — a recipient can re-verify with one command.
|
|
||||||
|
|
||||||
## Claim honesty
|
|
||||||
|
|
||||||
Run `ruview_claim_check {text}` on any report, README section, PR body, or model card
|
|
||||||
before quoting accuracy. It flags:
|
|
||||||
- untagged accuracy numbers (must be MEASURED / CLAIMED / SYNTHETIC),
|
|
||||||
- MEASURED claims with no reproducer cited,
|
|
||||||
- the retracted "100%/perfect accuracy" framing.
|
|
||||||
|
|
||||||
## Firmware-specific
|
|
||||||
|
|
||||||
A firmware fix is **not** "hardware-validated" without a captured boot log on real
|
|
||||||
silicon (e.g. the `v0.8.1-esp32` rev-v0.2 validation: `running headless so CSI
|
|
||||||
captures (#1000)` + `CSI filter upgraded to MGMT+DATA` + a no-false-detect mmwave
|
|
||||||
probe). Do not merge or release on a build-passes signal alone.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"schema": 1,
|
|
||||||
"generator": "metaharness 0.1.15 + ADR-182 hardening",
|
|
||||||
"template": "vertical:ruview",
|
|
||||||
"name": "@ruvnet/ruview",
|
|
||||||
"vars": {
|
|
||||||
"name": "@ruvnet/ruview",
|
|
||||||
"description": "RuView WiFi-sensing operator agent harness",
|
|
||||||
"host": "claude-code"
|
|
||||||
},
|
|
||||||
"hosts": [
|
|
||||||
"claude-code"
|
|
||||||
],
|
|
||||||
"files": {
|
|
||||||
".claude/settings.json": "b0ea971383716f18b89db73010b8f0ea0f1b16bdec4cd1068245772ba1c27bdd",
|
|
||||||
".claude/skills/calibrate-room/SKILL.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
|
|
||||||
".claude/skills/onboard/SKILL.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
|
|
||||||
".claude/skills/provision-node/SKILL.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
|
|
||||||
".claude/skills/train-pose/SKILL.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
|
|
||||||
".claude/skills/verify/SKILL.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
|
|
||||||
"CLAUDE.md": "1d7af0c310dd8093b4ae6c9c94a1c0cc9ff02ac9c8d5b45caba5363c3af99475",
|
|
||||||
"LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06",
|
|
||||||
"README.md": "ac35157d66243a5f9eba262bdf2d593e978d935b3dde6e455b7acf650768eac6",
|
|
||||||
"bin/cli.js": "85d8394375edb1e967418451452e68bdbe26e69fc6877ed4936894f6101e1a12",
|
|
||||||
"package.json": "4509b68bb4211217f1e9f3f95f3134b326ee23a2322aef8d19b99a4b1d415b08",
|
|
||||||
"skills/calibrate-room.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
|
|
||||||
"skills/onboard.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
|
|
||||||
"skills/provision-node.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
|
|
||||||
"skills/train-pose.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
|
|
||||||
"skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
|
|
||||||
"src/guardrails.js": "66407b00d31c4f7939b75ee3e29598855c36a4154ccf1436655a4e52b0d7c034",
|
|
||||||
"src/mcp-server.js": "ad0f21be65a37237b9c2aad69e6e75166e5f101d902cb986377043545a7a80fb",
|
|
||||||
"src/tools.js": "1d72377ae53ad2b0c6dc03eb66f584422d8a60e442cb0d4f08355590f3edf031"
|
|
||||||
},
|
|
||||||
"meta": {
|
|
||||||
"surface": "cli+mcp",
|
|
||||||
"adr": "ADR-182"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
380d4bf928fd7c5fa753d11a30c1e24e2ea471caca57b439f765a9d864cef472 manifest.json
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# RuView harness — agent operating notes
|
|
||||||
|
|
||||||
You are operating **RuView** (WiFi-DensePose), a camera-free WiFi-CSI sensing system.
|
|
||||||
|
|
||||||
## The one rule: prove everything
|
|
||||||
|
|
||||||
This project was accused of AI-slop; the fix is hard discipline. Before you quote ANY
|
|
||||||
accuracy number:
|
|
||||||
|
|
||||||
1. It must be tagged **MEASURED** (with a reproducer named), **CLAIMED**, or **SYNTHETIC**.
|
|
||||||
2. Pose PCK is quoted only as a **delta over the mean-pose baseline** on a leakage-free
|
|
||||||
held-out split. (A mean-pose predictor already scores ~50% PCK.)
|
|
||||||
3. Run `ruview_claim_check` on any report/PR/model-card. It flags untagged numbers and
|
|
||||||
the retracted "100%/perfect accuracy" framing.
|
|
||||||
4. Firmware is "hardware-validated" only with a captured **boot log on real silicon** —
|
|
||||||
never on a build-passes signal.
|
|
||||||
|
|
||||||
## Tools
|
|
||||||
|
|
||||||
`ruview_onboard`, `ruview_claim_check`, `ruview_verify`, `ruview_node_monitor`,
|
|
||||||
`ruview_calibrate`, `ruview_node_flash`. All fail-closed. Mutating/hardware tools
|
|
||||||
(`node_flash`) require explicit confirmation and are Windows/ESP-IDF gated.
|
|
||||||
|
|
||||||
## Skills
|
|
||||||
|
|
||||||
`onboard` · `provision-node` · `calibrate-room` · `train-pose` · `verify`
|
|
||||||
(`npx @ruvnet/ruview skill <name>`).
|
|
||||||
|
|
||||||
## Don'ts
|
|
||||||
|
|
||||||
- Don't present WiFi sensing as camera-grade.
|
|
||||||
- Don't echo or commit WiFi passwords / secrets.
|
|
||||||
- Don't merge or release firmware without a real boot log.
|
|
||||||
- Don't report a PCK without its mean-pose baseline.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# `npx @ruvnet/ruview` — RuView WiFi-sensing operator harness
|
|
||||||
|
|
||||||
An AI agent harness that knows how to operate **RuView** (WiFi-DensePose): onboard a
|
|
||||||
newcomer, provision an ESP32 CSI node, calibrate a room, train pose models, and —
|
|
||||||
crucially — **refuse to overstate accuracy**. Minted from the RuView monorepo via
|
|
||||||
[`metaharness`](https://www.npmjs.com/package/metaharness) and hardened per **ADR-182**.
|
|
||||||
|
|
||||||
WiFi sensing infers *coarse* pose/presence/breathing from Channel State Information.
|
|
||||||
It is **not a camera**. Every accuracy number this harness emits must be MEASURED
|
|
||||||
against a baseline — that rule is enforced in code (`ruview_claim_check`).
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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 + optional kernel/host)
|
|
||||||
npx @ruvnet/ruview --help
|
|
||||||
```
|
|
||||||
|
|
||||||
The operator tools are pure Node and run with **zero install weight** — the
|
|
||||||
package has no dependencies at all (ADR-263 O3). `doctor` / `install` can
|
|
||||||
additionally use `@metaharness/kernel` + a host adapter if you install them
|
|
||||||
(`npm i @metaharness/kernel @metaharness/host-claude-code`); everything else
|
|
||||||
runs without them.
|
|
||||||
|
|
||||||
## Tools (`ruview_*`)
|
|
||||||
|
|
||||||
Exposed both as CLI verbs and as an MCP server (`npx @ruvnet/ruview mcp start`):
|
|
||||||
|
|
||||||
| Tool | What it does |
|
|
||||||
|------|--------------|
|
|
||||||
| `ruview_onboard` | Pick docker-demo / repo-build / live-esp32; print the next command |
|
|
||||||
| `ruview_claim_check` | Lint text for untagged / overstated accuracy claims (guardrail) |
|
|
||||||
| `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) |
|
|
||||||
|
|
||||||
Every tool is **fail-closed**: missing repo / python / binary / port → an honest
|
|
||||||
negative, never a fabricated success.
|
|
||||||
|
|
||||||
## Skills
|
|
||||||
|
|
||||||
Host-neutral playbooks in `skills/` (`onboard`, `provision-node`, `calibrate-room`,
|
|
||||||
`train-pose`, `verify`). `npx @ruvnet/ruview skill <name>` prints one.
|
|
||||||
|
|
||||||
## Use as a Claude Code MCP server
|
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
## Hosts
|
|
||||||
|
|
||||||
claude-code (bundled), and via metaharness host adapters: codex, opencode, copilot,
|
|
||||||
pi-dev, hermes, rvm, github-actions.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT © ruvnet
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
#!/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/…) are PURE Node and run with
|
|
||||||
// zero deps. The kernel + host adapter are only touched by `doctor`/`install`
|
|
||||||
// (the harness-into-a-repo story), so the operator tools never block on a wasm load.
|
|
||||||
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { realpathSync, existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
||||||
import { join, dirname } from 'node:path';
|
|
||||||
import { argv } from 'node:process';
|
|
||||||
import { TOOLS, runTool, listTools } from '../src/tools.js';
|
|
||||||
import { claimCheck, summarize } from '../src/guardrails.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',
|
|
||||||
};
|
|
||||||
|
|
||||||
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]);
|
|
||||||
// Kernel + host adapter (optional — only needed to install into a repo).
|
|
||||||
let kernelLine = 'kernel/host: not installed (ok — operator tools run without them)';
|
|
||||||
try {
|
|
||||||
const { loadKernel } = await import('@metaharness/kernel');
|
|
||||||
const adapter = (await import('@metaharness/host-claude-code')).default;
|
|
||||||
const k = await loadKernel();
|
|
||||||
const info = k.kernelInfo();
|
|
||||||
checks.push(['kernel loads + reports version', typeof info.version === 'string' && info.version.length > 0]);
|
|
||||||
checks.push(['kernel backend is native|wasm|js', ['native', 'wasm', 'js'].includes(k.backend)]);
|
|
||||||
checks.push(['host adapter resolves', typeof adapter?.name === 'string']);
|
|
||||||
kernelLine = `kernel ${info.version} (${k.backend}) · host ${adapter.name}`;
|
|
||||||
} catch {
|
|
||||||
/* kernel not installed — fine for the tools-only path */
|
|
||||||
}
|
|
||||||
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'} — ${kernelLine}`);
|
|
||||||
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)
|
|
||||||
|
|
||||||
Harness:
|
|
||||||
doctor verify the install (tools + optional kernel/host)
|
|
||||||
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
|
|
||||||
--version | --help
|
|
||||||
|
|
||||||
Hosts: claude-code, codex, opencode, copilot, pi-dev, hermes, rvm, github-actions`);
|
|
||||||
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');
|
|
||||||
// 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 === 'calibrate' && typeof flags.args === 'string') toolArgs.args = flags.args.split(',');
|
|
||||||
const res = await runTool(VERB_TO_TOOL[cmd], toolArgs);
|
|
||||||
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 'install': {
|
|
||||||
const host = flags.host || 'claude-code';
|
|
||||||
try {
|
|
||||||
const adapter = (await import('@metaharness/host-claude-code')).default;
|
|
||||||
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 not installed. `npm i @metaharness/host-claude-code` or use the bundled .claude/ config.');
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CLI guard: run only when invoked directly (realpath both sides — npm/npx shims
|
|
||||||
// pass a non-normalized, possibly case-skewed argv[1] on Windows).
|
|
||||||
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); });
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@ruvnet/ruview",
|
|
||||||
"version": "0.2.0",
|
|
||||||
"description": "RuView WiFi-sensing operator agent harness — onboard, calibrate, train, and verify camera-free WiFi-CSI sensing, with the project's MEASURED-vs-CLAIMED honesty guardrail enforced. Minted via metaharness (ADR-182).",
|
|
||||||
"type": "module",
|
|
||||||
"bin": {
|
|
||||||
"ruview": "bin/cli.js"
|
|
||||||
},
|
|
||||||
"exports": {
|
|
||||||
".": "./src/tools.js",
|
|
||||||
"./guardrails": "./src/guardrails.js"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"bin/",
|
|
||||||
"src/",
|
|
||||||
"skills/",
|
|
||||||
".claude/",
|
|
||||||
".harness/",
|
|
||||||
"CLAUDE.md",
|
|
||||||
"README.md",
|
|
||||||
"LICENSE"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"test": "node --test test/*.test.mjs",
|
|
||||||
"doctor": "node ./bin/cli.js doctor",
|
|
||||||
"mcp": "node ./bin/cli.js mcp start",
|
|
||||||
"sync-skills": "node ./scripts/sync-skills.mjs",
|
|
||||||
"prepack": "node ./scripts/sync-skills.mjs",
|
|
||||||
"prepublishOnly": "npm test"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"wifi-sensing",
|
|
||||||
"wifi-densepose",
|
|
||||||
"ruview",
|
|
||||||
"csi",
|
|
||||||
"channel-state-information",
|
|
||||||
"pose-estimation",
|
|
||||||
"presence-detection",
|
|
||||||
"esp32",
|
|
||||||
"agent-harness",
|
|
||||||
"metaharness",
|
|
||||||
"mcp",
|
|
||||||
"mcp-server",
|
|
||||||
"claude-code",
|
|
||||||
"ambient-intelligence"
|
|
||||||
],
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/ruvnet/RuView/issues"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
// ADR-263 O7: skills/*.md is the single source of truth; the host-projected
|
|
||||||
// copies (.claude/skills/<name>/SKILL.md) are GENERATED here at pack time.
|
|
||||||
// Run with --check to verify without writing (used by tests/CI).
|
|
||||||
|
|
||||||
import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
||||||
import { join, dirname } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
||||||
const SRC = join(ROOT, 'skills');
|
|
||||||
const DST = join(ROOT, '.claude', 'skills');
|
|
||||||
const checkOnly = process.argv.includes('--check');
|
|
||||||
|
|
||||||
let drift = 0;
|
|
||||||
for (const f of readdirSync(SRC).filter((f) => f.endsWith('.md'))) {
|
|
||||||
const name = f.replace(/\.md$/, '');
|
|
||||||
const src = readFileSync(join(SRC, f), 'utf8');
|
|
||||||
const dstDir = join(DST, name);
|
|
||||||
const dstFile = join(dstDir, 'SKILL.md');
|
|
||||||
const current = existsSync(dstFile) ? readFileSync(dstFile, 'utf8') : null;
|
|
||||||
if (current === src) continue;
|
|
||||||
drift++;
|
|
||||||
if (checkOnly) {
|
|
||||||
console.error(`DRIFT: .claude/skills/${name}/SKILL.md != skills/${f}`);
|
|
||||||
} else {
|
|
||||||
mkdirSync(dstDir, { recursive: true });
|
|
||||||
writeFileSync(dstFile, src);
|
|
||||||
console.error(`synced .claude/skills/${name}/SKILL.md`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (checkOnly && drift > 0) {
|
|
||||||
console.error(`sync-skills --check: ${drift} file(s) out of sync — run \`npm run sync-skills\`.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.error(`sync-skills: ${drift === 0 ? 'all in sync' : `${drift} file(s) ${checkOnly ? 'OUT OF SYNC' : 'synced'}`}`);
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
---
|
|
||||||
name: calibrate-room
|
|
||||||
description: Run the ADR-151 per-room calibration pipeline — baseline → enroll → extract → train → a bank of small specialists (presence/posture/breathing/heartbeat/restlessness/anomaly).
|
|
||||||
---
|
|
||||||
|
|
||||||
# calibrate-room
|
|
||||||
|
|
||||||
Turn a provisioned node + sensing-server into a working room model. Pure-Rust,
|
|
||||||
edge-deployable (ADR-151). Use the `ruview_calibrate` tool (installed
|
|
||||||
`wifi-densepose` binary, else `cargo run -p wifi-densepose-cli`).
|
|
||||||
|
|
||||||
## Sequence
|
|
||||||
|
|
||||||
1. **baseline** — capture the empty room (Welford amplitude + von Mises phase). Leave
|
|
||||||
the room empty.
|
|
||||||
`ruview_calibrate {step: "baseline"}`
|
|
||||||
2. **enroll** — record the occupant(s) doing the target activities.
|
|
||||||
`ruview_calibrate {step: "enroll"}`
|
|
||||||
3. **train-room** — train the bank of small specialists from baseline + enrollment.
|
|
||||||
`ruview_calibrate {step: "train-room"}`
|
|
||||||
4. **room-watch** — live presence/posture/breathing from the trained room.
|
|
||||||
`ruview_calibrate {step: "room-watch"}` (or the `room-watch` skill)
|
|
||||||
|
|
||||||
## Honesty
|
|
||||||
|
|
||||||
The specialists are calibrated to *this* room; cross-room transfer is a separate
|
|
||||||
problem (LoRA recalibration, ADR-079 P9). Report which room a number came from, and
|
|
||||||
tag presence/vitals accuracy MEASURED only with a held-out check — run
|
|
||||||
`ruview_claim_check` on the writeup.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
---
|
|
||||||
name: onboard
|
|
||||||
description: Zero-to-sensing path picker for RuView (WiFi-DensePose) — pick docker-demo, repo-build, or live-esp32 and run the next concrete step.
|
|
||||||
---
|
|
||||||
|
|
||||||
# onboard
|
|
||||||
|
|
||||||
Get a newcomer from nothing to a working RuView setup. **First fact to set:** WiFi
|
|
||||||
sensing infers *coarse* pose/presence/breathing from Channel State Information — it
|
|
||||||
is **not a camera**, and any accuracy number must be MEASURED against a baseline
|
|
||||||
(use the `verify` skill / `ruview_claim_check` tool). Never present WiFi output as
|
|
||||||
camera-grade.
|
|
||||||
|
|
||||||
## Pick a path
|
|
||||||
|
|
||||||
Run `ruview_onboard {path}` or decide from:
|
|
||||||
|
|
||||||
1. **docker-demo** — fastest, no hardware. Replays sample CSI into the dashboard.
|
|
||||||
`docker run -p 8000:8000 ruvnet/wifi-densepose` → open `http://localhost:8000`.
|
|
||||||
Use to see what it looks like.
|
|
||||||
2. **repo-build** — for developers. `cd v2 && cargo test --workspace --no-default-features`
|
|
||||||
(1,031+ tests pass), then `cargo run -p wifi-densepose-cli -- --help`.
|
|
||||||
3. **live-esp32** — a real install. Flash a node (`provision-node` skill), point it at
|
|
||||||
the sensing-server, then `calibrate-room`. This is the only path that senses a real room.
|
|
||||||
|
|
||||||
## Then
|
|
||||||
|
|
||||||
- Live sensing → go to **provision-node**, then **calibrate-room**.
|
|
||||||
- Evaluating a model/claim → go to **verify** and run `ruview_claim_check` on any
|
|
||||||
report before you quote a number.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
---
|
|
||||||
|
|
||||||
# provision-node
|
|
||||||
|
|
||||||
Bring an ESP32 sensing node online.
|
|
||||||
|
|
||||||
## 1. Pick a firmware variant
|
|
||||||
|
|
||||||
- **s3-8mb** (display build) — ESP32-S3 N16R8 / 16MB; AMOLED optional. The display-detect
|
|
||||||
fix (#1000) means a *bare* board still captures CSI (MGMT+DATA).
|
|
||||||
- **s3-4mb** (no-display) — ESP32-S3 4MB; dual-OTA, display disabled.
|
|
||||||
- **c6** — ESP32-C6 + Seeed MR60BHA2 (60 GHz mmWave + WiFi CSI). The mmwave probe
|
|
||||||
requires a validated MR60 header (#1107) so an empty UART never false-detects.
|
|
||||||
|
|
||||||
Prebuilt binaries: GitHub release `v0.8.1-esp32` (hardware-validated on S3 QFN56 rev v0.2).
|
|
||||||
|
|
||||||
## 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:
|
|
||||||
|
|
||||||
```
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
(`ruview_node_flash` returns the exact pinned command rather than running an
|
|
||||||
unattended flash.)
|
|
||||||
|
|
||||||
## 3. Provision
|
|
||||||
|
|
||||||
```
|
|
||||||
python firmware/esp32-csi-node/provision.py --port <PORT> \
|
|
||||||
--ssid "<SSID>" --password "<secret>" --target-ip <server-ip> --target-port 5005
|
|
||||||
# optional ADR-060 overrides:
|
|
||||||
python firmware/esp32-csi-node/provision.py --port <PORT> --channel 6 --filter-mac AA:BB:CC:DD:EE:FF
|
|
||||||
```
|
|
||||||
|
|
||||||
Never echo or commit the WiFi password.
|
|
||||||
|
|
||||||
## 4. Confirm CSI is flowing
|
|
||||||
|
|
||||||
`ruview_node_monitor {port}` — PASS criteria: serial shows `CSI cb #...` callbacks and
|
|
||||||
(on a bare board) `CSI filter upgraded to MGMT+DATA`. No callbacks → the node isn't
|
|
||||||
capturing; do not proceed to calibration.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
name: train-pose
|
|
||||||
description: Train/evaluate WiFi pose models honestly — camera-supervised (MediaPipe + CSI) and camera-free (WiFlow), always checked against the mean-pose baseline before any PCK is quoted.
|
|
||||||
---
|
|
||||||
|
|
||||||
# train-pose
|
|
||||||
|
|
||||||
Build a CSI→pose model without overstating it. The project has a **retracted 92.9%/100%**
|
|
||||||
history — the discipline below exists so it never recurs.
|
|
||||||
|
|
||||||
## The non-negotiable: mean-pose baseline first
|
|
||||||
|
|
||||||
A pose model that always predicts the dataset's *mean pose* already scores ~50% PCK.
|
|
||||||
**Quote PCK only as a delta over that baseline**, on a held-out split with no subject
|
|
||||||
or temporal leakage. Example honest result (ADR-181):
|
|
||||||
|
|
||||||
> Held-out PCK@20 **59.5%** vs a 50% mean-pose baseline = **+9.4 pp real signal** — MEASURED.
|
|
||||||
|
|
||||||
## Paths
|
|
||||||
|
|
||||||
- **camera-supervised** (ADR-079) — MediaPipe Pose labels the camera frame; paired CSI
|
|
||||||
trains the net. Train/infer in one camera frame so the skeleton aligns.
|
|
||||||
- **camera-free** (WiFlow, ADR-152) — no camera at inference; geometry-conditioned.
|
|
||||||
- **in-browser** (ADR-181) — WebGPU/WASM trainer; the active backend is shown as a badge
|
|
||||||
(honest about what's executing).
|
|
||||||
|
|
||||||
## Before you publish a number
|
|
||||||
|
|
||||||
1. Run the mean-pose baseline on the same split.
|
|
||||||
2. Report `(model − baseline)` in pp, with the split definition (chronological /
|
|
||||||
blocked-gap / grouped-bucket; no leakage).
|
|
||||||
3. `ruview_claim_check` the writeup — it flags any untagged or 100%/perfect claim.
|
|
||||||
4. If it's a benchmark vs SOTA, tag MEASURED-EQUIVALENT only with the reproducer.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
---
|
|
||||||
name: verify
|
|
||||||
description: Prove a RuView result is real — run the deterministic SHA-256 proof and the witness bundle (ADR-028), and lint any claim for MEASURED-vs-CLAIMED honesty.
|
|
||||||
---
|
|
||||||
|
|
||||||
# verify
|
|
||||||
|
|
||||||
The "prove everything" skill. Nothing ships as validated without this.
|
|
||||||
|
|
||||||
## Deterministic proof (Trust Kill Switch)
|
|
||||||
|
|
||||||
`ruview_verify` runs `archive/v1/data/proof/verify.py`: it feeds a reference signal
|
|
||||||
through the production pipeline and hashes the output against
|
|
||||||
`expected_features.sha256`. Must print **VERDICT: PASS**. If numpy/scipy changed the
|
|
||||||
hash, regenerate with `verify.py --generate-hash` then re-verify.
|
|
||||||
|
|
||||||
## Witness bundle (ADR-028)
|
|
||||||
|
|
||||||
For a release-grade attestation:
|
|
||||||
|
|
||||||
```
|
|
||||||
bash scripts/generate-witness-bundle.sh
|
|
||||||
cd dist/witness-bundle-ADR028-*/ && bash VERIFY.sh # must be 7/7 PASS
|
|
||||||
```
|
|
||||||
|
|
||||||
Contains the Rust test log, the proof + expected hash, firmware SHA-256 manifest, and
|
|
||||||
crate versions — a recipient can re-verify with one command.
|
|
||||||
|
|
||||||
## Claim honesty
|
|
||||||
|
|
||||||
Run `ruview_claim_check {text}` on any report, README section, PR body, or model card
|
|
||||||
before quoting accuracy. It flags:
|
|
||||||
- untagged accuracy numbers (must be MEASURED / CLAIMED / SYNTHETIC),
|
|
||||||
- MEASURED claims with no reproducer cited,
|
|
||||||
- the retracted "100%/perfect accuracy" framing.
|
|
||||||
|
|
||||||
## Firmware-specific
|
|
||||||
|
|
||||||
A firmware fix is **not** "hardware-validated" without a captured boot log on real
|
|
||||||
silicon (e.g. the `v0.8.1-esp32` rev-v0.2 validation: `running headless so CSI
|
|
||||||
captures (#1000)` + `CSI filter upgraded to MGMT+DATA` + a no-false-detect mmwave
|
|
||||||
probe). Do not merge or release on a build-passes signal alone.
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
|
||||||
// RuView harness guardrails — the "prove everything" rule made executable.
|
|
||||||
//
|
|
||||||
// The project was accused of AI-slop; the cultural fix is that every accuracy
|
|
||||||
// number must be tagged MEASURED (with a reproducer) or CLAIMED/SYNTHETIC, and
|
|
||||||
// the retracted "100% accuracy" framing must never reappear untagged. This module
|
|
||||||
// is the static enforcement of that, shared by the `ruview_claim_check` MCP tool,
|
|
||||||
// the `npx ruview claim-check` CLI, and the claude-code pre-output hook.
|
|
||||||
|
|
||||||
/** Phrases that signal a quantitative accuracy claim (safe as substrings). */
|
|
||||||
const METRIC_TERMS = [
|
|
||||||
'accuracy', 'pck', 'precision', 'recall',
|
|
||||||
'mpjpe', 'error rate', 'detection rate', 'true positive',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Short/ambiguous metric tokens (ADR-263 F11): 'map' is usually the English
|
|
||||||
// word or a file extension, 'f1'/'o1' collide with finding/option labels.
|
|
||||||
// They only count as metric mentions when word-bounded, not a `.map` file
|
|
||||||
// reference, and the line (after scrubbing) carries a number — "mAP 62.3" is
|
|
||||||
// a claim, "F-numbers map to findings" is not.
|
|
||||||
// 'map' additionally must not be a `.map` file suffix or a hyphenated
|
|
||||||
// compound ("map-free", "map-reduce") — mAP the metric never appears as either.
|
|
||||||
const METRIC_TERMS_SHORT = [/(?<![.\w])map\b(?!-)/, /\bf1\b/, /\bauc\b/, /\biou\b/];
|
|
||||||
// Finding/option labels (F1, O2, …) count as labels unless the token sits in a
|
|
||||||
// metric context: an immediately following score/=/%/digit or colon ("F1: 0.91"),
|
|
||||||
// or a number later in the same clause ("F1 reaches 0.91" — an F1-score claim).
|
|
||||||
// Bare option refs ("F7 fixes", "O1–O9", "ADR-263 O2") carry no clause number of
|
|
||||||
// their own and stay labels. (A surviving 'f1' still only fires as a metric when
|
|
||||||
// its scrubbed line actually carries a number — see mentionsMetricTerm.)
|
|
||||||
const LABEL_TOKEN_RE = /\b[fo]\d+\b(?!\s*(?:score|=|\d|%|:))(?![^\n.;]*\d)/g;
|
|
||||||
const CODE_SPAN_RE = /`[^`]*`/g; // backticked identifiers are code, not claims
|
|
||||||
const HAS_NUMBER_RE = /\d/;
|
|
||||||
|
|
||||||
/** Line with code spans and finding/option labels removed. */
|
|
||||||
function scrubLine(lower) {
|
|
||||||
return lower.replace(CODE_SPAN_RE, ' ').replace(LABEL_TOKEN_RE, ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function mentionsMetricTerm(lower, scrubbed) {
|
|
||||||
if (METRIC_TERMS.some((t) => lower.includes(t))) return true;
|
|
||||||
if (!HAS_NUMBER_RE.test(scrubbed)) return false;
|
|
||||||
return METRIC_TERMS_SHORT.some((re) => re.test(scrubbed));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tags that make a claim honest (case-insensitive). */
|
|
||||||
const HONEST_TAGS = ['measured', 'claimed', 'synthetic', 'unvalidated', 'baseline'];
|
|
||||||
|
|
||||||
/** Reproducer references that count as evidence backing a MEASURED claim. */
|
|
||||||
const REPRODUCER_HINTS = [
|
|
||||||
'verify.py', 'witness', 'mean-pose', 'mean pose', 'held-out', 'held out',
|
|
||||||
'baseline', 'reproduce', 'sha256', 'boot log', 'pck@20 vs', 'expected_features',
|
|
||||||
// Packaging-claim reproducers (ADR-263/264 npm reviews): the tarball itself.
|
|
||||||
'npm pack', 'npm view', 'npm i ', 'npm install', 'tarball', 'cargo test',
|
|
||||||
];
|
|
||||||
|
|
||||||
const PERCENT_RE = /\b(\d{1,3}(?:\.\d+)?)\s?%/g;
|
|
||||||
// "perfect" / "100%" framing is the specific retracted claim — always high severity.
|
|
||||||
// NOTE: no trailing \b after "%": "%"→" " is non-word→non-word, so a trailing \b
|
|
||||||
// never matches and would silently miss "100%". Bare 100% is only damning next to a
|
|
||||||
// metric term (see claimCheck); the word phrases are inherently accuracy claims.
|
|
||||||
const PERFECT_PCT_RE = /\b100(?:\.0+)?\s?%/;
|
|
||||||
const PERFECT_WORD_RE = /perfect accuracy|flawless|never (?:wrong|fails)/i;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lint a block of text for untagged or overstated accuracy claims.
|
|
||||||
* @param {string} text
|
|
||||||
* @returns {{ok: boolean, findings: Array<{severity:'high'|'medium', line:number, excerpt:string, reason:string, suggestion:string}>}}
|
|
||||||
*/
|
|
||||||
export function claimCheck(text) {
|
|
||||||
const findings = [];
|
|
||||||
if (typeof text !== 'string' || text.length === 0) {
|
|
||||||
return { ok: true, findings };
|
|
||||||
}
|
|
||||||
const lines = text.split(/\r?\n/);
|
|
||||||
|
|
||||||
lines.forEach((raw, i) => {
|
|
||||||
const line = raw.trim();
|
|
||||||
if (!line) return;
|
|
||||||
const lower = line.toLowerCase();
|
|
||||||
|
|
||||||
const hasPercent = PERCENT_RE.test(line);
|
|
||||||
PERCENT_RE.lastIndex = 0; // reset stateful global regex
|
|
||||||
const scrubbed = scrubLine(lower);
|
|
||||||
const mentionsMetric = mentionsMetricTerm(lower, scrubbed);
|
|
||||||
if (!hasPercent && !mentionsMetric) return;
|
|
||||||
|
|
||||||
const tagged = HONEST_TAGS.some((t) => lower.includes(t));
|
|
||||||
const hasReproducer = REPRODUCER_HINTS.some((h) => lower.includes(h));
|
|
||||||
const perfect = PERFECT_WORD_RE.test(line) || (mentionsMetric && PERFECT_PCT_RE.test(line));
|
|
||||||
|
|
||||||
if (perfect && !lower.includes('retract')) {
|
|
||||||
findings.push({
|
|
||||||
severity: 'high',
|
|
||||||
line: i + 1,
|
|
||||||
excerpt: clip(line),
|
|
||||||
reason: 'States perfect/100% accuracy — this is the exact framing the project retracted.',
|
|
||||||
suggestion: 'Replace with a held-out number vs the mean-pose baseline, tagged MEASURED, or mark the old claim "retracted".',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A quantitative claim needs a number. Digits hidden in a code span still
|
|
||||||
// count — "accuracy reached `0.95`" is a claim — so test the line with only
|
|
||||||
// finding/option labels stripped, NOT the code-span-scrubbed copy: scrubbing
|
|
||||||
// dropped `0.95` and wrongly short-circuited both the untagged and the
|
|
||||||
// MEASURED-without-reproducer checks below. A bare metric word in prose
|
|
||||||
// ("precision matters here", "every accuracy number must be MEASURED") has no
|
|
||||||
// number and is not a taggable claim (ADR-263 F11).
|
|
||||||
if (!hasPercent && !HAS_NUMBER_RE.test(lower.replace(LABEL_TOKEN_RE, ' '))) return;
|
|
||||||
|
|
||||||
// A metric/percent with no honesty tag at all.
|
|
||||||
if (!tagged) {
|
|
||||||
findings.push({
|
|
||||||
severity: 'medium',
|
|
||||||
line: i + 1,
|
|
||||||
excerpt: clip(line),
|
|
||||||
reason: 'Accuracy claim is not tagged MEASURED / CLAIMED / SYNTHETIC.',
|
|
||||||
suggestion: 'Tag it. If MEASURED, name the reproducer (verify.py, witness bundle, held-out vs mean-pose).',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tagged MEASURED but cites no reproducer — still a gap (reached now even
|
|
||||||
// when the only number is inside a code span, e.g. "accuracy `0.97` (MEASURED)").
|
|
||||||
if (lower.includes('measured') && !hasReproducer) {
|
|
||||||
findings.push({
|
|
||||||
severity: 'medium',
|
|
||||||
line: i + 1,
|
|
||||||
excerpt: clip(line),
|
|
||||||
reason: 'Tagged MEASURED but cites no reproducer/evidence.',
|
|
||||||
suggestion: 'Add the evidence path: verify.py VERDICT, witness bundle, or held-out PCK vs the mean-pose baseline.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { ok: findings.length === 0, findings };
|
|
||||||
}
|
|
||||||
|
|
||||||
function clip(s, n = 120) {
|
|
||||||
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convenience: a one-line human summary for CLI output. */
|
|
||||||
export function summarize(result) {
|
|
||||||
if (result.ok) return 'claim-check: PASS — no untagged or overstated accuracy claims.';
|
|
||||||
const high = result.findings.filter((f) => f.severity === 'high').length;
|
|
||||||
return `claim-check: ${result.findings.length} finding(s) (${high} high) — accuracy claims need MEASURED/CLAIMED tags + a reproducer.`;
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
|
||||||
// RuView harness — minimal MCP stdio server (JSON-RPC 2.0 over stdin/stdout).
|
|
||||||
//
|
|
||||||
// Dependency-free on purpose: a published `npx ruview` must `mcp start` without
|
|
||||||
// pulling the full MCP SDK. Implements the subset hosts use: `initialize`,
|
|
||||||
// `tools/list`, `tools/call`, `ping`, empty `resources/list`/`prompts/list`
|
|
||||||
// stubs, and the `notifications/initialized` ack. Logs go to stderr ONLY —
|
|
||||||
// stdout is the JSON-RPC channel and must stay clean.
|
|
||||||
//
|
|
||||||
// ADR-263 O2: `tools/call` is dispatched asynchronously — a long-running
|
|
||||||
// verify/calibrate no longer blocks ping/tools/list, so hosts that health-check
|
|
||||||
// mid-run see a live server. Responses may therefore arrive out of request
|
|
||||||
// order, which JSON-RPC permits (ids correlate them).
|
|
||||||
|
|
||||||
import { createInterface } from 'node:readline';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { listTools, runTool } from './tools.js';
|
|
||||||
|
|
||||||
const PROTOCOL_VERSION = '2024-11-05';
|
|
||||||
// Single-source the version from package.json (ADR-263 O6).
|
|
||||||
const PKG = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
||||||
const SERVER_INFO = { name: 'ruview', version: PKG.version };
|
|
||||||
|
|
||||||
function send(msg) {
|
|
||||||
process.stdout.write(JSON.stringify(msg) + '\n');
|
|
||||||
}
|
|
||||||
function result(id, res) { send({ jsonrpc: '2.0', id, result: res }); }
|
|
||||||
function error(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
|
|
||||||
function log(...a) { process.stderr.write('[ruview-mcp] ' + a.join(' ') + '\n'); }
|
|
||||||
|
|
||||||
async function handle(msg) {
|
|
||||||
const { id, method, params } = msg;
|
|
||||||
switch (method) {
|
|
||||||
case 'initialize':
|
|
||||||
return result(id, {
|
|
||||||
protocolVersion: PROTOCOL_VERSION,
|
|
||||||
capabilities: { tools: { listChanged: false } },
|
|
||||||
serverInfo: SERVER_INFO,
|
|
||||||
instructions: 'RuView WiFi-sensing operator tools. All results are fail-closed; accuracy claims must pass ruview_claim_check.',
|
|
||||||
});
|
|
||||||
case 'notifications/initialized':
|
|
||||||
case 'initialized':
|
|
||||||
case 'notifications/cancelled':
|
|
||||||
return; // notifications — no response
|
|
||||||
case 'ping':
|
|
||||||
return result(id, {});
|
|
||||||
case 'tools/list':
|
|
||||||
return result(id, { tools: listTools() });
|
|
||||||
case 'resources/list':
|
|
||||||
return result(id, { resources: [] });
|
|
||||||
case 'prompts/list':
|
|
||||||
return result(id, { prompts: [] });
|
|
||||||
case 'tools/call': {
|
|
||||||
const name = params?.name;
|
|
||||||
const args = params?.arguments || {};
|
|
||||||
const out = await runTool(name, args);
|
|
||||||
// MCP content envelope: text block with the JSON, isError reflects ok=false.
|
|
||||||
return result(id, {
|
|
||||||
content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
|
|
||||||
isError: out && out.ok === false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if (id !== undefined) error(id, -32601, `Method not found: ${method}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startMcpServer() {
|
|
||||||
log(`starting v${SERVER_INFO.version} (protocol ${PROTOCOL_VERSION}, ${listTools().length} tools)`);
|
|
||||||
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
||||||
|
|
||||||
// tools/call runs are serialized through a FIFO promise chain: hardware/mutating
|
|
||||||
// tools (calibrate, serial monitor, flash) must never overlap. ping/tools/list/
|
|
||||||
// initialize/resources/prompts stay immediate (ADR-263 O2 — a health check must
|
|
||||||
// answer during a long tool run). `toolChain` also lets stdin-close drain the
|
|
||||||
// in-flight call so its response is flushed instead of dropped by process.exit.
|
|
||||||
let toolChain = Promise.resolve();
|
|
||||||
|
|
||||||
const dispatch = (msg) => handle(msg).catch((err) => {
|
|
||||||
if (msg && msg.id !== undefined) error(msg.id, -32603, String(err && err.message || err));
|
|
||||||
log('handler error:', String(err));
|
|
||||||
});
|
|
||||||
|
|
||||||
rl.on('line', (line) => {
|
|
||||||
const s = line.trim();
|
|
||||||
if (!s) return;
|
|
||||||
let msg;
|
|
||||||
try { msg = JSON.parse(s); } catch { return log('bad JSON line dropped'); }
|
|
||||||
if (msg && msg.method === 'tools/call') {
|
|
||||||
toolChain = toolChain.then(() => dispatch(msg)); // one tool at a time
|
|
||||||
} else {
|
|
||||||
dispatch(msg); // health/list/handshake answer immediately, even mid tool run
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
rl.on('close', () => {
|
|
||||||
// Wait for any queued/in-flight tool call to settle (its response written)
|
|
||||||
// before exiting — fire-and-forget used to race this and drop the response.
|
|
||||||
toolChain.then(() => {
|
|
||||||
log('stdin closed — exiting');
|
|
||||||
const done = () => process.exit(0);
|
|
||||||
// Pipe writes are async; flush buffered stdout before exit.
|
|
||||||
if (process.stdout.writableLength) process.stdout.once('drain', done);
|
|
||||||
else done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
|
||||||
// RuView harness — the `ruview.*` tool registry.
|
|
||||||
//
|
|
||||||
// One registry consumed by BOTH the CLI (`npx ruview <tool>`) and the MCP server
|
|
||||||
// (`npx ruview mcp start`). Every handler returns structured JSON and is
|
|
||||||
// FAIL-CLOSED: when a prerequisite (the RuView repo, python+pyserial, the
|
|
||||||
// `wifi-densepose` binary, an ESP32 on a port) is absent, it returns an honest
|
|
||||||
// negative — never a fabricated success. This mirrors the project's "prove
|
|
||||||
// everything" rule and the RuField fail-closed posture (ADR-262 §3.3).
|
|
||||||
//
|
|
||||||
// ADR-263: handlers are async (promise-based spawn, never spawnSync) so the MCP
|
|
||||||
// server keeps answering ping/tools/list while a long verify/calibrate runs.
|
|
||||||
// Canonical tool names use underscores (host tool-name regexes commonly enforce
|
|
||||||
// ^[a-zA-Z0-9_-]{1,64}$); the historical dotted names are accepted as aliases.
|
|
||||||
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { existsSync, accessSync, constants } from 'node:fs';
|
|
||||||
import { join, dirname, resolve, delimiter } from 'node:path';
|
|
||||||
import { claimCheck, summarize } from './guardrails.js';
|
|
||||||
|
|
||||||
/** Walk up from `start` to find the RuView monorepo root (or null). */
|
|
||||||
export function findRepoRoot(start = process.cwd()) {
|
|
||||||
let dir = resolve(start);
|
|
||||||
for (let i = 0; i < 8; i++) {
|
|
||||||
const hasProof = existsSync(join(dir, 'archive', 'v1', 'data', 'proof', 'verify.py'));
|
|
||||||
const hasV2 = existsSync(join(dir, 'v2', 'Cargo.toml'));
|
|
||||||
if (hasProof || hasV2) return dir;
|
|
||||||
const parent = dirname(dir);
|
|
||||||
if (parent === dir) break;
|
|
||||||
dir = parent;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dep-free PATH scan (ADR-263 O8) — no shell subprocess per lookup. Only hits
|
|
||||||
// are memoized: a miss can resolve later in a long-lived MCP session (the
|
|
||||||
// operator installs python/the CLI mid-run), so misses are re-probed each call.
|
|
||||||
const whichCache = new Map();
|
|
||||||
export function which(cmd) {
|
|
||||||
if (whichCache.has(cmd)) return whichCache.get(cmd);
|
|
||||||
const isWin = process.platform === 'win32';
|
|
||||||
const exts = isWin
|
|
||||||
? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
|
|
||||||
: [''];
|
|
||||||
let found = null;
|
|
||||||
outer:
|
|
||||||
for (const dir of (process.env.PATH || '').split(delimiter)) {
|
|
||||||
if (!dir) continue;
|
|
||||||
for (const ext of isWin ? ['', ...exts] : exts) {
|
|
||||||
const p = join(dir, cmd + ext);
|
|
||||||
try {
|
|
||||||
accessSync(p, isWin ? constants.F_OK : constants.X_OK);
|
|
||||||
found = p;
|
|
||||||
break outer;
|
|
||||||
} catch { /* keep scanning */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found !== null) whichCache.set(cmd, found);
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bounded output tails (ADR-263 O4): spawnSync's default 1 MiB maxBuffer killed
|
|
||||||
// chatty children with ENOBUFS; handlers only ever surface the last few kB, so
|
|
||||||
// keep rolling tails instead of the full stream.
|
|
||||||
const STDOUT_TAIL = 65536;
|
|
||||||
const STDERR_TAIL = 16384;
|
|
||||||
|
|
||||||
/** Promise-based spawn with timeout + rolling output tails. */
|
|
||||||
export function run(cmd, args, opts = {}) {
|
|
||||||
const timeout = opts.timeout ?? 120000;
|
|
||||||
return new Promise((resolvePromise) => {
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
let child;
|
|
||||||
try {
|
|
||||||
child = spawn(cmd, args, { cwd: opts.cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
||||||
} catch (e) {
|
|
||||||
resolvePromise({ status: null, ok: false, stdout: '', stderr: '', error: e.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let timedOut = false;
|
|
||||||
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeout);
|
|
||||||
child.stdout.on('data', (d) => {
|
|
||||||
stdout = (stdout + d).slice(-STDOUT_TAIL);
|
|
||||||
});
|
|
||||||
child.stderr.on('data', (d) => {
|
|
||||||
stderr = (stderr + d).slice(-STDERR_TAIL);
|
|
||||||
});
|
|
||||||
child.on('error', (e) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolvePromise({ status: null, ok: false, stdout, stderr, error: e.message });
|
|
||||||
});
|
|
||||||
child.on('close', (status) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolvePromise({
|
|
||||||
status,
|
|
||||||
ok: status === 0,
|
|
||||||
stdout,
|
|
||||||
stderr,
|
|
||||||
error: timedOut ? `timed out after ${timeout} ms` : null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ONBOARD_PATHS = {
|
|
||||||
'docker-demo': 'Fastest. `docker run -p 8000:8000 ruvnet/wifi-densepose` → open the dashboard. No hardware; replays sample CSI. Good for "what does it look like".',
|
|
||||||
'repo-build': 'Build from source. `cd v2 && cargo test --workspace --no-default-features` (1,031+ tests). Then `cargo run -p wifi-densepose-cli -- --help`. Good for developers.',
|
|
||||||
'live-esp32': 'Real sensing. Flash an ESP32-S3 (see `provision-node` skill), point it at the sensing-server, then `calibrate → enroll → train-room → room-watch` (see `calibrate-room`). Good for an actual install.',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Read-only serial monitor script; the port arrives via sys.argv (ADR-263 O5 —
|
|
||||||
// never spliced into interpreter source).
|
|
||||||
const MONITOR_SCRIPT = [
|
|
||||||
'import sys,time',
|
|
||||||
'try:',
|
|
||||||
' import serial',
|
|
||||||
'except Exception as e:',
|
|
||||||
" print('NO_PYSERIAL'); sys.exit(3)",
|
|
||||||
'port=sys.argv[1]',
|
|
||||||
'dur=float(sys.argv[2])',
|
|
||||||
'ser=serial.Serial(port,115200,timeout=1)',
|
|
||||||
'csi=0; n=0; t=time.time()',
|
|
||||||
'while time.time()-t<dur:',
|
|
||||||
' ln=ser.readline()',
|
|
||||||
' if not ln: continue',
|
|
||||||
" s=ln.decode('utf-8','replace')",
|
|
||||||
' n+=1',
|
|
||||||
" if 'CSI cb' in s or 'csi_collector' in s: csi+=1",
|
|
||||||
" if 'MGMT+DATA' in s: print('UPGRADE_MGMT_DATA')",
|
|
||||||
'ser.close()',
|
|
||||||
"print(f'LINES={n} CSI={csi}')",
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The tool registry. Each entry: { title, description, inputSchema, handler }.
|
|
||||||
* inputSchema is JSON-Schema (object). handler(args) → JSON-serializable result
|
|
||||||
* (sync or promise). Canonical names are underscore-form.
|
|
||||||
*/
|
|
||||||
export const TOOLS = {
|
|
||||||
ruview_onboard: {
|
|
||||||
title: 'Onboard',
|
|
||||||
description: 'Pick a RuView setup path (docker-demo | repo-build | live-esp32) and print the next concrete command.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: { path: { type: 'string', enum: Object.keys(ONBOARD_PATHS), description: 'Which setup path. Omit to list all.' } },
|
|
||||||
},
|
|
||||||
handler(args = {}) {
|
|
||||||
const repo = findRepoRoot();
|
|
||||||
if (args.path && ONBOARD_PATHS[args.path]) {
|
|
||||||
return { ok: true, path: args.path, next: ONBOARD_PATHS[args.path], in_ruview_repo: !!repo };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
in_ruview_repo: !!repo,
|
|
||||||
repo_root: repo,
|
|
||||||
paths: ONBOARD_PATHS,
|
|
||||||
recommend: repo ? 'repo-build' : 'docker-demo',
|
|
||||||
note: 'WiFi sensing infers coarse pose/presence from CSI — it is not a camera. Accuracy claims must be MEASURED vs a baseline (run `ruview_claim_check`).',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
ruview_claim_check: {
|
|
||||||
title: 'Claim check',
|
|
||||||
description: 'Static lint: scan text for untagged or overstated accuracy claims (the "prove everything" guardrail). Returns findings. Fail-closed: empty input is an error, not a pass.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['text'],
|
|
||||||
properties: { text: { type: 'string', description: 'The text to lint (a report, README section, PR body, model card).' } },
|
|
||||||
},
|
|
||||||
handler(args = {}) {
|
|
||||||
const text = typeof args.text === 'string' ? args.text : '';
|
|
||||||
if (text.trim().length === 0) {
|
|
||||||
return { ok: false, reason: 'empty_text', hint: 'Pass the text to lint — an empty input must not pass an honesty gate.' };
|
|
||||||
}
|
|
||||||
const result = claimCheck(text);
|
|
||||||
return { ...result, summary: summarize(result) };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
ruview_verify: {
|
|
||||||
title: 'Verify (witness)',
|
|
||||||
description: 'Run the deterministic proof (archive/v1/data/proof/verify.py) and report VERDICT. Fail-closed if not in a RuView repo or python is missing.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: { repo: { type: 'string', description: 'RuView repo root. Default: auto-detect from cwd.' } },
|
|
||||||
},
|
|
||||||
async handler(args = {}) {
|
|
||||||
const repo = args.repo ? resolve(args.repo) : findRepoRoot();
|
|
||||||
if (!repo) return { ok: false, reason: 'not_in_ruview_repo', hint: 'Run inside the RuView monorepo or pass {repo}.' };
|
|
||||||
const proof = join(repo, 'archive', 'v1', 'data', 'proof', 'verify.py');
|
|
||||||
if (!existsSync(proof)) return { ok: false, reason: 'proof_missing', path: proof };
|
|
||||||
const py = which('python') || which('python3');
|
|
||||||
if (!py) return { ok: false, reason: 'python_missing', hint: 'Install python to run the deterministic proof.' };
|
|
||||||
const r = await run(py, [proof], { cwd: repo, timeout: 180000 });
|
|
||||||
const verdict = /VERDICT:\s*PASS/i.test(r.stdout) ? 'PASS' : (/VERDICT:\s*FAIL/i.test(r.stdout) ? 'FAIL' : 'UNKNOWN');
|
|
||||||
return { ok: r.ok && verdict === 'PASS', verdict, exit: r.status, tail: r.stdout.slice(-1200), stderr: r.stderr.slice(-400) };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
ruview_node_monitor: {
|
|
||||||
title: 'Node monitor',
|
|
||||||
description: 'Open an ESP32 serial port and assert CSI is flowing (MGMT+DATA). Fail-closed if python+pyserial or the port is absent. Read-only.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
port: { type: 'string', description: 'Serial port, e.g. COM8 or /dev/ttyUSB0.' },
|
|
||||||
seconds: { type: 'number', description: 'Capture window (default 12).' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async handler(args = {}) {
|
|
||||||
const port = args.port;
|
|
||||||
if (!port || typeof port !== 'string') return { ok: false, reason: 'no_port', hint: 'Pass {port} (e.g. COM8).' };
|
|
||||||
const py = which('python') || which('python3');
|
|
||||||
if (!py) return { ok: false, reason: 'python_missing' };
|
|
||||||
const dur = Number(args.seconds) > 0 ? Number(args.seconds) : 12;
|
|
||||||
const r = await run(py, ['-c', MONITOR_SCRIPT, port, String(dur)], { timeout: (dur + 10) * 1000 });
|
|
||||||
if (r.stdout.includes('NO_PYSERIAL')) return { ok: false, reason: 'pyserial_missing', hint: 'pip install pyserial' };
|
|
||||||
if (!r.ok) return { ok: false, reason: 'port_error', stderr: r.stderr, error: r.error };
|
|
||||||
const csi = Number((r.stdout.match(/CSI=(\d+)/) || [])[1] || 0);
|
|
||||||
const upgraded = r.stdout.includes('UPGRADE_MGMT_DATA');
|
|
||||||
return { ok: csi > 0, csi_callbacks: csi, mgmt_data_upgrade: upgraded, raw: r.stdout.trim() };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
ruview_calibrate: {
|
|
||||||
title: 'Calibrate room',
|
|
||||||
description: 'Run the ADR-151 room pipeline via the wifi-densepose CLI (baseline→enroll→train-room). Fail-closed if the binary is absent.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
step: { type: 'string', enum: ['baseline', 'enroll', 'train-room', 'room-watch'], description: 'Which calibration step.' },
|
|
||||||
args: { type: 'array', items: { type: 'string' }, description: 'Extra CLI args passed through.' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async handler(args = {}) {
|
|
||||||
const step = args.step || 'baseline';
|
|
||||||
const bin = which('wifi-densepose');
|
|
||||||
const repo = findRepoRoot();
|
|
||||||
if (!bin && !repo) return { ok: false, reason: 'cli_missing', hint: 'Install the wifi-densepose CLI or run in the repo (cargo run -p wifi-densepose-cli).' };
|
|
||||||
const passthru = Array.isArray(args.args) ? args.args.map(String) : [];
|
|
||||||
// Prefer the installed binary; otherwise cargo-run from the repo.
|
|
||||||
const r = bin
|
|
||||||
? await run(bin, [step, ...passthru], { timeout: 300000 })
|
|
||||||
: await run('cargo', ['run', '-q', '-p', 'wifi-densepose-cli', '--', step, ...passthru], { cwd: repo, timeout: 600000 });
|
|
||||||
return { ok: r.ok, step, via: bin ? 'binary' : 'cargo', exit: r.status, tail: r.stdout.slice(-1500), stderr: r.stderr.slice(-500) };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
ruview_node_flash: {
|
|
||||||
title: 'Node flash',
|
|
||||||
description: 'Build+flash an ESP32 firmware variant. MUTATING + hardware. Fail-closed off-Windows or without ESP-IDF. Never claims hardware validation without a boot log.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
port: { type: 'string', description: 'Target port, e.g. COM8.' },
|
|
||||||
variant: { type: 'string', enum: ['s3-8mb', 's3-4mb', 'c6'], description: 'Firmware variant.' },
|
|
||||||
confirm: { type: 'boolean', description: 'Must be true to actually flash (guard).' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
handler(args = {}) {
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
return { ok: false, reason: 'unsupported_platform', detail: 'The ESP-IDF flash flow is Windows-subprocess-specific today (see CLAUDE.local.md).' };
|
|
||||||
}
|
|
||||||
if (!args.confirm) {
|
|
||||||
return { ok: false, reason: 'not_confirmed', detail: 'Mutating hardware op — re-call with {confirm:true}.', would_flash: { port: args.port, variant: args.variant || 's3-8mb' } };
|
|
||||||
}
|
|
||||||
return { ok: false, reason: 'manual_step_required', detail: 'Flashing uses the pinned ESP-IDF subprocess in CLAUDE.local.md. This tool returns the exact command rather than running an unattended flash.', see: 'skills/provision-node.md' };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Historical dotted names (pre-ADR-263) accepted as call-time aliases; the
|
|
||||||
// underscore form is what tools/list advertises.
|
|
||||||
export const TOOL_ALIASES = Object.fromEntries(
|
|
||||||
Object.keys(TOOLS).map((name) => [name.replace(/_/, '.'), name])
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Resolve a canonical or aliased tool name (or null). */
|
|
||||||
export function resolveToolName(name) {
|
|
||||||
if (TOOLS[name]) return name;
|
|
||||||
if (TOOL_ALIASES[name]) return TOOL_ALIASES[name];
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run one tool by name (canonical or dotted alias); always resolves to the structured result. */
|
|
||||||
export async function runTool(name, args) {
|
|
||||||
const canonical = resolveToolName(name);
|
|
||||||
if (!canonical) return { ok: false, reason: 'unknown_tool', name, available: Object.keys(TOOLS) };
|
|
||||||
try {
|
|
||||||
return await TOOLS[canonical].handler(args || {});
|
|
||||||
} catch (err) {
|
|
||||||
return { ok: false, reason: 'tool_threw', name: canonical, error: String(err && err.message || err) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** MCP-shaped tool list: [{name, description, inputSchema}]. */
|
|
||||||
export function listTools() {
|
|
||||||
return Object.entries(TOOLS).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema }));
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
|
||||||
// MCP stdio server e2e — spawns `bin/cli.js mcp start` and speaks JSON-RPC.
|
|
||||||
// Pins ADR-263 O2 (ping answered while a long tools/call runs), O6 (version
|
|
||||||
// from package.json), and O8 (underscore names advertised, dotted accepted,
|
|
||||||
// resources/prompts stubs).
|
|
||||||
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
||||||
import { join, dirname } from 'node:path';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { which } from '../src/tools.js';
|
|
||||||
|
|
||||||
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
||||||
const CLI = join(PKG_ROOT, 'bin', 'cli.js');
|
|
||||||
|
|
||||||
/** Start the MCP server; returns {send, next, close} where next(id) resolves the response with that id. */
|
|
||||||
function startServer() {
|
|
||||||
const child = spawn(process.execPath, [CLI, 'mcp', 'start'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
||||||
const waiters = new Map();
|
|
||||||
let buf = '';
|
|
||||||
child.stdout.on('data', (d) => {
|
|
||||||
buf += d;
|
|
||||||
let nl;
|
|
||||||
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
||||||
const line = buf.slice(0, nl).trim();
|
|
||||||
buf = buf.slice(nl + 1);
|
|
||||||
if (!line) continue;
|
|
||||||
const msg = JSON.parse(line);
|
|
||||||
const w = waiters.get(msg.id);
|
|
||||||
if (w) { waiters.delete(msg.id); w(msg); }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
send(msg) { child.stdin.write(JSON.stringify(msg) + '\n'); },
|
|
||||||
next(id) { return new Promise((res) => waiters.set(id, res)); },
|
|
||||||
close() { child.stdin.end(); child.kill(); },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('MCP handshake: initialize reports the package.json version; list endpoints respond', async () => {
|
|
||||||
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
|
|
||||||
const s = startServer();
|
|
||||||
try {
|
|
||||||
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
||||||
const init = await s.next(1);
|
|
||||||
assert.equal(init.result.serverInfo.version, pkg.version, 'ADR-263 O6: version must match package.json');
|
|
||||||
|
|
||||||
s.send({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
|
|
||||||
const tools = (await s.next(2)).result.tools;
|
|
||||||
assert.equal(tools.length, 6);
|
|
||||||
for (const t of tools) assert.match(t.name, /^[a-zA-Z0-9_-]{1,64}$/, `advertised name not host-safe: ${t.name}`);
|
|
||||||
|
|
||||||
s.send({ jsonrpc: '2.0', id: 3, method: 'resources/list' });
|
|
||||||
assert.deepEqual((await s.next(3)).result, { resources: [] });
|
|
||||||
s.send({ jsonrpc: '2.0', id: 4, method: 'prompts/list' });
|
|
||||||
assert.deepEqual((await s.next(4)).result, { prompts: [] });
|
|
||||||
|
|
||||||
// Dotted legacy name still callable (alias).
|
|
||||||
s.send({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'ruview.onboard', arguments: {} } });
|
|
||||||
const call = await s.next(5);
|
|
||||||
assert.equal(call.result.isError, false);
|
|
||||||
} finally {
|
|
||||||
s.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('MCP server answers ping while a long tools/call is in flight (ADR-263 O2)', { skip: !which('python') && !which('python3') ? 'python not on PATH' : false }, async () => {
|
|
||||||
// Fake RuView repo whose verify.py sleeps 3 s then passes.
|
|
||||||
const repo = mkdtempSync(join(tmpdir(), 'ruview-mcp-e2e-'));
|
|
||||||
const proofDir = join(repo, 'archive', 'v1', 'data', 'proof');
|
|
||||||
mkdirSync(proofDir, { recursive: true });
|
|
||||||
writeFileSync(join(proofDir, 'verify.py'), 'import time\ntime.sleep(3)\nprint("VERDICT: PASS")\n');
|
|
||||||
|
|
||||||
const s = startServer();
|
|
||||||
try {
|
|
||||||
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
||||||
await s.next(1);
|
|
||||||
|
|
||||||
const verifyDone = s.next(10);
|
|
||||||
s.send({ jsonrpc: '2.0', id: 10, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
|
|
||||||
|
|
||||||
// Give the server a beat to start the child, then ping.
|
|
||||||
await new Promise((r) => setTimeout(r, 300));
|
|
||||||
const t0 = Date.now();
|
|
||||||
const pinged = s.next(11);
|
|
||||||
s.send({ jsonrpc: '2.0', id: 11, method: 'ping' });
|
|
||||||
await pinged;
|
|
||||||
const pingMs = Date.now() - t0;
|
|
||||||
assert.ok(pingMs < 1000, `ping took ${pingMs} ms while verify was in flight — server is blocking`);
|
|
||||||
|
|
||||||
const verify = await verifyDone;
|
|
||||||
const payload = JSON.parse(verify.result.content[0].text);
|
|
||||||
assert.equal(payload.verdict, 'PASS');
|
|
||||||
} finally {
|
|
||||||
s.close();
|
|
||||||
rmSync(repo, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tools/call executions are serialized — two slow calls run sequentially', { skip: !which('python') && !which('python3') ? 'python not on PATH' : false }, async () => {
|
|
||||||
// Two verify.py that each sleep 0.8 s. Serialized ⇒ ~1.6 s+; concurrent ⇒ ~0.8 s.
|
|
||||||
const repo = mkdtempSync(join(tmpdir(), 'ruview-mcp-serial-'));
|
|
||||||
const proofDir = join(repo, 'archive', 'v1', 'data', 'proof');
|
|
||||||
mkdirSync(proofDir, { recursive: true });
|
|
||||||
writeFileSync(join(proofDir, 'verify.py'), 'import time\ntime.sleep(0.8)\nprint("VERDICT: PASS")\n');
|
|
||||||
|
|
||||||
const s = startServer();
|
|
||||||
try {
|
|
||||||
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
||||||
await s.next(1);
|
|
||||||
|
|
||||||
const t0 = Date.now();
|
|
||||||
const a = s.next(20);
|
|
||||||
const b = s.next(21);
|
|
||||||
s.send({ jsonrpc: '2.0', id: 20, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
|
|
||||||
s.send({ jsonrpc: '2.0', id: 21, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
|
|
||||||
const [ra, rb] = await Promise.all([a, b]);
|
|
||||||
const elapsed = Date.now() - t0;
|
|
||||||
|
|
||||||
assert.equal(JSON.parse(ra.result.content[0].text).verdict, 'PASS');
|
|
||||||
assert.equal(JSON.parse(rb.result.content[0].text).verdict, 'PASS');
|
|
||||||
assert.ok(elapsed > 1400, `two 0.8 s tool calls finished in ${elapsed} ms — they overlapped instead of serializing`);
|
|
||||||
} finally {
|
|
||||||
s.close();
|
|
||||||
rmSync(repo, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stdin close flushes an in-flight tools/call response before exit', async () => {
|
|
||||||
const child = spawn(process.execPath, [CLI, 'mcp', 'start'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
||||||
let out = '';
|
|
||||||
child.stdout.on('data', (d) => { out += d; });
|
|
||||||
const exited = new Promise((res) => child.on('exit', res));
|
|
||||||
|
|
||||||
// Write a tools/call then immediately close stdin. The old fire-and-forget
|
|
||||||
// dispatch raced rl 'close' → process.exit and could drop this response.
|
|
||||||
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 42, method: 'tools/call', params: { name: 'ruview_onboard', arguments: {} } }) + '\n');
|
|
||||||
child.stdin.end();
|
|
||||||
|
|
||||||
await exited;
|
|
||||||
const msgs = out.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
||||||
const resp = msgs.find((m) => m.id === 42);
|
|
||||||
assert.ok(resp, 'the in-flight tools/call response must be flushed to stdout before exit');
|
|
||||||
assert.equal(resp.result.isError, false);
|
|
||||||
});
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
|
||||||
// RuView harness tests — Node's built-in test runner (no devDeps to install).
|
|
||||||
// Run: `node --test test/*.test.mjs` (or `npm test`).
|
|
||||||
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readdirSync, readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
||||||
import { join, dirname, delimiter } from 'node:path';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { claimCheck, summarize } from '../src/guardrails.js';
|
|
||||||
import { TOOLS, TOOL_ALIASES, runTool, listTools, findRepoRoot, run, which } from '../src/tools.js';
|
|
||||||
import { run as cliRun } from '../bin/cli.js';
|
|
||||||
|
|
||||||
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
||||||
|
|
||||||
test('guardrail flags the retracted 100% framing as high severity', () => {
|
|
||||||
const r = claimCheck('Our model reaches 100% accuracy on every pose.');
|
|
||||||
assert.equal(r.ok, false);
|
|
||||||
assert.ok(r.findings.some((f) => f.severity === 'high'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('guardrail flags an untagged percentage accuracy claim', () => {
|
|
||||||
// "hit", not "measured" — "measured" would (correctly) route to the no-reproducer branch.
|
|
||||||
const r = claimCheck('We hit 92.9% PCK on the test set.');
|
|
||||||
assert.equal(r.ok, false);
|
|
||||||
assert.ok(r.findings.some((f) => /not tagged/i.test(f.reason)));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('guardrail passes a MEASURED claim that cites a reproducer', () => {
|
|
||||||
const r = claimCheck('Held-out PCK@20 59.5% vs 50% mean-pose baseline = +9.4pp (MEASURED, verify.py).');
|
|
||||||
assert.equal(r.ok, true, JSON.stringify(r.findings));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('guardrail flags MEASURED with no reproducer', () => {
|
|
||||||
const r = claimCheck('Presence detection 97% (MEASURED).');
|
|
||||||
assert.equal(r.ok, false);
|
|
||||||
assert.ok(r.findings.some((f) => /no reproducer/i.test(f.reason)));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('guardrail ignores non-metric prose', () => {
|
|
||||||
assert.equal(claimCheck('The ESP32 streams CSI over UDP to the sensing-server.').ok, true);
|
|
||||||
assert.equal(claimCheck('').ok, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ADR-263 F11/O9: precision pins — short metric tokens must not fire on prose.
|
|
||||||
test('guardrail does not false-positive on "map"/"F1" prose (ADR-263 F11)', () => {
|
|
||||||
assert.equal(claimCheck('F-numbers map to findings.').ok, true);
|
|
||||||
assert.equal(claimCheck('### F1 (HIGH, broken export): `require` points at a missing file').ok, true);
|
|
||||||
assert.equal(claimCheck('The 0.1.0 tarball ships 44 `.map` files = 62,698 B of dead weight.').ok, true);
|
|
||||||
assert.equal(claimCheck('the source maps can never resolve').ok, true);
|
|
||||||
assert.equal(claimCheck('- **O1 (F1):** fix `exports` (see F2 for the 33% map weight — MEASURED, tarball listing)').ok, true);
|
|
||||||
assert.equal(claimCheck('ADR-264: exports fix, map-free tarball, session-per-transport').ok, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('guardrail still catches real short-token metric claims', () => {
|
|
||||||
assert.equal(claimCheck('We reach mAP 62.3 on COCO.').ok, false);
|
|
||||||
assert.equal(claimCheck('F1 score of 0.91 on the held set.').ok, false, 'f1 with a real score must still fire');
|
|
||||||
assert.equal(claimCheck('IoU 0.75 across rooms.').ok, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Digits hidden in a code span still make a claim — scrubbing must not blind the
|
|
||||||
// number gate to `0.95` (regression: code-span number bypassed the gate).
|
|
||||||
test('guardrail flags an accuracy number stated inside a code span', () => {
|
|
||||||
const r = claimCheck('Count accuracy reached `0.95` in our tests.');
|
|
||||||
assert.equal(r.ok, false, JSON.stringify(r.findings));
|
|
||||||
assert.ok(r.findings.some((f) => /not tagged/i.test(f.reason)));
|
|
||||||
});
|
|
||||||
|
|
||||||
// A MEASURED claim whose only number hides in a code span must still reach the
|
|
||||||
// missing-reproducer check (regression: the scrubbed gate short-circuited it).
|
|
||||||
// Bare metric prose with no number at all (e.g. the README rule text) stays a pass.
|
|
||||||
test('guardrail flags a MEASURED code-span number with no reproducer', () => {
|
|
||||||
const r = claimCheck('Detection accuracy `0.97` on the set (MEASURED).');
|
|
||||||
assert.equal(r.ok, false, JSON.stringify(r.findings));
|
|
||||||
assert.ok(r.findings.some((f) => /no reproducer/i.test(f.reason)));
|
|
||||||
assert.equal(claimCheck('Every accuracy number must be MEASURED against a baseline.').ok, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// F1-score phrasings ("F1: 0.91", "F1 reaches 0.91") were scrubbed as option
|
|
||||||
// labels and slipped through; option refs alone must still not false-positive.
|
|
||||||
test('guardrail catches F1-score claims but not bare option refs (ADR-263 F11)', () => {
|
|
||||||
assert.equal(claimCheck('F1: 0.91 on the held-out set.').ok, false, 'F1: value is a metric claim');
|
|
||||||
assert.equal(claimCheck('F1 reaches 0.91 on the held-out set.').ok, false, 'F1 with a nearby number is a claim');
|
|
||||||
assert.equal(claimCheck('Options O1–O9 are tracked in ADR-263 O2.').ok, true, 'option labels are not metrics');
|
|
||||||
assert.equal(claimCheck('ADR-263 O2 lands the exports fix.').ok, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('summarize gives PASS/finding text', () => {
|
|
||||||
assert.match(summarize(claimCheck('nothing here')), /PASS/);
|
|
||||||
assert.match(summarize(claimCheck('100% accuracy')), /finding/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registry exposes the documented tools with schemas (underscore-canonical)', () => {
|
|
||||||
const names = Object.keys(TOOLS);
|
|
||||||
for (const n of ['ruview_onboard', 'ruview_claim_check', 'ruview_verify', 'ruview_node_monitor', 'ruview_calibrate', 'ruview_node_flash']) {
|
|
||||||
assert.ok(names.includes(n), `missing ${n}`);
|
|
||||||
assert.equal(TOOLS[n].inputSchema.type, 'object');
|
|
||||||
assert.match(n, /^[a-zA-Z0-9_-]{1,64}$/, 'canonical names must satisfy host tool-name regexes');
|
|
||||||
}
|
|
||||||
assert.equal(listTools().length, names.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dotted legacy names resolve via aliases (ADR-263 O8)', async () => {
|
|
||||||
assert.equal(TOOL_ALIASES['ruview.claim_check'], 'ruview_claim_check');
|
|
||||||
assert.equal(TOOL_ALIASES['ruview.node_monitor'], 'ruview_node_monitor');
|
|
||||||
const r = await runTool('ruview.onboard', {});
|
|
||||||
assert.equal(r.ok, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ruview_onboard returns paths and a recommendation', async () => {
|
|
||||||
const r = await runTool('ruview_onboard', {});
|
|
||||||
assert.equal(r.ok, true);
|
|
||||||
assert.ok(r.paths['live-esp32']);
|
|
||||||
assert.ok(['repo-build', 'docker-demo'].includes(r.recommend));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ruview_claim_check tool wraps the guardrail', async () => {
|
|
||||||
const r = await runTool('ruview_claim_check', { text: '100% accuracy' });
|
|
||||||
assert.equal(r.ok, false);
|
|
||||||
assert.match(r.summary, /honesty|tag|MEASURED|finding/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ADR-263 F1/O1: the honesty gate must fail closed on empty input.
|
|
||||||
test('ruview_claim_check fails closed on empty/missing text', async () => {
|
|
||||||
const empty = await runTool('ruview_claim_check', { text: '' });
|
|
||||||
assert.equal(empty.ok, false);
|
|
||||||
assert.equal(empty.reason, 'empty_text');
|
|
||||||
const missing = await runTool('ruview_claim_check', {});
|
|
||||||
assert.equal(missing.ok, false);
|
|
||||||
assert.equal(missing.reason, 'empty_text');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown tool fails closed', async () => {
|
|
||||||
const r = await runTool('ruview_does_not_exist', {});
|
|
||||||
assert.equal(r.ok, false);
|
|
||||||
assert.equal(r.reason, 'unknown_tool');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('node_monitor fails closed without a port', async () => {
|
|
||||||
const r = await runTool('ruview_node_monitor', {});
|
|
||||||
assert.equal(r.ok, false);
|
|
||||||
assert.equal(r.reason, 'no_port');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('node_flash refuses without confirm (mutating guard)', async () => {
|
|
||||||
const r = await runTool('ruview_node_flash', { port: 'COM8', variant: 's3-8mb' });
|
|
||||||
assert.equal(r.ok, false);
|
|
||||||
// either not-confirmed (win32) or unsupported_platform (posix) — both fail-closed
|
|
||||||
assert.ok(['not_confirmed', 'unsupported_platform'].includes(r.reason));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('verify fails closed when not in a RuView repo', async () => {
|
|
||||||
// point at a tmp dir with no repo markers
|
|
||||||
const r = await runTool('ruview_verify', { repo: process.platform === 'win32' ? 'C:/Windows/Temp' : '/tmp' });
|
|
||||||
assert.equal(r.ok, false);
|
|
||||||
assert.ok(['proof_missing', 'python_missing'].includes(r.reason), r.reason);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ADR-263 F2/O2: registry-level concurrency — a slow child must not block
|
|
||||||
// other tool calls (run() is promise-based, never spawnSync).
|
|
||||||
test('run() is non-blocking: a fast tool completes while a slow child runs', async () => {
|
|
||||||
const slow = run('node', ['-e', 'setTimeout(() => {}, 2000)'], { timeout: 5000 });
|
|
||||||
const t0 = Date.now();
|
|
||||||
const fast = await runTool('ruview_onboard', {});
|
|
||||||
const elapsed = Date.now() - t0;
|
|
||||||
assert.equal(fast.ok, true);
|
|
||||||
assert.ok(elapsed < 1000, `onboard took ${elapsed} ms while a 2 s child was running`);
|
|
||||||
const r = await slow;
|
|
||||||
assert.equal(r.ok, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('run() reports a timeout as a failure, not a hang', async () => {
|
|
||||||
const r = await run('node', ['-e', 'setTimeout(() => {}, 10000)'], { timeout: 300 });
|
|
||||||
assert.equal(r.ok, false);
|
|
||||||
assert.match(String(r.error), /timed out/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('run() bounds captured output instead of dying on big streams (ADR-263 O4)', async () => {
|
|
||||||
// 4 MiB of stdout would have hit spawnSync's 1 MiB default maxBuffer (ENOBUFS).
|
|
||||||
const r = await run('node', ['-e', "process.stdout.write('x'.repeat(4 * 1024 * 1024)); console.log('TAIL_MARKER')"], { timeout: 30000 });
|
|
||||||
assert.equal(r.ok, true);
|
|
||||||
assert.ok(r.stdout.length <= 65536, `tail not bounded: ${r.stdout.length}`);
|
|
||||||
assert.ok(r.stdout.includes('TAIL_MARKER'), 'tail must keep the end of the stream');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('which() finds node and re-probes misses (hits are cached)', () => {
|
|
||||||
assert.ok(which('node'), 'node must be on PATH in the test env');
|
|
||||||
assert.equal(which('definitely-not-a-binary-xyz'), null);
|
|
||||||
assert.equal(which('definitely-not-a-binary-xyz'), null); // re-probed, still absent
|
|
||||||
});
|
|
||||||
|
|
||||||
// ADR-263 O8: a miss must not be cached — an operator who installs a tool
|
|
||||||
// mid-session (e.g. python after a python_missing failure) must be found next call.
|
|
||||||
test('which() re-probes after a miss so a newly-installed tool is found', () => {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'ruview-which-'));
|
|
||||||
const name = 'ruview-probe-xyz';
|
|
||||||
const isWin = process.platform === 'win32';
|
|
||||||
const bin = join(dir, isWin ? `${name}.cmd` : name);
|
|
||||||
const prevPath = process.env.PATH;
|
|
||||||
try {
|
|
||||||
assert.equal(which(name), null, 'not on PATH yet → miss');
|
|
||||||
writeFileSync(bin, isWin ? '@echo off\n' : '#!/bin/sh\n', { mode: 0o755 });
|
|
||||||
process.env.PATH = dir + delimiter + prevPath;
|
|
||||||
assert.ok(which(name), 'installed mid-session → the miss must not have been cached');
|
|
||||||
} finally {
|
|
||||||
process.env.PATH = prevPath;
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('CLI run(): claim-check exits non-zero on a bad claim', async () => {
|
|
||||||
const code = await cliRun(['claim-check', '--text', '100% accuracy']);
|
|
||||||
assert.notEqual(code, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ADR-263 F1/O1: the CLI must not PASS silently with no input.
|
|
||||||
test('CLI run(): claim-check with no input exits 2 (fail-closed)', async () => {
|
|
||||||
assert.equal(await cliRun(['claim-check']), 2);
|
|
||||||
assert.equal(await cliRun(['claim-check', '--text', ' ']), 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('CLI run(): doctor exits 0 (tools-only path)', async () => {
|
|
||||||
const code = await cliRun(['doctor']);
|
|
||||||
assert.equal(code, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('CLI run(): unknown command exits non-zero', async () => {
|
|
||||||
assert.notEqual(await cliRun(['definitely-not-a-command']), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('findRepoRoot locates this monorepo from cwd', () => {
|
|
||||||
// when run from within wifi-densepose, it should find a root; elsewhere null is fine
|
|
||||||
const root = findRepoRoot();
|
|
||||||
assert.ok(root === null || typeof root === 'string');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ADR-263 F7/O7: skills ship from one source; the projected copies must match.
|
|
||||||
test('.claude/skills/*/SKILL.md are byte-identical to skills/*.md', () => {
|
|
||||||
const srcDir = join(PKG_ROOT, 'skills');
|
|
||||||
for (const f of readdirSync(srcDir).filter((f) => f.endsWith('.md'))) {
|
|
||||||
const name = f.replace(/\.md$/, '');
|
|
||||||
const src = readFileSync(join(srcDir, f), 'utf8');
|
|
||||||
const projected = readFileSync(join(PKG_ROOT, '.claude', 'skills', name, 'SKILL.md'), 'utf8');
|
|
||||||
assert.equal(projected, src, `skill drift: ${name} — run \`npm run sync-skills\``);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ADR-263 F6/O6 + F3/O3: package hygiene pins.
|
|
||||||
test('package.json has no optionalDependencies and no hardcoded server version drift', () => {
|
|
||||||
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
|
|
||||||
assert.equal(pkg.optionalDependencies, undefined, 'ADR-263 O3: optional deps tripled the cold npx install');
|
|
||||||
assert.equal(pkg.dependencies, undefined, 'the harness is dependency-free by design');
|
|
||||||
const mcpSrc = readFileSync(join(PKG_ROOT, 'src', 'mcp-server.js'), 'utf8');
|
|
||||||
assert.ok(!/version:\s*'\d+\.\d+\.\d+'/.test(mcpSrc), 'ADR-263 O6: server version must come from package.json');
|
|
||||||
});
|
|
||||||
Binary file not shown.
BIN
Binary file not shown.
@@ -184,9 +184,7 @@ function loadGroundTruth(filePath) {
|
|||||||
const raw = loadJsonl(filePath);
|
const raw = loadJsonl(filePath);
|
||||||
const frames = [];
|
const frames = [];
|
||||||
for (const r of raw) {
|
for (const r of raw) {
|
||||||
// Skip non-detection frames (empty keypoints []) — they must not dilute window
|
if (r.ts_ns == null || !r.keypoints) continue;
|
||||||
// confidence; confidence stats are over actual detections only (#1007 Bug 2).
|
|
||||||
if (r.ts_ns == null || !r.keypoints || r.keypoints.length === 0) continue;
|
|
||||||
frames.push({
|
frames.push({
|
||||||
tsMs: cameraTsToMs(r.ts_ns),
|
tsMs: cameraTsToMs(r.ts_ns),
|
||||||
keypoints: r.keypoints,
|
keypoints: r.keypoints,
|
||||||
@@ -268,29 +266,7 @@ function loadCsi(filePath) {
|
|||||||
// Sort by timestamp
|
// Sort by timestamp
|
||||||
rawCsi.sort((a, b) => a.tsMs - b.tsMs);
|
rawCsi.sort((a, b) => a.tsMs - b.tsMs);
|
||||||
features.sort((a, b) => a.tsMs - b.tsMs);
|
features.sort((a, b) => a.tsMs - b.tsMs);
|
||||||
|
return { rawCsi, features };
|
||||||
// Bug 3 (#1007): keep only frames at the session's MODAL subcarrier count so windows
|
|
||||||
// are homogeneous; never silently zero-pad/truncate the off-format frames the ESP32
|
|
||||||
// emits (HT20/HT40/fragments). extractCsiMatrix then sees uniform-width frames.
|
|
||||||
return { rawCsi: filterToModalSubcarriers(rawCsi), features };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep only frames whose subcarrier count equals the session's modal (most common)
|
|
||||||
* count. Off-format frames are dropped (logged), not padded — prevents the silent
|
|
||||||
* zero-padding that corrupted windows in #1007.
|
|
||||||
*/
|
|
||||||
function filterToModalSubcarriers(frames) {
|
|
||||||
if (frames.length === 0) return frames;
|
|
||||||
const counts = new Map();
|
|
||||||
for (const f of frames) counts.set(f.subcarriers, (counts.get(f.subcarriers) || 0) + 1);
|
|
||||||
let modal = frames[0].subcarriers, best = 0;
|
|
||||||
for (const [sc, n] of counts) if (n > best) { best = n; modal = sc; }
|
|
||||||
const kept = frames.filter((f) => f.subcarriers === modal);
|
|
||||||
if (kept.length !== frames.length) {
|
|
||||||
console.error(`[align] #1007: kept ${kept.length}/${frames.length} CSI frames at modal subcarrier count ${modal} (dropped ${frames.length - kept.length} off-format; no silent padding)`);
|
|
||||||
}
|
|
||||||
return kept;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -367,8 +343,7 @@ function averageKeypoints(cameraFrames) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract CSI amplitude matrix from raw_csi window.
|
* Extract CSI amplitude matrix from raw_csi window.
|
||||||
* Fill is frame-major (matrix[f*nSc + s]), so shape is [windowFrames, subcarriers]
|
* Returns { data: flat Float32Array, shape: [subcarriers, windowFrames] }.
|
||||||
* (#1007 Bug 4 — was mislabeled [subcarriers, windowFrames], transposing consumers).
|
|
||||||
*/
|
*/
|
||||||
function extractCsiMatrix(window) {
|
function extractCsiMatrix(window) {
|
||||||
const nFrames = window.length;
|
const nFrames = window.length;
|
||||||
@@ -388,13 +363,12 @@ function extractCsiMatrix(window) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { data: Array.from(matrix), shape: [nFrames, nSc] };
|
return { data: Array.from(matrix), shape: [nSc, nFrames] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract feature matrix from feature-type window.
|
* Extract feature matrix from feature-type window.
|
||||||
* Fill is frame-major (matrix[f*dim + d]), so shape is [windowFrames, featureDim]
|
* Returns { data: flat array, shape: [featureDim, windowFrames] }.
|
||||||
* (#1007 Bug 4 — was mislabeled [featureDim, windowFrames]).
|
|
||||||
*/
|
*/
|
||||||
function extractFeatureMatrix(window) {
|
function extractFeatureMatrix(window) {
|
||||||
const nFrames = window.length;
|
const nFrames = window.length;
|
||||||
@@ -408,7 +382,7 @@ function extractFeatureMatrix(window) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { data: Array.from(matrix), shape: [nFrames, dim] };
|
return { data: Array.from(matrix), shape: [dim, nFrames] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# firmware-release-guard.sh — guard against shipping firmware built from a
|
|
||||||
# stale generated `sdkconfig` (the v0.8.3-esp32 release bug).
|
|
||||||
#
|
|
||||||
# Symptom it catches: an incremental build reuses a leftover `sdkconfig`
|
|
||||||
# instead of `sdkconfig.defaults`, so an "8MB" build silently links the 4MB
|
|
||||||
# dual-OTA partition layout (no spiffs, ota_1 @ 0x1F0000) and the released
|
|
||||||
# `partition-table.bin` does not match the flash-size variant it claims to be.
|
|
||||||
#
|
|
||||||
# What it does: for the named flash-size variant, regenerate the EXPECTED
|
|
||||||
# partition table from the partition CSV that variant must use, and byte-compare
|
|
||||||
# it against the freshly built `partition-table.bin`. Also cross-checks the
|
|
||||||
# flash size recorded in the build's `flasher_args.json`. Exits non-zero on any
|
|
||||||
# mismatch so a release pipeline fails closed.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# scripts/firmware-release-guard.sh <8mb|4mb> <build-dir>
|
|
||||||
#
|
|
||||||
# Example:
|
|
||||||
# scripts/firmware-release-guard.sh 8mb firmware/esp32-csi-node/build
|
|
||||||
#
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
VARIANT="${1:-}"
|
|
||||||
BUILD_DIR="${2:-}"
|
|
||||||
|
|
||||||
if [[ -z "$VARIANT" || -z "$BUILD_DIR" ]]; then
|
|
||||||
echo "usage: $0 <8mb|4mb> <build-dir>" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Firmware project root (this script lives in <repo>/scripts).
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
FW_DIR="$SCRIPT_DIR/../firmware/esp32-csi-node"
|
|
||||||
|
|
||||||
case "$VARIANT" in
|
|
||||||
8mb) EXPECT_CSV="partitions_display.csv"; EXPECT_FLASH="8MB" ;;
|
|
||||||
4mb) EXPECT_CSV="partitions_4mb.csv"; EXPECT_FLASH="4MB" ;;
|
|
||||||
*) echo "ERROR: unknown variant '$VARIANT' (want 8mb|4mb)" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
BUILT_PT="$BUILD_DIR/partition_table/partition-table.bin"
|
|
||||||
CSV_PATH="$FW_DIR/$EXPECT_CSV"
|
|
||||||
|
|
||||||
[[ -f "$BUILT_PT" ]] || { echo "ERROR: built partition table not found: $BUILT_PT" >&2; exit 1; }
|
|
||||||
[[ -f "$CSV_PATH" ]] || { echo "ERROR: expected CSV not found: $CSV_PATH" >&2; exit 1; }
|
|
||||||
|
|
||||||
# Locate the ESP-IDF partition table generator.
|
|
||||||
GEN="${IDF_PATH:-}/components/partition_table/gen_esp32part.py"
|
|
||||||
if [[ ! -f "$GEN" ]]; then
|
|
||||||
GEN="C:/Users/ruv/esp/v5.4/esp-idf/components/partition_table/gen_esp32part.py"
|
|
||||||
fi
|
|
||||||
[[ -f "$GEN" ]] || { echo "ERROR: gen_esp32part.py not found (set IDF_PATH)" >&2; exit 1; }
|
|
||||||
|
|
||||||
PY="${PYTHON:-python}"
|
|
||||||
command -v "$PY" >/dev/null 2>&1 || PY="C:/Espressif/tools/python/v5.4/venv/Scripts/python.exe"
|
|
||||||
|
|
||||||
TMP="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
|
||||||
EXPECT_PT="$TMP/expected-partition-table.bin"
|
|
||||||
|
|
||||||
# Regenerate the expected table from the CSV this variant must use.
|
|
||||||
"$PY" "$GEN" --quiet "$CSV_PATH" "$EXPECT_PT"
|
|
||||||
|
|
||||||
fail=0
|
|
||||||
|
|
||||||
if ! cmp -s "$EXPECT_PT" "$BUILT_PT"; then
|
|
||||||
echo "FAIL: built partition table does not match $EXPECT_CSV for the $VARIANT variant." >&2
|
|
||||||
echo " The build likely reused a stale sdkconfig. Decoded built table:" >&2
|
|
||||||
"$PY" "$GEN" "$BUILT_PT" 2>/dev/null | grep -vE '^#|^Parsing|^Verifying' | sed 's/^/ /' >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Cross-check the flash size the build actually targeted.
|
|
||||||
FA="$BUILD_DIR/flasher_args.json"
|
|
||||||
if [[ -f "$FA" ]]; then
|
|
||||||
GOT_FLASH="$("$PY" - "$FA" <<'PYEOF'
|
|
||||||
import json,sys
|
|
||||||
with open(sys.argv[1]) as f: d=json.load(f)
|
|
||||||
print(d.get("flash_settings",{}).get("flash_size",""))
|
|
||||||
PYEOF
|
|
||||||
)"
|
|
||||||
if [[ "$GOT_FLASH" != "$EXPECT_FLASH" ]]; then
|
|
||||||
echo "FAIL: flasher_args.json flash_size='$GOT_FLASH', expected '$EXPECT_FLASH'." >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$fail" -ne 0 ]]; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "OK: $VARIANT firmware build matches $EXPECT_CSV (flash_size=$EXPECT_FLASH)."
|
|
||||||
@@ -15,7 +15,6 @@ import os
|
|||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
|
|
||||||
def parse_csi_packet(data):
|
def parse_csi_packet(data):
|
||||||
@@ -42,8 +41,7 @@ def parse_csi_packet(data):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"type": "raw_csi",
|
"type": "raw_csi",
|
||||||
# true UTC, not local-time-labeled-Z (#1007 Bug 1) — e.g. "2026-06-17T01:23:45.678Z"
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(time.time() * 1000) % 1000:03d}Z",
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
|
||||||
"ts_ns": time.time_ns(),
|
"ts_ns": time.time_ns(),
|
||||||
"node_id": node_id,
|
"node_id": node_id,
|
||||||
"rssi": rssi,
|
"rssi": rssi,
|
||||||
|
|||||||
Generated
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"yargs": "^17.7.2"
|
"yargs": "^17.7.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"ruview-cli": "dist/index.js"
|
"ruview": "dist/index.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.14.0",
|
"@types/node": "^20.14.0",
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@ruv/ruview-cli",
|
"name": "@ruv/ruview-cli",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "RuView CLI — shell access to WiFi-DensePose sensing, inference, and training capabilities. Private/unpublished; the `ruview` bin name belongs to @ruvnet/ruview (ADR-265 D4).",
|
"description": "RuView CLI — shell access to WiFi-DensePose sensing, inference, and training capabilities",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
"ruview-cli": "dist/index.js"
|
"ruview": "dist/index.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "tsc --watch",
|
"dev": "tsc --watch",
|
||||||
"test": "node --experimental-vm-modules node_modules/.bin/jest --passWithNoTests",
|
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
||||||
"lint": "eslint src --ext .ts",
|
"lint": "eslint src --ext .ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
* See ADR-104 for the full design rationale and security model.
|
* See ADR-104 for the full design rationale and security model.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createRequire } from "node:module";
|
|
||||||
import yargs from "yargs";
|
import yargs from "yargs";
|
||||||
import { hideBin } from "yargs/helpers";
|
import { hideBin } from "yargs/helpers";
|
||||||
import { csiCommand } from "./commands/csi.js";
|
import { csiCommand } from "./commands/csi.js";
|
||||||
@@ -35,15 +34,9 @@ import { cogsCommand } from "./commands/cogs.js";
|
|||||||
import { trainCommand } from "./commands/train.js";
|
import { trainCommand } from "./commands/train.js";
|
||||||
import { jobCommand } from "./commands/job.js";
|
import { jobCommand } from "./commands/job.js";
|
||||||
|
|
||||||
// Single-source the version from package.json (ADR-265 D3).
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const VERSION: string = (require("../package.json") as { version: string }).version;
|
|
||||||
|
|
||||||
// Bin name is `ruview-cli`: the bare `ruview` bin belongs to @ruvnet/ruview
|
|
||||||
// (ADR-264 O9 / ADR-265 D4).
|
|
||||||
const cli = yargs(hideBin(process.argv))
|
const cli = yargs(hideBin(process.argv))
|
||||||
.scriptName("ruview-cli")
|
.scriptName("ruview")
|
||||||
.version(VERSION)
|
.version("0.0.1")
|
||||||
.usage("$0 <command> [options]")
|
.usage("$0 <command> [options]")
|
||||||
.strict()
|
.strict()
|
||||||
.help()
|
.help()
|
||||||
|
|||||||
+23
-34
@@ -2,63 +2,52 @@
|
|||||||
|
|
||||||
**SENSE-BRIDGE** is a dual-transport [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that bridges the RuView WiFi-DensePose sensing stack to AI agents (Claude Code, Cursor, ruflo swarms, and any MCP-compatible client).
|
**SENSE-BRIDGE** is a dual-transport [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that bridges the RuView WiFi-DensePose sensing stack to AI agents (Claude Code, Cursor, ruflo swarms, and any MCP-compatible client).
|
||||||
|
|
||||||
Install once; AI agents can then call `ruview_presence_now`, `ruview_vitals_get_heart_rate`, `ruview_bfld_last_scan`, and more — without writing HTTP or WebSocket client code.
|
Install once; AI agents can then call `ruview.presence.now`, `ruview.vitals.get_heart_rate`, `ruview.bfld.last_scan`, and more — without writing HTTP or WebSocket client code.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Add to Claude Code (stdio transport — the default)
|
# 1. Add to Claude Code
|
||||||
claude mcp add rvagent -- npx -y @ruvnet/rvagent
|
claude mcp add rvagent -- npx @ruvnet/rvagent stdio
|
||||||
|
|
||||||
# 2. Or run directly
|
# 2. Or run directly
|
||||||
RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 npx @ruvnet/rvagent
|
RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 npx @ruvnet/rvagent stdio
|
||||||
|
|
||||||
# 3. Streamable HTTP (remote agents, ruflo swarms) — explicit opt-in
|
# 3. Streamable HTTP (remote agents, ruflo swarms)
|
||||||
RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 \
|
RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 \
|
||||||
RVAGENT_HTTP_TOKEN=your-secret \
|
RVAGENT_HTTP_TOKEN=your-secret \
|
||||||
RVAGENT_HTTP_PORT=3001 npx @ruvnet/rvagent
|
npx @ruvnet/rvagent http --port 3001
|
||||||
# POST JSON-RPC to http://127.0.0.1:3001/mcp (initialize first; then send the
|
# POST JSON-RPC to http://127.0.0.1:3001/mcp
|
||||||
# returned mcp-session-id header on every request)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Requirements: **Node.js >= 20**. The `wifi-densepose-sensing-server` Rust binary must be reachable at `RUVIEW_SENSING_SERVER_URL` (default `http://localhost:3000`).
|
Requirements: **Node.js >= 20**. The `wifi-densepose-sensing-server` Rust binary must be reachable at `RUVIEW_SENSING_SERVER_URL` (default `http://localhost:3000`).
|
||||||
|
|
||||||
## Tools
|
## Feature matrix
|
||||||
|
|
||||||
Canonical tool names are underscore-form (ADR-264 — host tool-name validators
|
|
||||||
commonly enforce `^[a-zA-Z0-9_-]{1,64}$`). The pre-0.1.1 dotted names
|
|
||||||
(`ruview.presence.now`, …) are still accepted at call time as deprecated
|
|
||||||
aliases; `tools/list` advertises the underscore form only.
|
|
||||||
|
|
||||||
| Tool | Description | ADR |
|
| Tool | Description | ADR |
|
||||||
|------|-------------|-----|
|
|------|-------------|-----|
|
||||||
| `ruview_csi_latest` | Latest 56×20 CSI window from the sensing-server | ADR-101/102 |
|
| `ruview.presence.now` | Current occupancy: `present`, `n_persons`, `confidence` | ADR-124 §4.1 |
|
||||||
| `ruview_pose_infer` | Single-shot 17-keypoint pose inference via cog binary | ADR-101 |
|
| `ruview.vitals.get_breathing` | Breathing rate bpm (null if unavailable) | ADR-124 §4.1 |
|
||||||
| `ruview_count_infer` | Single-shot person-count inference via cog binary | ADR-103 |
|
| `ruview.vitals.get_heart_rate` | Heart rate bpm (null if unavailable) | ADR-124 §4.1 |
|
||||||
| `ruview_registry_list` | Cognitum edge module registry (category/search filters) | ADR-102 |
|
| `ruview.vitals.get_all` | Full `EdgeVitalsMessage` surface | ADR-124 §4.1 |
|
||||||
| `ruview_train_count` | Kick off a count-cog training run (background job) | ADR-103 |
|
| `ruview.bfld.last_scan` | Latest BFLD scan: `identity_risk_score`, `privacy_class`, `n_frames` | ADR-118/124 |
|
||||||
| `ruview_job_status` | Poll a training job (persists across server restarts) | ADR-103 |
|
| `ruview.bfld.subscribe` | Subscribe to `ruview/<node_id>/bfld/*` events for `duration_s` seconds | ADR-122/124 |
|
||||||
| `ruview_presence_now` | Current occupancy: `present`, `n_persons`, `confidence` | ADR-124 §4.1 |
|
| *(next iters)* | `pose.latest`, `primitives.*`, `node.*`, `vector.*`, `policy.*` | ADR-124 §4.1/4.1a |
|
||||||
| `ruview_vitals_get_breathing` | Breathing rate bpm (null if unavailable) | ADR-124 §4.1 |
|
|
||||||
| `ruview_vitals_get_heart_rate` | Heart rate bpm (null if unavailable) | ADR-124 §4.1 |
|
|
||||||
| `ruview_vitals_get_all` | Full `EdgeVitalsMessage` surface | ADR-124 §4.1 |
|
|
||||||
| `ruview_bfld_last_scan` | Latest BFLD scan: `identity_risk_score`, `privacy_class`, `n_frames` | ADR-118/124 |
|
|
||||||
| `ruview_bfld_subscribe` | Subscribe to `ruview/<node_id>/bfld/*` events for `duration_s` seconds | ADR-122/124 |
|
|
||||||
| *(roadmap, ADR-124 §4.1/4.1a)* | `pose.latest`, `primitives.*`, `node.*`, `vector.*`, and the `policy.*` governance layer are catalogued in `src/schemas/` but **not yet implemented** | ADR-124 |
|
|
||||||
|
|
||||||
**Transport security (ADR-124 §6, hardened per ADR-264)**:
|
**Transport security (ADR-124 §6)**:
|
||||||
- **stdio** (default): process-level isolation — no auth needed for local Claude Code / Cursor.
|
- **stdio**: process-level isolation — no auth needed for local Claude Code / Cursor.
|
||||||
- **Streamable HTTP** (`/mcp`, opt-in via `RVAGENT_HTTP_PORT`): one transport + one MCP server per session (routed by `mcp-session-id`), Origin validation (localhost on any port allowed; anything else → 403), optional bearer token (`RVAGENT_HTTP_TOKEN` → 401 on mismatch), 1 MiB request-body cap (413), binds `127.0.0.1` by default per MCP spec.
|
- **Streamable HTTP** (`POST /mcp`): Origin header validation (cross-origin → 403), optional bearer token (`RVAGENT_HTTP_TOKEN` → 401 on mismatch), binds `127.0.0.1` by default per MCP spec.
|
||||||
|
|
||||||
**Schema validation**: each tool declares one Zod schema; the CallTool gate parses exactly once and the advertised JSON Schema is generated from the same Zod source. Invalid arguments return `McpError(InvalidParams)` rather than a wrapped string.
|
**Schema validation**: every tool call runs `zod.safeParse` before dispatch; invalid arguments return `McpError(InvalidParams)` rather than a wrapped string.
|
||||||
|
|
||||||
|
**Policy layer** (ADR-124 §4.1a): `ruview.policy.*` tools gate every sensing call — `vitals.*` is default-deny until a policy grant is registered via `npx @ruvnet/rvagent policy grant`. Presence and node-list are allow by default.
|
||||||
|
|
||||||
## ADR cross-reference
|
## ADR cross-reference
|
||||||
|
|
||||||
| ADR | Decision |
|
| ADR | Decision |
|
||||||
|-----|----------|
|
|-----|----------|
|
||||||
| [ADR-124](../../docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md) | SENSE-BRIDGE: dual-transport MCP server + ruvector npm + ruflo integration |
|
| [ADR-124](../../docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md) | SENSE-BRIDGE: dual-transport MCP server + ruvector npm + ruflo integration |
|
||||||
| [ADR-264](../../docs/adr/ADR-264-rvagent-mcp-and-cli-npm-deep-review.md) | npm deep review — exports fix, map-free tarball, naming, session-per-transport |
|
| [ADR-118](../../docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md) | BFLD pipeline — source of `bfld.last_scan` wire format |
|
||||||
| [ADR-118](../../docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md) | BFLD pipeline — source of `bfld_last_scan` wire format |
|
|
||||||
| [ADR-122](../../docs/adr/ADR-122-bfld-ruview-ha-matter-exposure.md) | MQTT topic routing `ruview/<node_id>/bfld/*` |
|
| [ADR-122](../../docs/adr/ADR-122-bfld-ruview-ha-matter-exposure.md) | MQTT topic routing `ruview/<node_id>/bfld/*` |
|
||||||
| [ADR-115](../../docs/adr/ADR-115-home-assistant-integration.md) | `EdgeVitalsMessage` WebSocket surface (`ws.py:74-88` parity) |
|
| [ADR-115](../../docs/adr/ADR-115-home-assistant-integration.md) | `EdgeVitalsMessage` WebSocket surface (`ws.py:74-88` parity) |
|
||||||
| [ADR-055](../../docs/adr/ADR-055-integrated-sensing-server.md) | Sensing-server REST API (`/api/v1/*`) |
|
| [ADR-055](../../docs/adr/ADR-055-integrated-sensing-server.md) | Sensing-server REST API (`/api/v1/*`) |
|
||||||
@@ -69,7 +58,7 @@ aliases; `tools/list` advertises the underscore form only.
|
|||||||
cd tools/ruview-mcp
|
cd tools/ruview-mcp
|
||||||
npm install
|
npm install
|
||||||
npm run build # tsc
|
npm run build # tsc
|
||||||
npm test # jest — 99 tests across 7 suites
|
npm test # jest — 93 tests across 7 suites
|
||||||
```
|
```
|
||||||
|
|
||||||
Source: `tools/ruview-mcp/src/`. Tests: `tools/ruview-mcp/tests/`.
|
Source: `tools/ruview-mcp/src/`. Tests: `tools/ruview-mcp/tests/`.
|
||||||
|
|||||||
Generated
+373
-10
@@ -1,24 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "@ruvnet/rvagent",
|
"name": "@ruvnet/rvagent",
|
||||||
"version": "0.2.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@ruvnet/rvagent",
|
"name": "@ruvnet/rvagent",
|
||||||
"version": "0.2.0",
|
"version": "0.1.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8"
|
||||||
"zod-to-json-schema": "^3.25.2"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"ruview-mcp": "dist/index.js",
|
"ruview-mcp": "dist/index.js",
|
||||||
"rvagent": "dist/index.js"
|
"rvagent": "dist/index.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^20.14.0",
|
"@types/node": "^20.14.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"ts-jest": "^29.1.0",
|
"ts-jest": "^29.1.0",
|
||||||
@@ -629,6 +629,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@jest/diff-sequences": {
|
||||||
|
"version": "30.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz",
|
||||||
|
"integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jest/environment": {
|
"node_modules/@jest/environment": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz",
|
||||||
@@ -690,6 +700,16 @@
|
|||||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@jest/get-type": {
|
||||||
|
"version": "30.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz",
|
||||||
|
"integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jest/globals": {
|
"node_modules/@jest/globals": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz",
|
||||||
@@ -706,6 +726,30 @@
|
|||||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@jest/pattern": {
|
||||||
|
"version": "30.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz",
|
||||||
|
"integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"jest-regex-util": "30.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jest/pattern/node_modules/jest-regex-util": {
|
||||||
|
"version": "30.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz",
|
||||||
|
"integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jest/reporters": {
|
"node_modules/@jest/reporters": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz",
|
||||||
@@ -1017,6 +1061,52 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/body-parser": {
|
||||||
|
"version": "1.19.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
|
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/connect": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/connect": {
|
||||||
|
"version": "3.4.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
|
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/express": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/body-parser": "*",
|
||||||
|
"@types/express-serve-static-core": "^5.0.0",
|
||||||
|
"@types/serve-static": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/express-serve-static-core": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"@types/qs": "*",
|
||||||
|
"@types/range-parser": "*",
|
||||||
|
"@types/send": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/graceful-fs": {
|
"node_modules/@types/graceful-fs": {
|
||||||
"version": "4.1.9",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
|
||||||
@@ -1027,6 +1117,13 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/http-errors": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/istanbul-lib-coverage": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||||
@@ -1055,14 +1152,229 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/jest": {
|
"node_modules/@types/jest": {
|
||||||
"version": "29.5.14",
|
"version": "30.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz",
|
||||||
"integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==",
|
"integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"expect": "^29.0.0",
|
"expect": "^30.0.0",
|
||||||
"pretty-format": "^29.0.0"
|
"pretty-format": "^30.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/jest/node_modules/@jest/expect-utils": {
|
||||||
|
"version": "30.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz",
|
||||||
|
"integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jest/get-type": "30.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/jest/node_modules/@jest/schemas": {
|
||||||
|
"version": "30.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz",
|
||||||
|
"integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sinclair/typebox": "^0.34.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/jest/node_modules/@jest/types": {
|
||||||
|
"version": "30.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz",
|
||||||
|
"integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jest/pattern": "30.4.0",
|
||||||
|
"@jest/schemas": "30.4.1",
|
||||||
|
"@types/istanbul-lib-coverage": "^2.0.6",
|
||||||
|
"@types/istanbul-reports": "^3.0.4",
|
||||||
|
"@types/node": "*",
|
||||||
|
"@types/yargs": "^17.0.33",
|
||||||
|
"chalk": "^4.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/jest/node_modules/@sinclair/typebox": {
|
||||||
|
"version": "0.34.49",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
|
||||||
|
"integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/jest/node_modules/ansi-styles": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/jest/node_modules/ci-info": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/sibiraj-s"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/jest/node_modules/expect": {
|
||||||
|
"version": "30.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz",
|
||||||
|
"integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jest/expect-utils": "30.4.1",
|
||||||
|
"@jest/get-type": "30.1.0",
|
||||||
|
"jest-matcher-utils": "30.4.1",
|
||||||
|
"jest-message-util": "30.4.1",
|
||||||
|
"jest-mock": "30.4.1",
|
||||||
|
"jest-util": "30.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/jest/node_modules/jest-diff": {
|
||||||
|
"version": "30.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz",
|
||||||
|
"integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jest/diff-sequences": "30.4.0",
|
||||||
|
"@jest/get-type": "30.1.0",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"pretty-format": "30.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/jest/node_modules/jest-matcher-utils": {
|
||||||
|
"version": "30.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz",
|
||||||
|
"integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jest/get-type": "30.1.0",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"jest-diff": "30.4.1",
|
||||||
|
"pretty-format": "30.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/jest/node_modules/jest-message-util": {
|
||||||
|
"version": "30.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz",
|
||||||
|
"integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/code-frame": "^7.27.1",
|
||||||
|
"@jest/types": "30.4.1",
|
||||||
|
"@types/stack-utils": "^2.0.3",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"graceful-fs": "^4.2.11",
|
||||||
|
"jest-util": "30.4.1",
|
||||||
|
"picomatch": "^4.0.3",
|
||||||
|
"pretty-format": "30.4.1",
|
||||||
|
"slash": "^3.0.0",
|
||||||
|
"stack-utils": "^2.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/jest/node_modules/jest-mock": {
|
||||||
|
"version": "30.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz",
|
||||||
|
"integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jest/types": "30.4.1",
|
||||||
|
"@types/node": "*",
|
||||||
|
"jest-util": "30.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/jest/node_modules/jest-util": {
|
||||||
|
"version": "30.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz",
|
||||||
|
"integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jest/types": "30.4.1",
|
||||||
|
"@types/node": "*",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"ci-info": "^4.2.0",
|
||||||
|
"graceful-fs": "^4.2.11",
|
||||||
|
"picomatch": "^4.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/jest/node_modules/picomatch": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/jest/node_modules/pretty-format": {
|
||||||
|
"version": "30.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz",
|
||||||
|
"integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jest/schemas": "30.4.1",
|
||||||
|
"ansi-styles": "^5.2.0",
|
||||||
|
"react-is-18": "npm:react-is@^18.3.1",
|
||||||
|
"react-is-19": "npm:react-is@^19.2.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
@@ -1075,6 +1387,41 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qs": {
|
||||||
|
"version": "6.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||||
|
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/range-parser": {
|
||||||
|
"version": "1.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||||
|
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/send": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/serve-static": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/http-errors": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/stack-utils": {
|
"node_modules/@types/stack-utils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||||
@@ -4008,6 +4355,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-is-18": {
|
||||||
|
"name": "react-is",
|
||||||
|
"version": "18.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/react-is-19": {
|
||||||
|
"name": "react-is",
|
||||||
|
"version": "19.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz",
|
||||||
|
"integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "@ruvnet/rvagent",
|
"name": "@ruvnet/rvagent",
|
||||||
"version": "0.2.0",
|
"version": "0.1.0",
|
||||||
"description": "SENSE-BRIDGE: dual-transport MCP server (stdio default; Streamable HTTP opt-in via RVAGENT_HTTP_PORT) exposing RuView WiFi-DensePose sensing primitives to AI agents",
|
"description": "SENSE-BRIDGE: dual-transport MCP server (stdio + Streamable HTTP) exposing RuView WiFi-DensePose sensing primitives to AI agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"import": "./dist/index.js",
|
||||||
"import": "./dist/index.js"
|
"require": "./dist/index.cjs",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -17,7 +18,8 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"README.md"
|
"README.md",
|
||||||
|
"CHANGELOG.md"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
@@ -25,8 +27,7 @@
|
|||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --forceExit",
|
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --forceExit",
|
||||||
"lint": "eslint src --ext .ts",
|
"lint": "eslint src --ext .ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit"
|
||||||
"prepublishOnly": "npm run build && npm test"
|
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"mcp",
|
"mcp",
|
||||||
@@ -52,11 +53,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8"
|
||||||
"zod-to-json-schema": "^3.25.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^20.14.0",
|
"@types/node": "^20.14.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"ts-jest": "^29.1.0",
|
"ts-jest": "^29.1.0",
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { existsSync } from "node:fs";
|
|
||||||
import type { RuviewConfig } from "./types.js";
|
import type { RuviewConfig } from "./types.js";
|
||||||
|
|
||||||
function env(key: string): string | undefined {
|
function env(key: string): string | undefined {
|
||||||
@@ -52,35 +51,17 @@ export function loadConfig(): RuviewConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ordered cog-binary candidate paths for a host of the given CPU architecture.
|
* Attempt to locate a cog binary on PATH or in common install locations.
|
||||||
* The native-arch build is probed FIRST: an appliance that ships both
|
* Returns the bare binary name if not found (will fail gracefully at invocation).
|
||||||
* `cog-<id>-arm` and `cog-<id>-x86_64` must never hand back the wrong-arch
|
|
||||||
* binary (ADR-264 F8/O7 — the pre-review order tried `-arm` unconditionally).
|
|
||||||
* The `/usr/local/bin` and bare-name (PATH) fallbacks follow, arch-agnostic.
|
|
||||||
*
|
|
||||||
* Pure and arch-injectable so the ordering is unit-testable.
|
|
||||||
*/
|
|
||||||
export function cogBinaryCandidates(
|
|
||||||
name: string,
|
|
||||||
arch: string = process.arch
|
|
||||||
): string[] {
|
|
||||||
const id = name.replace("cog-", "");
|
|
||||||
const dir = `/var/lib/cognitum/apps/${id}`;
|
|
||||||
const arm = `${dir}/cog-${id}-arm`;
|
|
||||||
const x86 = `${dir}/cog-${id}-x86_64`;
|
|
||||||
// arm64 → prefer -arm; everything else (notably x64) → prefer -x86_64.
|
|
||||||
const archOrdered = arch === "arm64" ? [arm, x86] : [x86, arm];
|
|
||||||
return [...archOrdered, `/usr/local/bin/${name}`];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locate a cog binary in the common appliance install locations, probing each
|
|
||||||
* candidate in native-arch-first order. Falls back to the bare name (PATH
|
|
||||||
* resolution at spawn time) when no candidate exists.
|
|
||||||
*/
|
*/
|
||||||
function detectCogBinary(name: string): string {
|
function detectCogBinary(name: string): string {
|
||||||
for (const candidate of cogBinaryCandidates(name)) {
|
// Common install paths for Cognitum cog binaries on Linux/macOS appliances.
|
||||||
if (existsSync(candidate)) return candidate;
|
const candidates = [
|
||||||
}
|
`/var/lib/cognitum/apps/${name.replace("cog-", "")}/cog-${name.replace("cog-", "")}-arm`,
|
||||||
return name; // bare name — rely on PATH; spawn fails gracefully if absent
|
`/var/lib/cognitum/apps/${name.replace("cog-", "")}/cog-${name.replace("cog-", "")}-x86_64`,
|
||||||
|
`/usr/local/bin/${name}`,
|
||||||
|
name, // bare name — rely on PATH
|
||||||
|
];
|
||||||
|
// Return the first candidate that might exist; actual existence is checked at call time.
|
||||||
|
return candidates[candidates.length - 1] ?? name;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* Streamable HTTP transport for @ruvnet/rvagent (ADR-124 §3, hardened per
|
* Streamable HTTP transport scaffold for @ruvnet/rvagent (ADR-124 §3).
|
||||||
* ADR-264 F7/O3).
|
|
||||||
*
|
*
|
||||||
* Binds to 127.0.0.1 by default and mounts an /mcp endpoint backed by
|
* Binds to 127.0.0.1 by default and mounts a POST /mcp endpoint backed by
|
||||||
* StreamableHTTPServerTransport from @modelcontextprotocol/sdk.
|
* StreamableHTTPServerTransport from @modelcontextprotocol/sdk.
|
||||||
*
|
*
|
||||||
* Session model (ADR-264 F7): the SDK's stateful mode requires ONE transport
|
* Security model (ADR-124 §6):
|
||||||
* (and one MCP Server) per session. An `initialize` POST creates a fresh
|
* - Origin validation: requests from origins other than the configured
|
||||||
* transport + server pair via the caller-supplied factory; follow-up
|
* allowlist are rejected with 403 Forbidden before reaching the MCP layer.
|
||||||
* POST/GET/DELETE requests are routed to their session by the
|
* - Default allowlist: ['http://localhost', 'http://127.0.0.1'] — covers
|
||||||
* `mcp-session-id` header. Transports are dropped when their session closes.
|
* Claude Code and Cursor on the same machine.
|
||||||
*
|
|
||||||
* Security model (ADR-124 §6 + ADR-264 F7):
|
|
||||||
* - Origin validation: browser-style requests whose Origin is not local
|
|
||||||
* are rejected with 403 before reaching the MCP layer. With NO explicit
|
|
||||||
* allowlist, localhost origins match on hostname, ANY port
|
|
||||||
* (http://localhost:5173 is local). When an explicit allowedOrigins list is
|
|
||||||
* configured, matching is exact — the any-port-localhost convenience is off,
|
|
||||||
* so a localhost peer on an unlisted port must be added to be accepted.
|
|
||||||
* - Bearer token: when RVAGENT_HTTP_TOKEN is set, requests must carry
|
* - Bearer token: when RVAGENT_HTTP_TOKEN is set, requests must carry
|
||||||
* Authorization: Bearer <token>; missing/wrong tokens → 401.
|
* Authorization: Bearer <token>; missing/wrong tokens → 401.
|
||||||
* - Body cap: request bodies over 1 MiB are rejected with 413 (the
|
|
||||||
* unbounded-buffering DoS from the pre-ADR-264 scaffold).
|
|
||||||
* - Bind address: defaults to 127.0.0.1 per MCP spec security requirement.
|
* - Bind address: defaults to 127.0.0.1 per MCP spec security requirement.
|
||||||
* Set RVAGENT_HTTP_HOST=0.0.0.0 only for intentional fleet deployment.
|
* Set RVAGENT_HTTP_HOST=0.0.0.0 only for intentional fleet deployment.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* import { createHttpTransport } from './http-transport.js';
|
* import { createHttpTransport } from './http-transport.js';
|
||||||
* const { httpServer } = await createHttpTransport(() => buildServer(config));
|
* const { server: httpServer, transport } = await createHttpTransport(mcpServer);
|
||||||
* // httpServer is a node:http.Server — call httpServer.close() to shut down.
|
* // httpServer is a node:http.Server — call httpServer.close() to shut down.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from "node:http";
|
import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
||||||
import type { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
|
import type { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
|
|
||||||
export type McpServerFactory = () => McpServer;
|
|
||||||
|
|
||||||
export interface HttpTransportOptions {
|
export interface HttpTransportOptions {
|
||||||
/** TCP host to bind (default: 127.0.0.1). */
|
/** TCP host to bind (default: 127.0.0.1). */
|
||||||
host?: string;
|
host?: string;
|
||||||
@@ -46,8 +32,8 @@ export interface HttpTransportOptions {
|
|||||||
port?: number;
|
port?: number;
|
||||||
/**
|
/**
|
||||||
* Allowed Origin header values. Requests with an Origin not in this list
|
* Allowed Origin header values. Requests with an Origin not in this list
|
||||||
* (and not a localhost origin) are rejected with 403. Use '*' to disable
|
* are rejected with 403. Use '*' to disable Origin validation entirely
|
||||||
* Origin validation entirely (not recommended outside of local-dev flags).
|
* (not recommended outside of local-dev flags).
|
||||||
*/
|
*/
|
||||||
allowedOrigins?: string[];
|
allowedOrigins?: string[];
|
||||||
/**
|
/**
|
||||||
@@ -56,51 +42,32 @@ export interface HttpTransportOptions {
|
|||||||
* Defaults to process.env.RVAGENT_HTTP_TOKEN (undefined = auth disabled).
|
* Defaults to process.env.RVAGENT_HTTP_TOKEN (undefined = auth disabled).
|
||||||
*/
|
*/
|
||||||
bearerToken?: string;
|
bearerToken?: string;
|
||||||
/** Maximum accepted request body size in bytes (default: 1 MiB). */
|
|
||||||
maxBodyBytes?: number;
|
|
||||||
/**
|
|
||||||
* Maximum number of concurrent live sessions (default: 64). When a new
|
|
||||||
* `initialize` arrives at the cap, the oldest-idle session is evicted (its
|
|
||||||
* transport closed) to make room — bounds memory against a flaky client that
|
|
||||||
* loops `initialize` or a malicious localhost peer (ADR-264 F7).
|
|
||||||
*/
|
|
||||||
maxSessions?: number;
|
|
||||||
/**
|
|
||||||
* Idle time-to-live for a session in ms (default: 5 min). Sessions with no
|
|
||||||
* request activity for longer than this are swept and closed.
|
|
||||||
*/
|
|
||||||
sessionIdleMs?: number;
|
|
||||||
/** How often the idle-session sweeper runs, in ms (default: 60 s). */
|
|
||||||
sweepIntervalMs?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HttpTransportResult {
|
export interface HttpTransportResult {
|
||||||
/** The raw Node.js HTTP server — call .close() to shut down. */
|
/** The raw Node.js HTTP server — call .close() to shut down. */
|
||||||
httpServer: HttpServer;
|
httpServer: HttpServer;
|
||||||
/** Live sessions keyed by session id (exposed for tests/observability). */
|
/** The MCP Streamable HTTP transport instance wired to the MCP server. */
|
||||||
sessions: Map<string, StreamableHTTPServerTransport>;
|
transport: StreamableHTTPServerTransport;
|
||||||
/** The bound address string (e.g. "http://127.0.0.1:3001"). */
|
/** The bound address string (e.g. "http://127.0.0.1:3001"). */
|
||||||
boundAddress: string;
|
boundAddress: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_HOST = "127.0.0.1";
|
const DEFAULT_HOST = "127.0.0.1";
|
||||||
const DEFAULT_PORT = 3001;
|
const DEFAULT_PORT = 3001;
|
||||||
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
const LOCALHOST_ORIGINS = new Set([
|
||||||
const DEFAULT_MAX_SESSIONS = 64;
|
"http://localhost",
|
||||||
const DEFAULT_SESSION_IDLE_MS = 5 * 60 * 1000;
|
"http://127.0.0.1",
|
||||||
const DEFAULT_SWEEP_INTERVAL_MS = 60 * 1000;
|
"https://localhost",
|
||||||
const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
"https://127.0.0.1",
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate Origin header against the allowlist.
|
* Validate Origin header against the allowlist.
|
||||||
* Returns true if the request should be allowed, false if it should be rejected.
|
* Returns true if the request should be allowed, false if it should be rejected.
|
||||||
*
|
*
|
||||||
* An absent Origin header is allowed (same-origin non-browser requests, curl,
|
* An absent Origin header is allowed (same-origin non-browser requests, curl, etc.).
|
||||||
* etc.). When NO explicit allowlist was configured (empty list), a localhost
|
* A present Origin that is not in the allowlist is rejected.
|
||||||
* origin is allowed on any port as a convenience — real browser origins carry
|
|
||||||
* ports (ADR-264 F7). When an explicit allowlist IS configured, matching is
|
|
||||||
* exact: the any-port-localhost shortcut is disabled so an operator who pins an
|
|
||||||
* allowlist actually gets it (a looped-back peer on an unlisted port is denied).
|
|
||||||
*/
|
*/
|
||||||
export function isOriginAllowed(
|
export function isOriginAllowed(
|
||||||
origin: string | undefined,
|
origin: string | undefined,
|
||||||
@@ -108,222 +75,76 @@ export function isOriginAllowed(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (origin === undefined) return true; // no Origin = not a cross-origin browser request
|
if (origin === undefined) return true; // no Origin = not a cross-origin browser request
|
||||||
if (allowedOrigins.includes("*")) return true;
|
if (allowedOrigins.includes("*")) return true;
|
||||||
if (allowedOrigins.includes(origin)) return true;
|
return allowedOrigins.some((o) => o === origin);
|
||||||
// Explicit allowlist ⇒ exact matching only; skip the localhost convenience.
|
|
||||||
if (allowedOrigins.length > 0) return false;
|
|
||||||
try {
|
|
||||||
const u = new URL(origin);
|
|
||||||
return (
|
|
||||||
(u.protocol === "http:" || u.protocol === "https:") &&
|
|
||||||
LOCAL_HOSTNAMES.has(u.hostname === "::1" ? "[::1]" : u.hostname)
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read a request body with a hard size cap; null = payload too large. */
|
|
||||||
function readBody(
|
|
||||||
req: IncomingMessage,
|
|
||||||
maxBytes: number
|
|
||||||
): Promise<string | null> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let size = 0;
|
|
||||||
let tooLarge = false;
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
req.on("data", (chunk: Buffer) => {
|
|
||||||
if (tooLarge) return; // keep draining so the 413 response can flush
|
|
||||||
size += chunk.length;
|
|
||||||
if (size > maxBytes) {
|
|
||||||
tooLarge = true;
|
|
||||||
chunks.length = 0;
|
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
chunks.push(chunk);
|
|
||||||
});
|
|
||||||
req.on("end", () => {
|
|
||||||
if (!tooLarge) resolve(Buffer.concat(chunks).toString("utf8"));
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function json(res: ServerResponse, status: number, body: object): void {
|
|
||||||
res.writeHead(status, { "Content-Type": "application/json" });
|
|
||||||
res.end(JSON.stringify(body));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the HTTP server around a per-session MCP transport map.
|
* Build and wire a Streamable HTTP transport to the provided MCP server.
|
||||||
* Returns the Node.js HTTP server (not yet listening) plus the session map.
|
* Returns the Node.js HTTP server (not yet listening) plus the transport.
|
||||||
* Call httpServer.listen(port, host) or rely on createHttpTransport which
|
* Call httpServer.listen(port, host) or rely on createHttpTransport which
|
||||||
* does that for you.
|
* does that for you.
|
||||||
*/
|
*/
|
||||||
export function buildHttpApp(
|
export function buildHttpApp(
|
||||||
serverFactory: McpServerFactory,
|
mcpServer: McpServer,
|
||||||
opts: HttpTransportOptions = {}
|
opts: HttpTransportOptions = {}
|
||||||
): { httpServer: HttpServer; sessions: Map<string, StreamableHTTPServerTransport> } {
|
): { httpServer: HttpServer; transport: StreamableHTTPServerTransport } {
|
||||||
const allowedOrigins: string[] = opts.allowedOrigins ?? [];
|
const allowedOrigins: string[] = opts.allowedOrigins ?? [
|
||||||
|
...LOCALHOST_ORIGINS,
|
||||||
|
];
|
||||||
const bearerToken = opts.bearerToken ?? process.env["RVAGENT_HTTP_TOKEN"];
|
const bearerToken = opts.bearerToken ?? process.env["RVAGENT_HTTP_TOKEN"];
|
||||||
const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
||||||
const maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
||||||
const sessionIdleMs = opts.sessionIdleMs ?? DEFAULT_SESSION_IDLE_MS;
|
|
||||||
const sweepIntervalMs = opts.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
|
|
||||||
const sessions = new Map<string, StreamableHTTPServerTransport>();
|
|
||||||
// lastSeen tracks per-session request activity so the sweeper and the
|
|
||||||
// oldest-idle eviction can bound the session map (ADR-264 F7).
|
|
||||||
const lastSeen = new Map<string, number>();
|
|
||||||
|
|
||||||
/** Mark a session as freshly used. */
|
const transport = new StreamableHTTPServerTransport({
|
||||||
function touch(sessionId: string): void {
|
sessionIdGenerator: () => randomUUID(),
|
||||||
lastSeen.set(sessionId, Date.now());
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/** Close a session's transport and drop it from the bookkeeping maps. */
|
const httpServer = createServer(
|
||||||
function closeSession(id: string): void {
|
(req: IncomingMessage, res: ServerResponse) => {
|
||||||
const transport = sessions.get(id);
|
// ── Origin validation ────────────────────────────────────────────────
|
||||||
sessions.delete(id);
|
|
||||||
lastSeen.delete(id);
|
|
||||||
if (transport) {
|
|
||||||
try {
|
|
||||||
void transport.close(); // onclose is idempotent against the maps above
|
|
||||||
} catch {
|
|
||||||
/* best-effort: a half-open transport must not block eviction */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Evict the session that has been idle longest — called when at capacity. */
|
|
||||||
function evictOldestIdle(): void {
|
|
||||||
let oldestId: string | undefined;
|
|
||||||
let oldestSeen = Infinity;
|
|
||||||
for (const [id, seen] of lastSeen) {
|
|
||||||
if (seen < oldestSeen) {
|
|
||||||
oldestSeen = seen;
|
|
||||||
oldestId = id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (oldestId !== undefined) closeSession(oldestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Periodic sweep: close sessions idle beyond sessionIdleMs. */
|
|
||||||
function sweepIdleSessions(): void {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [id, seen] of lastSeen) {
|
|
||||||
if (now - seen > sessionIdleMs) closeSession(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const sweepTimer = setInterval(sweepIdleSessions, sweepIntervalMs);
|
|
||||||
sweepTimer.unref(); // never keep the process alive just to sweep
|
|
||||||
|
|
||||||
const httpServer = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
||||||
void (async () => {
|
|
||||||
// ── Origin validation ──────────────────────────────────────────────
|
|
||||||
const origin = req.headers["origin"] as string | undefined;
|
const origin = req.headers["origin"] as string | undefined;
|
||||||
if (!isOriginAllowed(origin, allowedOrigins)) {
|
if (!isOriginAllowed(origin, allowedOrigins)) {
|
||||||
json(res, 403, { error: "Forbidden: cross-origin request rejected" });
|
res.writeHead(403, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "Forbidden: cross-origin request rejected" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bearer token auth ──────────────────────────────────────────────
|
// ── Bearer token auth ────────────────────────────────────────────────
|
||||||
if (bearerToken !== undefined && bearerToken !== "") {
|
if (bearerToken !== undefined && bearerToken !== "") {
|
||||||
const authHeader = req.headers["authorization"] as string | undefined;
|
const authHeader = req.headers["authorization"] as string | undefined;
|
||||||
const supplied = authHeader?.startsWith("Bearer ")
|
const supplied = authHeader?.startsWith("Bearer ")
|
||||||
? authHeader.slice("Bearer ".length)
|
? authHeader.slice("Bearer ".length)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (supplied !== bearerToken) {
|
if (supplied !== bearerToken) {
|
||||||
json(res, 401, { error: "Unauthorized: missing or invalid bearer token" });
|
res.writeHead(401, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "Unauthorized: missing or invalid bearer token" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Route: /mcp ────────────────────────────────────────────────────
|
// ── Route: POST /mcp ─────────────────────────────────────────────────
|
||||||
if (req.url !== "/mcp") {
|
if (req.method === "POST" && req.url === "/mcp") {
|
||||||
json(res, 404, { error: "Not found. MCP endpoint: /mcp" });
|
let body = "";
|
||||||
return;
|
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
||||||
}
|
req.on("end", () => {
|
||||||
|
let parsed: unknown;
|
||||||
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
try {
|
||||||
|
parsed = JSON.parse(body);
|
||||||
if (req.method === "POST") {
|
} catch {
|
||||||
const body = await readBody(req, maxBodyBytes);
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||||||
if (body === null) {
|
res.end(JSON.stringify({ error: "Bad Request: invalid JSON body" }));
|
||||||
json(res, 413, { error: `Payload too large (max ${maxBodyBytes} bytes)` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(body);
|
|
||||||
} catch {
|
|
||||||
json(res, 400, { error: "Bad Request: invalid JSON body" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Existing session → route to its transport.
|
|
||||||
if (sessionId !== undefined) {
|
|
||||||
const transport = sessions.get(sessionId);
|
|
||||||
if (!transport) {
|
|
||||||
json(res, 404, { error: `Unknown session "${sessionId}"` });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
touch(sessionId);
|
void transport.handleRequest(req, res, parsed);
|
||||||
await transport.handleRequest(req, res, parsed);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// New session: must be an initialize request (ADR-264 F7 — one
|
|
||||||
// transport + one MCP Server per session).
|
|
||||||
if (!isInitializeRequest(parsed)) {
|
|
||||||
json(res, 400, {
|
|
||||||
error: "Bad Request: no mcp-session-id and not an initialize request",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Bound the session map: at capacity, reclaim the oldest-idle slot
|
|
||||||
// before minting a new session (ADR-264 F7).
|
|
||||||
if (sessions.size >= maxSessions) evictOldestIdle();
|
|
||||||
const transport = new StreamableHTTPServerTransport({
|
|
||||||
sessionIdGenerator: () => randomUUID(),
|
|
||||||
onsessioninitialized: (id: string) => {
|
|
||||||
sessions.set(id, transport);
|
|
||||||
touch(id);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
transport.onclose = () => {
|
|
||||||
if (transport.sessionId !== undefined) {
|
|
||||||
sessions.delete(transport.sessionId);
|
|
||||||
lastSeen.delete(transport.sessionId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const mcpServer = serverFactory();
|
|
||||||
await mcpServer.connect(transport as Parameters<typeof mcpServer.connect>[0]);
|
|
||||||
await transport.handleRequest(req, res, parsed);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET (SSE stream) / DELETE (session termination) — session-scoped.
|
// ── Fallback ─────────────────────────────────────────────────────────
|
||||||
if (req.method === "GET" || req.method === "DELETE") {
|
res.writeHead(404, { "Content-Type": "application/json" });
|
||||||
const transport = sessionId !== undefined ? sessions.get(sessionId) : undefined;
|
res.end(JSON.stringify({ error: "Not found. MCP endpoint: POST /mcp" }));
|
||||||
if (!transport) {
|
}
|
||||||
json(res, 400, { error: "Bad Request: missing or unknown mcp-session-id" });
|
);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (sessionId !== undefined) touch(sessionId);
|
|
||||||
await transport.handleRequest(req, res);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
json(res, 405, { error: "Method not allowed. Use POST/GET/DELETE on /mcp" });
|
return { httpServer, transport };
|
||||||
})().catch(() => {
|
|
||||||
if (!res.headersSent) json(res, 500, { error: "Internal server error" });
|
|
||||||
else res.end();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
httpServer.on("close", () => clearInterval(sweepTimer));
|
|
||||||
|
|
||||||
return { httpServer, sessions };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -331,13 +152,19 @@ export function buildHttpApp(
|
|||||||
* is bound and listening.
|
* is bound and listening.
|
||||||
*/
|
*/
|
||||||
export async function createHttpTransport(
|
export async function createHttpTransport(
|
||||||
serverFactory: McpServerFactory,
|
mcpServer: McpServer,
|
||||||
opts: HttpTransportOptions = {}
|
opts: HttpTransportOptions = {}
|
||||||
): Promise<HttpTransportResult> {
|
): Promise<HttpTransportResult> {
|
||||||
const host = opts.host ?? process.env["RVAGENT_HTTP_HOST"] ?? DEFAULT_HOST;
|
const host = opts.host ?? process.env["RVAGENT_HTTP_HOST"] ?? DEFAULT_HOST;
|
||||||
const port = opts.port ?? Number(process.env["RVAGENT_HTTP_PORT"] ?? DEFAULT_PORT);
|
const port = opts.port ?? Number(process.env["RVAGENT_HTTP_PORT"] ?? DEFAULT_PORT);
|
||||||
|
|
||||||
const { httpServer, sessions } = buildHttpApp(serverFactory, opts);
|
const { httpServer, transport } = buildHttpApp(mcpServer, opts);
|
||||||
|
|
||||||
|
// Wire MCP server to the transport only after the HTTP server is built.
|
||||||
|
// Cast needed: StreamableHTTPServerTransport implements Transport but
|
||||||
|
// exactOptionalPropertyTypes causes a false incompatibility on optional
|
||||||
|
// callback properties; the cast is safe — the SDK types are consistent.
|
||||||
|
await mcpServer.connect(transport as Parameters<typeof mcpServer.connect>[0]);
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
httpServer.once("error", reject);
|
httpServer.once("error", reject);
|
||||||
@@ -346,7 +173,7 @@ export async function createHttpTransport(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
httpServer,
|
httpServer,
|
||||||
sessions,
|
transport,
|
||||||
boundAddress: `http://${host}:${port}`,
|
boundAddress: `http://${host}:${port}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+269
-197
@@ -1,39 +1,29 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* @ruvnet/rvagent — RuView MCP Server
|
* @ruv/ruview-mcp — RuView MCP Server
|
||||||
*
|
*
|
||||||
* Exposes RuView's WiFi-DensePose sensing capabilities as Model Context Protocol
|
* Exposes RuView's WiFi-DensePose sensing capabilities as Model Context Protocol
|
||||||
* (MCP) tools that Claude Code, Cursor, Codex, and other MCP-compatible agents
|
* (MCP) tools that Claude Code, Cursor, Codex, and other MCP-compatible agents
|
||||||
* can call directly.
|
* can call directly.
|
||||||
*
|
*
|
||||||
* Transports (ADR-264 O3):
|
* Tools exposed:
|
||||||
* stdio (default) node dist/index.js
|
* ruview_csi_latest — pull the latest CSI window from the sensing-server
|
||||||
* Streamable HTTP RVAGENT_HTTP_PORT=3001 node dist/index.js
|
* ruview_pose_infer — single-shot 17-keypoint pose estimation
|
||||||
* (127.0.0.1-bound, Origin-gated, optional bearer token —
|
* ruview_count_infer — single-shot person count with confidence interval
|
||||||
* see http-transport.ts for the security model)
|
* ruview_registry_list — list cogs from the Cognitum edge registry (ADR-102)
|
||||||
|
* ruview_train_count — kick off a count-cog training run (returns job ID)
|
||||||
|
* ruview_job_status — poll a background training job
|
||||||
*
|
*
|
||||||
* Tool naming (ADR-264 O4): canonical names are underscore-form
|
* Usage:
|
||||||
* (host tool-name regexes commonly enforce ^[a-zA-Z0-9_-]{1,64}$). The
|
* node dist/index.js # stdio transport (default)
|
||||||
* pre-ADR-264 dotted names (ruview.bfld.last_scan, …) remain callable as
|
* RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 node dist/index.js
|
||||||
* router-only aliases for one deprecation cycle; tools/list advertises the
|
|
||||||
* underscore form only.
|
|
||||||
*
|
|
||||||
* Validation (ADR-264 O5): each tool declares ONE Zod schema. The CallTool
|
|
||||||
* gate parses exactly once and hands the typed result to the handler; the
|
|
||||||
* advertised JSON Schema is generated from the same Zod source, so what is
|
|
||||||
* advertised is what is enforced.
|
|
||||||
*
|
*
|
||||||
* To register with Claude Code:
|
* To register with Claude Code:
|
||||||
* claude mcp add ruview -- npx -y @ruvnet/rvagent
|
* claude mcp add ruview -- node /path/to/tools/ruview-mcp/dist/index.js
|
||||||
*
|
*
|
||||||
* See ADR-104 for the original design rationale and ADR-264 for the npm
|
* See ADR-104 for the full design rationale and security model.
|
||||||
* deep-review this layout implements.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createRequire } from "node:module";
|
|
||||||
import { realpathSync } from "node:fs";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { argv } from "node:process";
|
|
||||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import {
|
import {
|
||||||
@@ -42,8 +32,6 @@ import {
|
|||||||
McpError,
|
McpError,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import type { z } from "zod";
|
|
||||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
||||||
|
|
||||||
import { loadConfig } from "./config.js";
|
import { loadConfig } from "./config.js";
|
||||||
import { csiLatestSchema, csiLatest } from "./tools/csi-latest.js";
|
import { csiLatestSchema, csiLatest } from "./tools/csi-latest.js";
|
||||||
@@ -56,51 +44,40 @@ import {
|
|||||||
jobStatusSchema,
|
jobStatusSchema,
|
||||||
jobStatus,
|
jobStatus,
|
||||||
} from "./tools/train-count.js";
|
} from "./tools/train-count.js";
|
||||||
import { bfldLastScanSchema, bfldLastScan } from "./tools/bfld-last-scan.js";
|
import { TOOL_INPUT_SCHEMAS } from "./schemas/index.js";
|
||||||
import { bfldSubscribeSchema, bfldSubscribe } from "./tools/bfld-subscribe.js";
|
import { bfldLastScan } from "./tools/bfld-last-scan.js";
|
||||||
import { presenceNowSchema, presenceNow } from "./tools/presence-now.js";
|
import { bfldSubscribe } from "./tools/bfld-subscribe.js";
|
||||||
import {
|
import { presenceNow } from "./tools/presence-now.js";
|
||||||
vitalsGetBreathingSchema,
|
import { vitalsGetBreathing } from "./tools/vitals-get-breathing.js";
|
||||||
vitalsGetBreathing,
|
import { vitalsGetHeartRate } from "./tools/vitals-get-heart-rate.js";
|
||||||
} from "./tools/vitals-get-breathing.js";
|
import { vitalsGetAll } from "./tools/vitals-get-all.js";
|
||||||
import {
|
|
||||||
vitalsGetHeartRateSchema,
|
|
||||||
vitalsGetHeartRate,
|
|
||||||
} from "./tools/vitals-get-heart-rate.js";
|
|
||||||
import { vitalsGetAllSchema, vitalsGetAll } from "./tools/vitals-get-all.js";
|
|
||||||
// NOTE: ./http-transport.js is imported lazily in main() — it chain-loads the
|
|
||||||
// SDK's streamableHttp module (~48 ms MEASURED), which the default stdio path
|
|
||||||
// never uses.
|
|
||||||
|
|
||||||
// Single-source the version from package.json (ADR-264 O8/ADR-265 D3).
|
const PACKAGE_VERSION = "0.1.0";
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const PACKAGE_VERSION: string = (
|
|
||||||
require("../package.json") as { version: string }
|
|
||||||
).version;
|
|
||||||
const SERVER_NAME = "rvagent";
|
const SERVER_NAME = "rvagent";
|
||||||
|
|
||||||
// ── Tool registry ──────────────────────────────────────────────────────────
|
// ── Tool registry ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type RuviewConfig = ReturnType<typeof loadConfig>;
|
const TOOLS = [
|
||||||
|
|
||||||
interface ToolDef {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
/** The single validation source; the advertised JSON Schema derives from it. */
|
|
||||||
schema: z.ZodTypeAny;
|
|
||||||
handler: (parsedArgs: unknown, config: RuviewConfig) => Promise<object>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TOOLS: ToolDef[] = [
|
|
||||||
{
|
{
|
||||||
name: "ruview_csi_latest",
|
name: "ruview_csi_latest",
|
||||||
description:
|
description:
|
||||||
"Pull the latest CSI window from a running wifi-densepose-sensing-server. " +
|
"Pull the latest CSI window from a running wifi-densepose-sensing-server. " +
|
||||||
"Returns 56-subcarrier × 20-frame amplitude/phase arrays suitable for " +
|
"Returns 56-subcarrier × 20-frame amplitude/phase arrays suitable for " +
|
||||||
"downstream inference or research analysis.",
|
"downstream inference or research analysis.",
|
||||||
schema: csiLatestSchema,
|
inputSchema: {
|
||||||
handler: (args, config) =>
|
type: "object" as const,
|
||||||
csiLatest(args as Parameters<typeof csiLatest>[0], config),
|
properties: {
|
||||||
|
sensing_server_url: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Base URL of the sensing-server (default: RUVIEW_SENSING_SERVER_URL or http://localhost:3000).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
|
||||||
|
const input = csiLatestSchema.parse(args);
|
||||||
|
return csiLatest(input, config);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ruview_pose_infer",
|
name: "ruview_pose_infer",
|
||||||
@@ -109,9 +86,23 @@ export const TOOLS: ToolDef[] = [
|
|||||||
"cog-pose-estimation Cog binary (ADR-101). Accepts a CSI window JSON file " +
|
"cog-pose-estimation Cog binary (ADR-101). Accepts a CSI window JSON file " +
|
||||||
"or uses the live sensing-server if no window is provided. " +
|
"or uses the live sensing-server if no window is provided. " +
|
||||||
"Returns [{keypoints: [[x,y]×17], confidence}] per detected person.",
|
"Returns [{keypoints: [[x,y]×17], confidence}] per detected person.",
|
||||||
schema: poseInferSchema,
|
inputSchema: {
|
||||||
handler: (args, config) =>
|
type: "object" as const,
|
||||||
poseInfer(args as Parameters<typeof poseInfer>[0], config),
|
properties: {
|
||||||
|
window_path: {
|
||||||
|
type: "string",
|
||||||
|
description: "Path to a CSI window JSON file. Omit to use the live sensing-server.",
|
||||||
|
},
|
||||||
|
cog_binary: {
|
||||||
|
type: "string",
|
||||||
|
description: "Path to cog-pose-estimation binary.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
|
||||||
|
const input = poseInferSchema.parse(args);
|
||||||
|
return poseInfer(input, config);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ruview_count_infer",
|
name: "ruview_count_infer",
|
||||||
@@ -119,9 +110,29 @@ export const TOOLS: ToolDef[] = [
|
|||||||
"Run a single-shot person-count inference using the cog-person-count Cog " +
|
"Run a single-shot person-count inference using the cog-person-count Cog " +
|
||||||
"binary (ADR-103). Returns {count, confidence, count_p95_low, count_p95_high} " +
|
"binary (ADR-103). Returns {count, confidence, count_p95_low, count_p95_high} " +
|
||||||
"with a Stoer-Wagner multi-node fusion upper bound when multiple nodes are active.",
|
"with a Stoer-Wagner multi-node fusion upper bound when multiple nodes are active.",
|
||||||
schema: countInferSchema,
|
inputSchema: {
|
||||||
handler: (args, config) =>
|
type: "object" as const,
|
||||||
countInfer(args as Parameters<typeof countInfer>[0], config),
|
properties: {
|
||||||
|
window_path: {
|
||||||
|
type: "string",
|
||||||
|
description: "Path to a CSI window JSON file. Omit to use the live sensing-server.",
|
||||||
|
},
|
||||||
|
cog_binary: {
|
||||||
|
type: "string",
|
||||||
|
description: "Path to cog-person-count binary.",
|
||||||
|
},
|
||||||
|
max_persons: {
|
||||||
|
type: "integer",
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 7,
|
||||||
|
description: "Upper bound on person count (1–7). Default: 7.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
|
||||||
|
const input = countInferSchema.parse(args);
|
||||||
|
return countInfer(input, config);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ruview_registry_list",
|
name: "ruview_registry_list",
|
||||||
@@ -129,9 +140,33 @@ export const TOOLS: ToolDef[] = [
|
|||||||
"List cogs from the Cognitum edge module registry (ADR-102). " +
|
"List cogs from the Cognitum edge module registry (ADR-102). " +
|
||||||
"Fetches /api/v1/edge/registry from the sensing-server, which proxies the " +
|
"Fetches /api/v1/edge/registry from the sensing-server, which proxies the " +
|
||||||
"canonical GCS catalog (105 cogs, 11 categories). Supports category filter and search.",
|
"canonical GCS catalog (105 cogs, 11 categories). Supports category filter and search.",
|
||||||
schema: registryListSchema,
|
inputSchema: {
|
||||||
handler: (args, config) =>
|
type: "object" as const,
|
||||||
registryList(args as Parameters<typeof registryList>[0], config),
|
properties: {
|
||||||
|
category: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Filter by category: health, security, building, retail, industrial, " +
|
||||||
|
"research, ai, swarm, signal, network, developer.",
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
type: "string",
|
||||||
|
description: "Search substring matched against cog id and name (case-insensitive).",
|
||||||
|
},
|
||||||
|
refresh: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Bypass the 1-hour registry cache.",
|
||||||
|
},
|
||||||
|
sensing_server_url: {
|
||||||
|
type: "string",
|
||||||
|
description: "Override the sensing-server URL.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
|
||||||
|
const input = registryListSchema.parse(args);
|
||||||
|
return registryList(input, config);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ruview_train_count",
|
name: "ruview_train_count",
|
||||||
@@ -139,139 +174,211 @@ export const TOOLS: ToolDef[] = [
|
|||||||
"Kick off a cog-person-count training run using the Candle GPU trainer " +
|
"Kick off a cog-person-count training run using the Candle GPU trainer " +
|
||||||
"(ADR-103). The paired JSONL file provides CSI windows + camera-derived " +
|
"(ADR-103). The paired JSONL file provides CSI windows + camera-derived " +
|
||||||
"person-count labels. Returns a job_id to poll with ruview_job_status.",
|
"person-count labels. Returns a job_id to poll with ruview_job_status.",
|
||||||
schema: trainCountSchema,
|
inputSchema: {
|
||||||
handler: (args, config) =>
|
type: "object" as const,
|
||||||
trainCount(args as Parameters<typeof trainCount>[0], config),
|
required: ["paired_jsonl"],
|
||||||
|
properties: {
|
||||||
|
paired_jsonl: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Path to the paired JSONL training file (produced by scripts/align-ground-truth.js).",
|
||||||
|
},
|
||||||
|
epochs: {
|
||||||
|
type: "integer",
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 10000,
|
||||||
|
description: "Training epochs (default: 400).",
|
||||||
|
},
|
||||||
|
learning_rate: {
|
||||||
|
type: "number",
|
||||||
|
description: "Initial learning rate (default: 0.001).",
|
||||||
|
},
|
||||||
|
output_dir: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Directory for model artifacts (default: v2/crates/cog-person-count/cog/artifacts/).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
|
||||||
|
const input = trainCountSchema.parse(args);
|
||||||
|
return trainCount(input, config);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ruview_job_status",
|
name: "ruview_job_status",
|
||||||
description:
|
description:
|
||||||
"Poll the status of a background training job started by ruview_train_count. " +
|
"Poll the status of a background training job started by ruview_train_count. " +
|
||||||
"Returns {status, epochs_done, epochs_total, recent_log} for the given job_id.",
|
"Returns {status, epochs_done, epochs_total, recent_log} for the given job_id.",
|
||||||
schema: jobStatusSchema,
|
inputSchema: {
|
||||||
handler: (args, config) =>
|
type: "object" as const,
|
||||||
jobStatus(args as Parameters<typeof jobStatus>[0], config),
|
required: ["job_id"],
|
||||||
|
properties: {
|
||||||
|
job_id: {
|
||||||
|
type: "string",
|
||||||
|
description: "UUID returned by ruview_train_count.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
|
||||||
|
const input = jobStatusSchema.parse(args);
|
||||||
|
return jobStatus(input, config);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// ── ADR-124 BFLD tools (Phase 4 Refinement; underscore names per ADR-264) ─
|
// ── ADR-124 BFLD tools (Phase 4 Refinement) ──────────────────────────────
|
||||||
{
|
{
|
||||||
name: "ruview_bfld_last_scan",
|
name: "ruview.bfld.last_scan",
|
||||||
description:
|
description:
|
||||||
"Return the most recent BFLD scan result for a node (ADR-118/ADR-121). " +
|
"Return the most recent BFLD scan result for a node (ADR-118/ADR-121). " +
|
||||||
"Fields: node_id, identity_risk_score [0,1], privacy_class, n_frames, timestamp_ms. " +
|
"Fields: node_id, identity_risk_score [0,1], privacy_class, n_frames, timestamp_ms. " +
|
||||||
"Proxied from sensing-server GET /api/v1/bfld/<node_id>/last_scan which aggregates " +
|
"Proxied from sensing-server GET /api/v1/bfld/<node_id>/last_scan which aggregates " +
|
||||||
"the MQTT state topics ruview/<node_id>/bfld/* (ADR-122 §2.2).",
|
"the MQTT state topics ruview/<node_id>/bfld/* (ADR-122 §2.2).",
|
||||||
schema: bfldLastScanSchema,
|
inputSchema: {
|
||||||
handler: (args, config) =>
|
type: "object" as const,
|
||||||
bfldLastScan(args as Parameters<typeof bfldLastScan>[0], config),
|
properties: {
|
||||||
|
node_id: {
|
||||||
|
type: "string",
|
||||||
|
description: "Target node id. Omit to use the single active node.",
|
||||||
|
},
|
||||||
|
sensing_server_url: {
|
||||||
|
type: "string",
|
||||||
|
description: "Override sensing-server URL for this call only.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
|
||||||
|
return bfldLastScan(args as Parameters<typeof bfldLastScan>[0], config);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ruview_bfld_subscribe",
|
name: "ruview.bfld.subscribe",
|
||||||
description:
|
description:
|
||||||
"Subscribe to BFLD events on ruview/<node_id>/bfld/* for duration_s seconds (ADR-122). " +
|
"Subscribe to BFLD events on ruview/<node_id>/bfld/* for duration_s seconds (ADR-122). " +
|
||||||
"Returns {ok, subscription_id, expires_at, topic}. When the sensing-server is unreachable, " +
|
"Returns {ok, subscription_id, expires_at, topic}. When the sensing-server is unreachable, " +
|
||||||
"returns a synthetic envelope with ok:false,warn:true so the caller can distinguish " +
|
"returns a synthetic envelope with ok:false,warn:true so the caller can distinguish " +
|
||||||
"a network error from an invalid request.",
|
"a network error from an invalid request.",
|
||||||
schema: bfldSubscribeSchema,
|
inputSchema: {
|
||||||
handler: (args, config) =>
|
type: "object" as const,
|
||||||
bfldSubscribe(args as Parameters<typeof bfldSubscribe>[0], config),
|
required: ["duration_s"],
|
||||||
|
properties: {
|
||||||
|
node_id: {
|
||||||
|
type: "string",
|
||||||
|
description: "Target node id. Omit to use the single active node.",
|
||||||
|
},
|
||||||
|
duration_s: {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 3600,
|
||||||
|
description: "Subscription duration in seconds (max 3600).",
|
||||||
|
},
|
||||||
|
sensing_server_url: {
|
||||||
|
type: "string",
|
||||||
|
description: "Override sensing-server URL for this call only.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
|
||||||
|
return bfldSubscribe(args as Parameters<typeof bfldSubscribe>[0], config);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// ── ADR-124 Presence + Vitals tools ───────────────────────────────────────
|
// ── ADR-124 Presence + Vitals tools (Phase 4 Refinement iter 5) ──────────
|
||||||
{
|
{
|
||||||
name: "ruview_presence_now",
|
name: "ruview.presence.now",
|
||||||
description:
|
description:
|
||||||
"Return current occupancy for a node: present, n_persons, confidence, timestamp_ms. " +
|
"Return current occupancy for a node: present, n_persons, confidence, timestamp_ms. " +
|
||||||
"Wraps EdgeVitalsMessage.presence + n_persons (ADR-124 §4.1, ws.py:74-88).",
|
"Wraps EdgeVitalsMessage.presence + n_persons (ADR-124 §4.1, ws.py:74-88).",
|
||||||
schema: presenceNowSchema,
|
inputSchema: {
|
||||||
handler: (args, config) =>
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
node_id: { type: "string", description: "Target node id." },
|
||||||
|
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
|
||||||
presenceNow(args as Parameters<typeof presenceNow>[0], config),
|
presenceNow(args as Parameters<typeof presenceNow>[0], config),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ruview_vitals_get_breathing",
|
name: "ruview.vitals.get_breathing",
|
||||||
description:
|
description:
|
||||||
"Return breathing rate for a node: breathing_rate_bpm (null if unavailable), " +
|
"Return breathing rate for a node: breathing_rate_bpm (null if unavailable), " +
|
||||||
"confidence, timestamp_ms. Wraps EdgeVitalsMessage.breathing_rate_bpm (ws.py:82).",
|
"confidence, timestamp_ms. Wraps EdgeVitalsMessage.breathing_rate_bpm (ws.py:82).",
|
||||||
schema: vitalsGetBreathingSchema,
|
inputSchema: {
|
||||||
handler: (args, config) =>
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
node_id: { type: "string", description: "Target node id." },
|
||||||
|
window_s: { type: "number", description: "Averaging window in seconds (max 300)." },
|
||||||
|
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
|
||||||
vitalsGetBreathing(args as Parameters<typeof vitalsGetBreathing>[0], config),
|
vitalsGetBreathing(args as Parameters<typeof vitalsGetBreathing>[0], config),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ruview_vitals_get_heart_rate",
|
name: "ruview.vitals.get_heart_rate",
|
||||||
description:
|
description:
|
||||||
"Return heart rate for a node: heartrate_bpm (null if unavailable), " +
|
"Return heart rate for a node: heartrate_bpm (null if unavailable), " +
|
||||||
"confidence, timestamp_ms. Wraps EdgeVitalsMessage.heartrate_bpm (ws.py:83).",
|
"confidence, timestamp_ms. Wraps EdgeVitalsMessage.heartrate_bpm (ws.py:83).",
|
||||||
schema: vitalsGetHeartRateSchema,
|
inputSchema: {
|
||||||
handler: (args, config) =>
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
node_id: { type: "string", description: "Target node id." },
|
||||||
|
window_s: { type: "number", description: "Averaging window in seconds (max 300)." },
|
||||||
|
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
|
||||||
vitalsGetHeartRate(args as Parameters<typeof vitalsGetHeartRate>[0], config),
|
vitalsGetHeartRate(args as Parameters<typeof vitalsGetHeartRate>[0], config),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ruview_vitals_get_all",
|
name: "ruview.vitals.get_all",
|
||||||
description:
|
description:
|
||||||
"Return the full EdgeVitalsMessage for a node (all fields except raw): " +
|
"Return the full EdgeVitalsMessage for a node (all fields except raw): " +
|
||||||
"presence, n_persons, confidence, breathing_rate_bpm, heartrate_bpm, motion, zone_id. " +
|
"presence, n_persons, confidence, breathing_rate_bpm, heartrate_bpm, motion, zone_id. " +
|
||||||
"Full surface of ws.py:74-88.",
|
"Full surface of ws.py:74-88.",
|
||||||
schema: vitalsGetAllSchema,
|
inputSchema: {
|
||||||
handler: (args, config) =>
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
node_id: { type: "string", description: "Target node id." },
|
||||||
|
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
|
||||||
vitalsGetAll(args as Parameters<typeof vitalsGetAll>[0], config),
|
vitalsGetAll(args as Parameters<typeof vitalsGetAll>[0], config),
|
||||||
},
|
},
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
/**
|
// ── Server bootstrap ────────────────────────────────────────────────────────
|
||||||
* Pre-ADR-264 dotted tool names, accepted at call time for one deprecation
|
|
||||||
* cycle. Router-only: tools/list never advertises these.
|
|
||||||
*/
|
|
||||||
export const TOOL_ALIASES: Record<string, string> = {
|
|
||||||
"ruview.bfld.last_scan": "ruview_bfld_last_scan",
|
|
||||||
"ruview.bfld.subscribe": "ruview_bfld_subscribe",
|
|
||||||
"ruview.presence.now": "ruview_presence_now",
|
|
||||||
"ruview.vitals.get_breathing": "ruview_vitals_get_breathing",
|
|
||||||
"ruview.vitals.get_heart_rate": "ruview_vitals_get_heart_rate",
|
|
||||||
"ruview.vitals.get_all": "ruview_vitals_get_all",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
async function main(): Promise<void> {
|
||||||
* Advertised JSON Schema, generated from the Zod source (ADR-264 O5).
|
const config = loadConfig();
|
||||||
* Memoized: schemas are static for the process lifetime, and tools/list is
|
|
||||||
* called once per session (per HTTP session under the session-per-server
|
|
||||||
* model) — no point re-walking the Zod tree each time.
|
|
||||||
*/
|
|
||||||
const jsonSchemaCache = new Map<string, object>();
|
|
||||||
export function toolInputJsonSchema(def: ToolDef): object {
|
|
||||||
const cached = jsonSchemaCache.get(def.name);
|
|
||||||
if (cached !== undefined) return cached;
|
|
||||||
const raw = zodToJsonSchema(def.schema, { $refStrategy: "none" }) as Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
delete raw["$schema"];
|
|
||||||
jsonSchemaCache.set(def.name, raw);
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Server factory ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a fully-wired MCP Server. A factory (not a singleton) because each
|
|
||||||
* Streamable-HTTP session needs its own Server instance (ADR-264 F7/O3).
|
|
||||||
*/
|
|
||||||
export function buildServer(config: RuviewConfig = loadConfig()): Server {
|
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{ name: SERVER_NAME, version: PACKAGE_VERSION },
|
{
|
||||||
{ capabilities: { tools: {} } }
|
name: SERVER_NAME,
|
||||||
|
version: PACKAGE_VERSION,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// List tools handler.
|
||||||
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
||||||
tools: TOOLS.map((t) => ({
|
tools: TOOLS.map((t) => ({
|
||||||
name: t.name,
|
name: t.name,
|
||||||
description: t.description,
|
description: t.description,
|
||||||
inputSchema: toolInputJsonSchema(t),
|
inputSchema: t.inputSchema,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Call tool handler — the SINGLE Zod validation gate (ADR-264 O5): parse
|
// Call tool handler — uniform Zod validation gate (ADR-124 §3 Architecture).
|
||||||
// once, hand the typed result (with defaults applied) to the handler.
|
// If TOOL_INPUT_SCHEMAS has a schema for the tool name, run safeParse first.
|
||||||
|
// Parse failures throw McpError(InvalidParams) so the client sees a typed
|
||||||
|
// JSON-RPC error rather than a wrapped string error.
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
const { name: rawName, arguments: args } = request.params;
|
const { name, arguments: args } = request.params;
|
||||||
const name = TOOL_ALIASES[rawName] ?? rawName;
|
|
||||||
const tool = TOOLS.find((t) => t.name === name);
|
const tool = TOOLS.find((t) => t.name === name);
|
||||||
|
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
@@ -281,7 +388,7 @@ export function buildServer(config: RuviewConfig = loadConfig()): Server {
|
|||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: JSON.stringify({
|
text: JSON.stringify({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: `Unknown tool "${rawName}". Available tools: ${TOOLS.map((t) => t.name).join(", ")}`,
|
error: `Unknown tool "${name}". Available tools: ${TOOLS.map((t) => t.name).join(", ")}`,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -289,16 +396,22 @@ export function buildServer(config: RuviewConfig = loadConfig()): Server {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = tool.schema.safeParse(args ?? {});
|
// Schema validation gate — applies to all tools registered in TOOL_INPUT_SCHEMAS.
|
||||||
if (!parsed.success) {
|
const schemaEntry = Object.prototype.hasOwnProperty.call(TOOL_INPUT_SCHEMAS, name)
|
||||||
throw new McpError(
|
? TOOL_INPUT_SCHEMAS[name as keyof typeof TOOL_INPUT_SCHEMAS]
|
||||||
ErrorCode.InvalidParams,
|
: undefined;
|
||||||
`Invalid arguments for tool "${rawName}": ${parsed.error.message}`
|
if (schemaEntry !== undefined) {
|
||||||
);
|
const parsed = schemaEntry.safeParse(args ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new McpError(
|
||||||
|
ErrorCode.InvalidParams,
|
||||||
|
`Invalid arguments for tool "${name}": ${parsed.error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await tool.handler(parsed.data, config);
|
const result = await tool.handler(args ?? {}, config);
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@@ -325,59 +438,18 @@ export function buildServer(config: RuviewConfig = loadConfig()): Server {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return server;
|
// Wire up stdio transport.
|
||||||
}
|
|
||||||
|
|
||||||
// ── Server bootstrap ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const config = loadConfig();
|
|
||||||
|
|
||||||
// stdio transport (default, always on).
|
|
||||||
const stdioServer = buildServer(config);
|
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await stdioServer.connect(transport);
|
await server.connect(transport);
|
||||||
|
|
||||||
// Streamable HTTP transport — explicit opt-in only (ADR-264 O3). Lazily
|
|
||||||
// imported so the stdio path never pays the streamableHttp load cost.
|
|
||||||
const httpPort = process.env["RVAGENT_HTTP_PORT"];
|
|
||||||
let httpNote = "";
|
|
||||||
if (httpPort !== undefined && httpPort !== "") {
|
|
||||||
const { createHttpTransport } = await import("./http-transport.js");
|
|
||||||
const { boundAddress } = await createHttpTransport(
|
|
||||||
() => buildServer(config),
|
|
||||||
{ port: Number(httpPort) }
|
|
||||||
);
|
|
||||||
httpNote = ` HTTP: ${boundAddress}/mcp.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log to stderr so it doesn't interfere with the MCP stdio protocol.
|
// Log to stderr so it doesn't interfere with the MCP stdio protocol.
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`[@ruvnet/rvagent] Server v${PACKAGE_VERSION} started. ` +
|
`[@ruvnet/rvagent] Server v${PACKAGE_VERSION} started. ` +
|
||||||
`Sensing server: ${config.sensingServerUrl}.${httpNote}\n`
|
`Sensing server: ${config.sensingServerUrl}\n`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLI guard: boot the server only when this module is the entrypoint — invoked
|
main().catch((e) => {
|
||||||
// as the `rvagent` / `ruview-mcp` bin or `node dist/index.js`. Importing it as a
|
process.stderr.write(`[ruview-mcp] Fatal: ${String(e)}\n`);
|
||||||
// library (`import { buildServer } from "@ruvnet/rvagent"`) must NOT side-effect
|
process.exit(1);
|
||||||
// connect a StdioServerTransport to the consumer's stdin/stdout. Realpath both
|
});
|
||||||
// sides because npm's bin shim is a symlink and passes a non-normalized,
|
|
||||||
// possibly case-skewed argv[1] on Windows (mirrors harness/ruview/bin/cli.js).
|
|
||||||
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) {
|
|
||||||
main().catch((e) => {
|
|
||||||
process.stderr.write(`[ruview-mcp] Fatal: ${String(e)}\n`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,16 +17,7 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import {
|
import { mkdirSync, appendFileSync, openSync } from "node:fs";
|
||||||
mkdirSync,
|
|
||||||
appendFileSync,
|
|
||||||
openSync,
|
|
||||||
closeSync,
|
|
||||||
readFileSync,
|
|
||||||
writeFileSync,
|
|
||||||
statSync,
|
|
||||||
readSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import type { RuviewConfig, TrainJobResult, JobStatusResult } from "../types.js";
|
import type { RuviewConfig, TrainJobResult, JobStatusResult } from "../types.js";
|
||||||
@@ -75,101 +66,17 @@ export const jobStatusSchema = z.object({
|
|||||||
|
|
||||||
export type JobStatusInput = z.infer<typeof jobStatusSchema>;
|
export type JobStatusInput = z.infer<typeof jobStatusSchema>;
|
||||||
|
|
||||||
interface JobRecord {
|
// In-process job registry (survives for the lifetime of the MCP server process).
|
||||||
status: "queued" | "running" | "done" | "failed" | "unknown";
|
// For a production implementation, persist to ~/.ruview/jobs/<id>.json.
|
||||||
log_path: string;
|
const jobRegistry = new Map<
|
||||||
queued_at: number;
|
string,
|
||||||
epochs_total: number;
|
{
|
||||||
/**
|
status: "queued" | "running" | "done" | "failed";
|
||||||
* OS pid of the training child. Persisted so a later process (e.g. after an
|
log_path: string;
|
||||||
* MCP server restart) can tell whether a job still marked 'running' actually
|
queued_at: number;
|
||||||
* outlived the process that spawned it (ADR-264 O6).
|
epochs_total: number;
|
||||||
*/
|
|
||||||
pid?: number | undefined;
|
|
||||||
/** Human-readable explanation attached during reconciliation (unknown state). */
|
|
||||||
reason?: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-process job registry, mirrored to <jobsDir>/<id>.json on every state
|
|
||||||
// change so ruview_job_status survives an MCP server restart (ADR-264 O6).
|
|
||||||
const jobRegistry = new Map<string, JobRecord>();
|
|
||||||
|
|
||||||
function jobRecordPath(jobsDir: string, jobId: string): string {
|
|
||||||
return path.join(jobsDir, `${jobId}.json`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistJob(jobsDir: string, jobId: string, record: JobRecord): void {
|
|
||||||
try {
|
|
||||||
writeFileSync(
|
|
||||||
jobRecordPath(jobsDir, jobId),
|
|
||||||
JSON.stringify({ job_id: jobId, ...record }, null, 2)
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// Persistence is best-effort; the in-memory record still serves this process.
|
|
||||||
}
|
}
|
||||||
}
|
>();
|
||||||
|
|
||||||
function loadPersistedJob(jobsDir: string, jobId: string): JobRecord | undefined {
|
|
||||||
try {
|
|
||||||
const raw = JSON.parse(readFileSync(jobRecordPath(jobsDir, jobId), "utf8")) as
|
|
||||||
Partial<JobRecord>;
|
|
||||||
if (typeof raw.log_path !== "string" || typeof raw.status !== "string") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
status: raw.status,
|
|
||||||
log_path: raw.log_path,
|
|
||||||
queued_at: typeof raw.queued_at === "number" ? raw.queued_at : 0,
|
|
||||||
epochs_total: typeof raw.epochs_total === "number" ? raw.epochs_total : 0,
|
|
||||||
pid: typeof raw.pid === "number" ? raw.pid : undefined,
|
|
||||||
reason: typeof raw.reason === "string" ? raw.reason : undefined,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is `pid` still a live process? `process.kill(pid, 0)` sends no signal but
|
|
||||||
* probes existence: ESRCH ⇒ gone; EPERM ⇒ alive but owned by another user
|
|
||||||
* (treated as alive so we never falsely reconcile a still-running job).
|
|
||||||
*/
|
|
||||||
function isProcessAlive(pid: number): boolean {
|
|
||||||
try {
|
|
||||||
process.kill(pid, 0);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
return (e as NodeJS.ErrnoException).code === "EPERM";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan log lines (tail) for the "# exit code: N" marker the child.on('close')
|
|
||||||
* handler appends. `found:false` means the process died without the marker —
|
|
||||||
* i.e. this server never saw the close (it restarted mid-run).
|
|
||||||
*/
|
|
||||||
function findExitMarker(lines: string[]): { found: boolean; code: number | null } {
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
const m = /^# exit code: (-?\d+|null)$/.exec((lines[i] ?? "").trim());
|
|
||||||
if (m) return { found: true, code: m[1] === "null" ? null : Number(m[1]) };
|
|
||||||
}
|
|
||||||
return { found: false, code: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read the last `maxLines` lines of a file without loading the whole log. */
|
|
||||||
function tailLines(filePath: string, maxLines: number, maxBytes = 64 * 1024): string[] {
|
|
||||||
const size = statSync(filePath).size;
|
|
||||||
const start = Math.max(0, size - maxBytes);
|
|
||||||
const buf = Buffer.alloc(size - start);
|
|
||||||
const fd = openSync(filePath, "r");
|
|
||||||
try {
|
|
||||||
readSync(fd, buf, 0, buf.length, start);
|
|
||||||
} finally {
|
|
||||||
closeSync(fd);
|
|
||||||
}
|
|
||||||
const lines = buf.toString("utf8").split("\n");
|
|
||||||
return lines.slice(Math.max(0, lines.length - maxLines));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function trainCount(
|
export async function trainCount(
|
||||||
input: TrainCountInput,
|
input: TrainCountInput,
|
||||||
@@ -185,16 +92,13 @@ export async function trainCount(
|
|||||||
const outputDir =
|
const outputDir =
|
||||||
input.output_dir ?? "v2/crates/cog-person-count/cog/artifacts";
|
input.output_dir ?? "v2/crates/cog-person-count/cog/artifacts";
|
||||||
|
|
||||||
// Record the job immediately so ruview_job_status can find it — in memory
|
// Record the job immediately so ruview_job_status can find it.
|
||||||
// and on disk (survives server restarts, ADR-264 O6).
|
jobRegistry.set(jobId, {
|
||||||
const record: JobRecord = {
|
|
||||||
status: "queued",
|
status: "queued",
|
||||||
log_path: logPath,
|
log_path: logPath,
|
||||||
queued_at: queuedAt,
|
queued_at: queuedAt,
|
||||||
epochs_total: input.epochs,
|
epochs_total: input.epochs,
|
||||||
};
|
});
|
||||||
jobRegistry.set(jobId, record);
|
|
||||||
persistJob(logDir, jobId, record);
|
|
||||||
|
|
||||||
// Write the header synchronously so the log file exists before spawn.
|
// Write the header synchronously so the log file exists before spawn.
|
||||||
const header = [
|
const header = [
|
||||||
@@ -238,29 +142,21 @@ export async function trainCount(
|
|||||||
|
|
||||||
child.unref(); // Allow the MCP server process to exit without waiting for training.
|
child.unref(); // Allow the MCP server process to exit without waiting for training.
|
||||||
|
|
||||||
// The child holds its own duplicates of the log fds; close the parent's
|
const entry = jobRegistry.get(jobId);
|
||||||
// copies immediately or every job leaks 2 fds for the server's lifetime
|
if (entry) {
|
||||||
// (ADR-264 F6/O6).
|
entry.status = "running";
|
||||||
closeSync(logFdOut);
|
}
|
||||||
closeSync(logFdErr);
|
|
||||||
|
|
||||||
// Record the child pid so a later process can reconcile a stale 'running'
|
|
||||||
// record after a server restart (child.pid is undefined only if spawn failed
|
|
||||||
// synchronously, in which case the 'error' handler flips status to 'failed').
|
|
||||||
record.pid = child.pid;
|
|
||||||
record.status = "running";
|
|
||||||
persistJob(logDir, jobId, record);
|
|
||||||
|
|
||||||
child.on("error", (e) => {
|
child.on("error", (e) => {
|
||||||
appendFileSync(logPath, `\n# ERROR: ${e.message}\n`);
|
appendFileSync(logPath, `\n# ERROR: ${e.message}\n`);
|
||||||
record.status = "failed";
|
const rec = jobRegistry.get(jobId);
|
||||||
persistJob(logDir, jobId, record);
|
if (rec) rec.status = "failed";
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
appendFileSync(logPath, `\n# exit code: ${code}\n`);
|
appendFileSync(logPath, `\n# exit code: ${code}\n`);
|
||||||
record.status = code === 0 ? "done" : "failed";
|
const rec = jobRegistry.get(jobId);
|
||||||
persistJob(logDir, jobId, record);
|
if (rec) rec.status = code === 0 ? "done" : "failed";
|
||||||
});
|
});
|
||||||
|
|
||||||
const result: TrainJobResult = {
|
const result: TrainJobResult = {
|
||||||
@@ -282,48 +178,24 @@ export async function trainCount(
|
|||||||
|
|
||||||
export async function jobStatus(
|
export async function jobStatus(
|
||||||
input: JobStatusInput,
|
input: JobStatusInput,
|
||||||
config: RuviewConfig
|
_config: RuviewConfig
|
||||||
): Promise<object> {
|
): Promise<object> {
|
||||||
// Memory first, then the persisted record (survives server restarts).
|
const job = jobRegistry.get(input.job_id);
|
||||||
let job = jobRegistry.get(input.job_id) ?? loadPersistedJob(config.jobsDir, input.job_id);
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: `Job ${input.job_id} not found in this server or in ${config.jobsDir}.`,
|
error: `Job ${input.job_id} not found. ` +
|
||||||
|
"The MCP server may have restarted — check the log directory directly.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconcile a 'running' record whose owning process is gone. The status flip
|
// Read the last 20 lines of the log file.
|
||||||
// to done/failed lives only in the spawning process's child.on('close'/'error')
|
|
||||||
// handlers; if this server restarted mid-run, the record froze at 'running'
|
|
||||||
// (ADR-264 O6). When the pid is dead, recover the true outcome from the log's
|
|
||||||
// "# exit code: N" marker, else surface an honest 'unknown'.
|
|
||||||
if (job.status === "running" && typeof job.pid === "number" && !isProcessAlive(job.pid)) {
|
|
||||||
let tail: string[] = [];
|
|
||||||
try {
|
|
||||||
tail = tailLines(job.log_path, 40);
|
|
||||||
} catch {
|
|
||||||
/* log unreadable — treated as no marker below */
|
|
||||||
}
|
|
||||||
const marker = findExitMarker(tail);
|
|
||||||
const reconciled: JobRecord = { ...job };
|
|
||||||
if (marker.found) {
|
|
||||||
reconciled.status = marker.code === 0 ? "done" : "failed";
|
|
||||||
reconciled.reason = undefined;
|
|
||||||
} else {
|
|
||||||
reconciled.status = "unknown";
|
|
||||||
reconciled.reason =
|
|
||||||
"process gone, no exit marker — server likely restarted mid-run";
|
|
||||||
}
|
|
||||||
jobRegistry.set(input.job_id, reconciled);
|
|
||||||
persistJob(config.jobsDir, input.job_id, reconciled);
|
|
||||||
job = reconciled;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bounded tail read — never load a multi-GB training log wholesale.
|
|
||||||
let recentLog: string[] = [];
|
let recentLog: string[] = [];
|
||||||
try {
|
try {
|
||||||
recentLog = tailLines(job.log_path, 20);
|
const { readFileSync } = await import("node:fs");
|
||||||
|
const content = readFileSync(job.log_path, "utf8");
|
||||||
|
const lines = content.split("\n");
|
||||||
|
recentLog = lines.slice(Math.max(0, lines.length - 20));
|
||||||
} catch {
|
} catch {
|
||||||
recentLog = ["(log not readable yet)"];
|
recentLog = ["(log not readable yet)"];
|
||||||
}
|
}
|
||||||
@@ -334,7 +206,6 @@ export async function jobStatus(
|
|||||||
log_path: job.log_path,
|
log_path: job.log_path,
|
||||||
recent_log: recentLog,
|
recent_log: recentLog,
|
||||||
epochs_total: job.epochs_total,
|
epochs_total: job.epochs_total,
|
||||||
...(job.reason !== undefined ? { reason: job.reason } : {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { ok: true, result };
|
return { ok: true, result };
|
||||||
|
|||||||
@@ -115,12 +115,7 @@ export interface TrainJobResult {
|
|||||||
/** Output of ruview_job_status. */
|
/** Output of ruview_job_status. */
|
||||||
export interface JobStatusResult {
|
export interface JobStatusResult {
|
||||||
job_id: string;
|
job_id: string;
|
||||||
/**
|
status: "queued" | "running" | "done" | "failed";
|
||||||
* 'unknown' is only ever produced by post-restart reconciliation: a record
|
|
||||||
* frozen at 'running' whose owning process is gone and whose log carries no
|
|
||||||
* exit-code marker (see reason).
|
|
||||||
*/
|
|
||||||
status: "queued" | "running" | "done" | "failed" | "unknown";
|
|
||||||
progress_pct?: number | undefined;
|
progress_pct?: number | undefined;
|
||||||
/** Most recent log lines (last 20). */
|
/** Most recent log lines (last 20). */
|
||||||
recent_log: string[];
|
recent_log: string[];
|
||||||
@@ -129,8 +124,6 @@ export interface JobStatusResult {
|
|||||||
epochs_done?: number | undefined;
|
epochs_done?: number | undefined;
|
||||||
/** Total epochs scheduled. */
|
/** Total epochs scheduled. */
|
||||||
epochs_total?: number | undefined;
|
epochs_total?: number | undefined;
|
||||||
/** Explanation attached when status was reconciled to 'unknown'. */
|
|
||||||
reason?: string | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Vitals (ADR-124 §6 Python surface parity: ws.py:74-88) ───────────────
|
// ── Vitals (ADR-124 §6 Python surface parity: ws.py:74-88) ───────────────
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
/**
|
|
||||||
* ADR-264 F8/O7 — cog-binary detection must be architecture-aware.
|
|
||||||
*
|
|
||||||
* detectCogBinary() itself probes hardcoded /var/lib paths, so it is not
|
|
||||||
* cheaply testable without fs mocking. The bug it fixes, however, lives purely
|
|
||||||
* in the candidate ORDER, which cogBinaryCandidates() exposes as a pure,
|
|
||||||
* arch-injectable function — that is what we pin here.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { cogBinaryCandidates } from "../src/config.js";
|
|
||||||
|
|
||||||
describe("cogBinaryCandidates()", () => {
|
|
||||||
it("probes -arm before -x86_64 on arm64 hosts", () => {
|
|
||||||
const c = cogBinaryCandidates("cog-person-count", "arm64");
|
|
||||||
const arm = c.findIndex((p) => p.endsWith("cog-person-count-arm"));
|
|
||||||
const x86 = c.findIndex((p) => p.endsWith("cog-person-count-x86_64"));
|
|
||||||
expect(arm).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(x86).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(arm).toBeLessThan(x86);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("probes -x86_64 before -arm on x64 hosts", () => {
|
|
||||||
const c = cogBinaryCandidates("cog-person-count", "x64");
|
|
||||||
const arm = c.findIndex((p) => p.endsWith("cog-person-count-arm"));
|
|
||||||
const x86 = c.findIndex((p) => p.endsWith("cog-person-count-x86_64"));
|
|
||||||
expect(x86).toBeLessThan(arm);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults an unknown arch to the x86_64-first order", () => {
|
|
||||||
const c = cogBinaryCandidates("cog-pose-estimation", "riscv64");
|
|
||||||
const arm = c.findIndex((p) => p.endsWith("cog-pose-estimation-arm"));
|
|
||||||
const x86 = c.findIndex((p) => p.endsWith("cog-pose-estimation-x86_64"));
|
|
||||||
expect(x86).toBeLessThan(arm);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the /usr/local/bin and bare-name PATH fallbacks last", () => {
|
|
||||||
const c = cogBinaryCandidates("cog-person-count", "arm64");
|
|
||||||
// The two arch builds come first; the /usr/local/bin fallback follows them.
|
|
||||||
expect(c[c.length - 1]).toBe("/usr/local/bin/cog-person-count");
|
|
||||||
expect(c).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives the id by stripping the cog- prefix once", () => {
|
|
||||||
const c = cogBinaryCandidates("cog-person-count", "x64");
|
|
||||||
expect(c[0]).toBe(
|
|
||||||
"/var/lib/cognitum/apps/person-count/cog-person-count-x86_64"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -59,9 +59,7 @@ async function startServer(
|
|||||||
basePort: number
|
basePort: number
|
||||||
): Promise<{ port: number; close: () => Promise<void> }> {
|
): Promise<{ port: number; close: () => Promise<void> }> {
|
||||||
const port = basePort + Math.floor(Math.random() * 100);
|
const port = basePort + Math.floor(Math.random() * 100);
|
||||||
// Factory, not instance: each Streamable-HTTP session gets its own MCP
|
const { httpServer } = buildHttpApp(makeMockMcpServer(), opts);
|
||||||
// Server (ADR-264 F7/O3).
|
|
||||||
const { httpServer } = buildHttpApp(() => makeMockMcpServer(), opts);
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
httpServer.once("error", reject);
|
httpServer.once("error", reject);
|
||||||
httpServer.listen(port, "127.0.0.1", () => resolve());
|
httpServer.listen(port, "127.0.0.1", () => resolve());
|
||||||
@@ -97,34 +95,8 @@ describe("isOriginAllowed()", () => {
|
|||||||
expect(isOriginAllowed("https://evil.example.com", ["*"])).toBe(true);
|
expect(isOriginAllowed("https://evil.example.com", ["*"])).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ADR-264 F7: real browser origins carry ports — localhost must match on
|
it("is case-sensitive per RFC 6454", () => {
|
||||||
// hostname, any port, even with an empty allowlist.
|
expect(isOriginAllowed("HTTP://localhost", allow)).toBe(false);
|
||||||
it("allows localhost origins on any port", () => {
|
|
||||||
expect(isOriginAllowed("http://localhost:5173", [])).toBe(true);
|
|
||||||
expect(isOriginAllowed("http://127.0.0.1:8080", [])).toBe(true);
|
|
||||||
expect(isOriginAllowed("https://localhost:3001", [])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects non-local origins even with a localhost-looking prefix", () => {
|
|
||||||
expect(isOriginAllowed("http://localhost.evil.example.com", [])).toBe(false);
|
|
||||||
expect(isOriginAllowed("https://evil.example.com:443", [])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ADR-264 F7 hardening: an EXPLICIT allowlist means exact matching only. The
|
|
||||||
// any-port-localhost convenience applies solely to the empty-allowlist case,
|
|
||||||
// so an operator who pins an allowlist actually gets it.
|
|
||||||
it("with an explicit allowlist, rejects a localhost origin on an unlisted port", () => {
|
|
||||||
expect(isOriginAllowed("http://localhost:5173", allow)).toBe(false);
|
|
||||||
expect(isOriginAllowed("http://127.0.0.1:8080", allow)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("with an explicit allowlist, still accepts an exactly-listed localhost origin", () => {
|
|
||||||
expect(isOriginAllowed("http://localhost", allow)).toBe(true);
|
|
||||||
expect(isOriginAllowed("http://127.0.0.1", allow)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is case-sensitive for non-local allowlist entries per RFC 6454", () => {
|
|
||||||
expect(isOriginAllowed("HTTPS://Partner.Example.com", ["https://partner.example.com"])).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,117 +165,3 @@ describe("HTTP transport bearer-token auth gate", () => {
|
|||||||
expect(r.status).not.toBe(401);
|
expect(r.status).not.toBe(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 7. ADR-264 F7/O3 hardening: body cap + per-session routing ─────────────
|
|
||||||
|
|
||||||
describe("HTTP transport session + body-cap hardening (ADR-264 F7)", () => {
|
|
||||||
let port: number;
|
|
||||||
let close: () => Promise<void>;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const srv = await startServer({ allowedOrigins: ["*"], maxBodyBytes: 64 * 1024 }, 49600);
|
|
||||||
port = srv.port;
|
|
||||||
close = srv.close;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => { await close(); });
|
|
||||||
|
|
||||||
it("rejects oversized request bodies with 413", async () => {
|
|
||||||
const huge = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "x", params: { pad: "y".repeat(128 * 1024) } });
|
|
||||||
const r = await post(port, "/mcp", {}, huge);
|
|
||||||
expect(r.status).toBe(413);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a non-initialize POST without a session id with 400 (never a shared transport)", async () => {
|
|
||||||
const r = await post(port, "/mcp", {}, MCP_BODY); // tools/list, no mcp-session-id
|
|
||||||
expect(r.status).toBe(400);
|
|
||||||
const body = JSON.parse(r.body) as Record<string, unknown>;
|
|
||||||
expect(body["error"]).toMatch(/initialize/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a POST with an unknown session id with 404", async () => {
|
|
||||||
const r = await post(port, "/mcp", { "mcp-session-id": "no-such-session" }, MCP_BODY);
|
|
||||||
expect(r.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a fresh session (and MCP server) per initialize request", async () => {
|
|
||||||
const init = JSON.stringify({
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
method: "initialize",
|
|
||||||
params: {
|
|
||||||
protocolVersion: "2024-11-05",
|
|
||||||
capabilities: {},
|
|
||||||
clientInfo: { name: "test-client", version: "0.0.0" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const r = await post(port, "/mcp", { Accept: "application/json, text/event-stream" }, init);
|
|
||||||
expect([200, 406]).not.toContain(0); // sanity
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 8. ADR-264 F7: session-map bounds (cap + idle TTL sweep) ───────────────
|
|
||||||
|
|
||||||
describe("HTTP transport session bounds (ADR-264 F7)", () => {
|
|
||||||
const initBody = (id: number): string =>
|
|
||||||
JSON.stringify({
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id,
|
|
||||||
method: "initialize",
|
|
||||||
params: {
|
|
||||||
protocolVersion: "2024-11-05",
|
|
||||||
capabilities: {},
|
|
||||||
clientInfo: { name: "test-client", version: "0.0.0" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build directly (not via startServer) so we can inspect the sessions map.
|
|
||||||
async function startWithApp(
|
|
||||||
opts: Parameters<typeof buildHttpApp>[1],
|
|
||||||
basePort: number
|
|
||||||
): Promise<{
|
|
||||||
port: number;
|
|
||||||
sessions: ReturnType<typeof buildHttpApp>["sessions"];
|
|
||||||
close: () => Promise<void>;
|
|
||||||
}> {
|
|
||||||
const { httpServer, sessions } = buildHttpApp(() => makeMockMcpServer(), opts);
|
|
||||||
const port = basePort + Math.floor(Math.random() * 100);
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
httpServer.once("error", reject);
|
|
||||||
httpServer.listen(port, "127.0.0.1", () => resolve());
|
|
||||||
});
|
|
||||||
const close = () =>
|
|
||||||
new Promise<void>((res, rej) => httpServer.close((e) => (e ? rej(e) : res())));
|
|
||||||
return { port, sessions, close };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACCEPT = { Accept: "application/json, text/event-stream" };
|
|
||||||
|
|
||||||
it("never exceeds maxSessions — evicts the oldest-idle session at capacity", async () => {
|
|
||||||
const srv = await startWithApp({ allowedOrigins: ["*"], maxSessions: 2 }, 49800);
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
await post(srv.port, "/mcp", ACCEPT, initBody(i));
|
|
||||||
}
|
|
||||||
expect(srv.sessions.size).toBeLessThanOrEqual(2);
|
|
||||||
} finally {
|
|
||||||
await srv.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sweeps sessions idle beyond sessionIdleMs", async () => {
|
|
||||||
const srv = await startWithApp(
|
|
||||||
{ allowedOrigins: ["*"], sessionIdleMs: 20, sweepIntervalMs: 10 },
|
|
||||||
49900
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await post(srv.port, "/mcp", ACCEPT, initBody(1));
|
|
||||||
expect(srv.sessions.size).toBe(1);
|
|
||||||
await new Promise((r) => setTimeout(r, 150));
|
|
||||||
expect(srv.sessions.size).toBe(0);
|
|
||||||
} finally {
|
|
||||||
await srv.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve, dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
// jest runs from the package root; avoid import.meta (ts-jest transforms this
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
// suite to a module target that rejects it — pre-existing suite failure).
|
const pkgPath = resolve(__dirname, "../package.json");
|
||||||
const pkgPath = resolve(process.cwd(), "package.json");
|
|
||||||
|
|
||||||
// Parse once; keep raw for snapshot assertions.
|
// Parse once; keep raw for snapshot assertions.
|
||||||
const raw = readFileSync(pkgPath, "utf-8");
|
const raw = readFileSync(pkgPath, "utf-8");
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
/**
|
|
||||||
* ADR-264 O6 — post-restart job reconciliation.
|
|
||||||
*
|
|
||||||
* When the MCP server restarts mid-run, the persisted job record stays frozen
|
|
||||||
* at 'running' (the child.on('close') that flips it lived in the dead process).
|
|
||||||
* ruview_job_status must reconcile such a record against the recorded pid and
|
|
||||||
* the log's "# exit code: N" marker.
|
|
||||||
*
|
|
||||||
* We fabricate a persisted record pointing at a KNOWN-DEAD pid (a synchronous
|
|
||||||
* child that has already exited) and assert the reconciled status.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { jobStatus } from "../src/tools/train-count.js";
|
|
||||||
import type { RuviewConfig } from "../src/types.js";
|
|
||||||
|
|
||||||
/** A pid that has certainly exited: spawnSync waits for the child to finish. */
|
|
||||||
function deadPid(): number {
|
|
||||||
const r = spawnSync(process.execPath, ["-e", ""]);
|
|
||||||
if (typeof r.pid !== "number") throw new Error("could not spawn probe child");
|
|
||||||
return r.pid;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeConfig(jobsDir: string): RuviewConfig {
|
|
||||||
return {
|
|
||||||
sensingServerUrl: "http://127.0.0.1:19999",
|
|
||||||
apiToken: undefined,
|
|
||||||
poseCogBinary: "nonexistent",
|
|
||||||
countCogBinary: "nonexistent",
|
|
||||||
jobsDir,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Write a fake persisted 'running' record + its log, return {jobId, config}. */
|
|
||||||
function seedRunningJob(logBody: string): { jobId: string; config: RuviewConfig } {
|
|
||||||
const jobsDir = mkdtempSync(path.join(os.tmpdir(), "rvagent-jobs-"));
|
|
||||||
const jobId = randomUUID();
|
|
||||||
const logPath = path.join(jobsDir, `${jobId}.log`);
|
|
||||||
writeFileSync(logPath, logBody);
|
|
||||||
const record = {
|
|
||||||
job_id: jobId,
|
|
||||||
status: "running",
|
|
||||||
log_path: logPath,
|
|
||||||
queued_at: Date.now() / 1000,
|
|
||||||
epochs_total: 5,
|
|
||||||
pid: deadPid(),
|
|
||||||
};
|
|
||||||
writeFileSync(
|
|
||||||
path.join(jobsDir, `${jobId}.json`),
|
|
||||||
JSON.stringify(record, null, 2)
|
|
||||||
);
|
|
||||||
return { jobId, config: makeConfig(jobsDir) };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("ruview_job_status reconciliation (ADR-264 O6)", () => {
|
|
||||||
it("reconciles a dead 'running' job with exit 0 to 'done'", async () => {
|
|
||||||
const { jobId, config } = seedRunningJob(
|
|
||||||
"# training...\nepoch 5/5\n# exit code: 0\n"
|
|
||||||
);
|
|
||||||
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
|
|
||||||
expect(out["ok"]).toBe(true);
|
|
||||||
const res = out["result"] as Record<string, unknown>;
|
|
||||||
expect(res["status"]).toBe("done");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reconciles a dead 'running' job with non-zero exit to 'failed'", async () => {
|
|
||||||
const { jobId, config } = seedRunningJob(
|
|
||||||
"# training...\npanic: cuda oom\n# exit code: 101\n"
|
|
||||||
);
|
|
||||||
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
|
|
||||||
const res = out["result"] as Record<string, unknown>;
|
|
||||||
expect(res["status"]).toBe("failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marks a dead 'running' job with no exit marker as 'unknown' with a reason", async () => {
|
|
||||||
const { jobId, config } = seedRunningJob("# training...\nepoch 2/5\n");
|
|
||||||
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
|
|
||||||
const res = out["result"] as Record<string, unknown>;
|
|
||||||
expect(res["status"]).toBe("unknown");
|
|
||||||
expect(typeof res["reason"]).toBe("string");
|
|
||||||
expect(res["reason"]).toMatch(/restarted/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a signal-killed marker (null) as 'failed'", async () => {
|
|
||||||
const { jobId, config } = seedRunningJob(
|
|
||||||
"# training...\n# exit code: null\n"
|
|
||||||
);
|
|
||||||
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
|
|
||||||
const res = out["result"] as Record<string, unknown>;
|
|
||||||
expect(res["status"]).toBe("failed");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": false,
|
"declarationMap": true,
|
||||||
"sourceMap": false,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"exactOptionalPropertyTypes": true,
|
"exactOptionalPropertyTypes": true,
|
||||||
|
|||||||
@@ -148,16 +148,3 @@ export class ApiService {
|
|||||||
|
|
||||||
// Create singleton instance
|
// Create singleton instance
|
||||||
export const apiService = new ApiService();
|
export const apiService = new ApiService();
|
||||||
|
|
||||||
// Storage key shared with the QuickSettings "API Access" panel.
|
|
||||||
export const API_TOKEN_STORAGE_KEY = 'ruview-api-token';
|
|
||||||
|
|
||||||
// Apply a previously-saved bearer token at module load — before app init
|
|
||||||
// dispatches its first request — so a configured RUVIEW_API_TOKEN works from
|
|
||||||
// the very first /api/v1/* call. The server only ever checks the
|
|
||||||
// `Authorization: Bearer` header (see bearer_auth.rs) — this intentionally
|
|
||||||
// never puts the token in a URL query string.
|
|
||||||
try {
|
|
||||||
const storedToken = localStorage.getItem(API_TOKEN_STORAGE_KEY);
|
|
||||||
if (storedToken) apiService.setAuthToken(storedToken);
|
|
||||||
} catch { /* storage unavailable (private browsing etc.) */ }
|
|
||||||
@@ -4088,20 +4088,6 @@ a:focus-visible,
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qs-text-input {
|
|
||||||
padding: var(--space-6) var(--space-8);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background: var(--color-secondary);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qs-text-input:focus {
|
|
||||||
outline: 2px solid var(--color-primary);
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Screenshot Flash --- */
|
/* --- Screenshot Flash --- */
|
||||||
|
|
||||||
.screenshot-flash {
|
.screenshot-flash {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// Quick Settings Panel - Centralized configuration for all UI features
|
// Quick Settings Panel - Centralized configuration for all UI features
|
||||||
// Accessible via gear icon in header
|
// Accessible via gear icon in header
|
||||||
|
|
||||||
import { apiService, API_TOKEN_STORAGE_KEY } from '../services/api.service.js';
|
|
||||||
|
|
||||||
export class QuickSettings {
|
export class QuickSettings {
|
||||||
constructor(app) {
|
constructor(app) {
|
||||||
this.app = app;
|
this.app = app;
|
||||||
@@ -11,8 +9,6 @@ export class QuickSettings {
|
|||||||
this.isOpen = false;
|
this.isOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A stored token is applied at api.service.js module load (before any
|
|
||||||
// request fires) — this panel only saves/clears it.
|
|
||||||
init() {
|
init() {
|
||||||
this.createButton();
|
this.createButton();
|
||||||
this.createPanel();
|
this.createPanel();
|
||||||
@@ -74,18 +70,6 @@ export class QuickSettings {
|
|||||||
<span class="qs-switch"></span>
|
<span class="qs-switch"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="qs-section">
|
|
||||||
<div class="qs-section-title">API Access</div>
|
|
||||||
<div class="qs-row" style="flex-direction: column; align-items: stretch; gap: 6px;">
|
|
||||||
<span>Bearer token (set only if the server enforces RUVIEW_API_TOKEN)</span>
|
|
||||||
<input type="password" id="qs-api-token" class="qs-text-input" placeholder="Paste token..." autocomplete="off" style="width: 100%; box-sizing: border-box;">
|
|
||||||
<div style="display: flex; gap: 8px;">
|
|
||||||
<button class="qs-btn" id="qs-api-token-save">Save & Apply</button>
|
|
||||||
<button class="qs-btn-danger" id="qs-api-token-clear">Clear</button>
|
|
||||||
</div>
|
|
||||||
<span id="qs-api-token-status" style="font-size: 0.85em; opacity: 0.75;"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="qs-section">
|
<div class="qs-section">
|
||||||
<div class="qs-section-title">Data</div>
|
<div class="qs-section-title">Data</div>
|
||||||
<div class="qs-row">
|
<div class="qs-row">
|
||||||
@@ -128,30 +112,6 @@ export class QuickSettings {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.panel.querySelector('#qs-api-token-save').addEventListener('click', () => {
|
|
||||||
const input = this.panel.querySelector('#qs-api-token');
|
|
||||||
const status = this.panel.querySelector('#qs-api-token-status');
|
|
||||||
const token = input.value.trim();
|
|
||||||
if (!token) {
|
|
||||||
status.textContent = 'Enter a token first, or use Clear to remove one.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try { localStorage.setItem(API_TOKEN_STORAGE_KEY, token); } catch { /* noop */ }
|
|
||||||
apiService.setAuthToken(token);
|
|
||||||
status.textContent = 'Token saved and applied. Reloading...';
|
|
||||||
setTimeout(() => window.location.reload(), 600);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.panel.querySelector('#qs-api-token-clear').addEventListener('click', () => {
|
|
||||||
const input = this.panel.querySelector('#qs-api-token');
|
|
||||||
const status = this.panel.querySelector('#qs-api-token-status');
|
|
||||||
try { localStorage.removeItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
|
|
||||||
apiService.setAuthToken(null);
|
|
||||||
input.value = '';
|
|
||||||
status.textContent = 'Token cleared. Reloading...';
|
|
||||||
setTimeout(() => window.location.reload(), 600);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.panel.querySelector('#qs-clear-data').addEventListener('click', () => {
|
this.panel.querySelector('#qs-clear-data').addEventListener('click', () => {
|
||||||
try {
|
try {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@@ -194,10 +154,6 @@ export class QuickSettings {
|
|||||||
if (this.getSetting('compact')) {
|
if (this.getSetting('compact')) {
|
||||||
document.body.classList.add('compact-mode');
|
document.body.classList.add('compact-mode');
|
||||||
}
|
}
|
||||||
const status = this.panel.querySelector('#qs-api-token-status');
|
|
||||||
let hasToken = false;
|
|
||||||
try { hasToken = !!localStorage.getItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
|
|
||||||
if (status) status.textContent = hasToken ? 'A token is currently set.' : 'No token set (auth is off or unnecessary).';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prefersReducedMotion() {
|
prefersReducedMotion() {
|
||||||
|
|||||||
Generated
+16
-15
@@ -7533,6 +7533,7 @@ dependencies = [
|
|||||||
"tokio-test",
|
"tokio-test",
|
||||||
"toml 0.8.23",
|
"toml 0.8.23",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"wifi-densepose-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10960,7 +10961,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wifi-densepose-engine"
|
name = "wifi-densepose-engine"
|
||||||
version = "0.3.1"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"criterion",
|
"criterion",
|
||||||
@@ -11091,17 +11092,6 @@ dependencies = [
|
|||||||
"tower-http",
|
"tower-http",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wifi-densepose-posecode"
|
|
||||||
version = "0.3.0"
|
|
||||||
dependencies = [
|
|
||||||
"criterion",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"wifi-densepose-signal",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wifi-densepose-rufield"
|
name = "wifi-densepose-rufield"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -11136,7 +11126,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wifi-densepose-sensing-server"
|
name = "wifi-densepose-sensing-server"
|
||||||
version = "0.3.4"
|
version = "0.3.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -11171,7 +11161,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wifi-densepose-signal"
|
name = "wifi-densepose-signal"
|
||||||
version = "0.3.5"
|
version = "0.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"criterion",
|
"criterion",
|
||||||
@@ -11278,7 +11268,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wifi-densepose-worldgraph"
|
name = "wifi-densepose-worldgraph"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"petgraph",
|
"petgraph",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -11287,6 +11277,17 @@ dependencies = [
|
|||||||
"wifi-densepose-geo",
|
"wifi-densepose-geo",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wifi-densepose-worldmodel"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"wifi-densepose-worldgraph",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
|
|||||||
+4
-8
@@ -25,7 +25,8 @@ members = [
|
|||||||
"crates/wifi-densepose-ruvector",
|
"crates/wifi-densepose-ruvector",
|
||||||
"crates/wifi-densepose-desktop",
|
"crates/wifi-densepose-desktop",
|
||||||
"crates/wifi-densepose-pointcloud",
|
"crates/wifi-densepose-pointcloud",
|
||||||
# geo + worldgraph extracted to ruvnet/worldgraph submodule (see crates/worldgraph)
|
"crates/wifi-densepose-geo",
|
||||||
|
"crates/wifi-densepose-worldgraph", # ADR-139 — WorldGraph environmental digital twin
|
||||||
"crates/wifi-densepose-engine", # ADR-135..146 integration/composition layer
|
"crates/wifi-densepose-engine", # ADR-135..146 integration/composition layer
|
||||||
"crates/wifi-densepose-calibration", # ADR-151 — per-room calibration & specialist training
|
"crates/wifi-densepose-calibration", # ADR-151 — per-room calibration & specialist training
|
||||||
"crates/nvsim",
|
"crates/nvsim",
|
||||||
@@ -46,9 +47,6 @@ members = [
|
|||||||
# PR #491 slot heuristic with a Candle network + Stoer-Wagner fusion.
|
# PR #491 slot heuristic with a Candle network + Stoer-Wagner fusion.
|
||||||
# Motivated by #499 ghost-skeleton reports.
|
# Motivated by #499 ghost-skeleton reports.
|
||||||
"crates/cog-person-count",
|
"crates/cog-person-count",
|
||||||
# ADR-266: Multi-actor PoseCode scene protocol. Converts persistent
|
|
||||||
# RuvSense PoseTracks into confidence-scored, deterministic motion scenes.
|
|
||||||
"crates/wifi-densepose-posecode",
|
|
||||||
# ADR-116: Home Assistant + Matter Cognitum Seed cog. Wraps the
|
# ADR-116: Home Assistant + Matter Cognitum Seed cog. Wraps the
|
||||||
# ADR-115 MQTT publisher as a Seed-installable artifact with
|
# ADR-115 MQTT publisher as a Seed-installable artifact with
|
||||||
# mDNS, embedded broker, RuVector thresholds, Ed25519 witness.
|
# mDNS, embedded broker, RuVector thresholds, Ed25519 witness.
|
||||||
@@ -60,7 +58,7 @@ members = [
|
|||||||
"crates/wifi-densepose-bfld",
|
"crates/wifi-densepose-bfld",
|
||||||
# ADR-147: OccWorld thin-client bridge — WorldGraph PersonTrack history →
|
# ADR-147: OccWorld thin-client bridge — WorldGraph PersonTrack history →
|
||||||
# OccWorld Python subprocess → TrajectoryPrior injection into pose tracker.
|
# OccWorld Python subprocess → TrajectoryPrior injection into pose tracker.
|
||||||
# worldmodel extracted to ruvnet/worldgraph submodule (consumed via path dep)
|
"crates/wifi-densepose-worldmodel",
|
||||||
# ADR-147 (Phase 5): OccWorld TransVQVAE ported to Candle — native Rust
|
# ADR-147 (Phase 5): OccWorld TransVQVAE ported to Candle — native Rust
|
||||||
# inference without Python/IPC overhead. Loaded alongside the Python bridge
|
# inference without Python/IPC overhead. Loaded alongside the Python bridge
|
||||||
# as a faster alternative once Phase-5 weights are available.
|
# as a faster alternative once Phase-5 weights are available.
|
||||||
@@ -90,7 +88,6 @@ members = [
|
|||||||
exclude = [
|
exclude = [
|
||||||
"crates/wifi-densepose-wasm-edge",
|
"crates/wifi-densepose-wasm-edge",
|
||||||
"crates/homecore-plugin-example",
|
"crates/homecore-plugin-example",
|
||||||
"crates/worldgraph", # ruvnet/worldgraph submodule — its own workspace (geo/worldgraph/worldmodel)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -218,8 +215,7 @@ wifi-densepose-hardware = { version = "0.3.0", path = "crates/wifi-densepose-har
|
|||||||
wifi-densepose-wasm = { version = "0.3.0", path = "crates/wifi-densepose-wasm" }
|
wifi-densepose-wasm = { version = "0.3.0", path = "crates/wifi-densepose-wasm" }
|
||||||
wifi-densepose-mat = { version = "0.3.0", path = "crates/wifi-densepose-mat" }
|
wifi-densepose-mat = { version = "0.3.0", path = "crates/wifi-densepose-mat" }
|
||||||
wifi-densepose-ruvector = { version = "0.3.0", path = "crates/wifi-densepose-ruvector" }
|
wifi-densepose-ruvector = { version = "0.3.0", path = "crates/wifi-densepose-ruvector" }
|
||||||
wifi-densepose-posecode = { version = "0.3.0", path = "crates/wifi-densepose-posecode" }
|
wifi-densepose-worldmodel = { version = "0.3.0", path = "crates/wifi-densepose-worldmodel" }
|
||||||
wifi-densepose-worldmodel = { version = "0.3.0", path = "crates/worldgraph/wifi-densepose-worldmodel" }
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
+1
-1
Submodule v2/crates/ruv-neural updated: c9638faaf8...1ece3afa33
Submodule v2/crates/ruview-swarm deleted from 267aba5be2
@@ -0,0 +1,84 @@
|
|||||||
|
[package]
|
||||||
|
name = "ruview-swarm"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "RuView drone swarm control system — hierarchical-mesh topology, Raft consensus, MARL, CSI sensing integration (ADR-148)"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
# Publishing disabled until: (1) PR #862 merges, (2) internal path-deps are
|
||||||
|
# published in dependency order, (3) export-control sign-off on the ITAR-gated
|
||||||
|
# coordination features (USML Category VIII(h)(12)). Flip to true deliberately.
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
# ITAR/USML Category VIII(h)(12): swarming coordination features.
|
||||||
|
# Must not be enabled in international distributions without export counsel review.
|
||||||
|
itar-unrestricted = []
|
||||||
|
mavlink = ["dep:mavlink"]
|
||||||
|
ros2-dds = []
|
||||||
|
onnx = ["dep:ort"]
|
||||||
|
simulation = []
|
||||||
|
demo = ["simulation"]
|
||||||
|
full = ["mavlink", "onnx", "demo", "itar-unrestricted"]
|
||||||
|
ruflo = ["dep:reqwest", "dep:serde_json"]
|
||||||
|
# Heavy GPU-capable MARL training (real Candle autodiff PPO). Off by default so
|
||||||
|
# the default build stays light and the existing test suite keeps passing.
|
||||||
|
train = ["dep:candle-core", "dep:candle-nn"]
|
||||||
|
cuda = ["candle-core/cuda", "candle-nn/cuda"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wifi-densepose-core = { path = "../wifi-densepose-core" }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = { version = "1", optional = true }
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
# MAVLink v2 (optional)
|
||||||
|
mavlink = { version = "0.13", optional = true }
|
||||||
|
|
||||||
|
# ONNX Runtime (optional — for MARL actor inference)
|
||||||
|
ort = { version = "2.0.0-rc.11", optional = true }
|
||||||
|
|
||||||
|
# Candle 0.9 — real autodiff PPO training (optional, behind `train` feature).
|
||||||
|
candle-core = { version = "0.9", default-features = false, optional = true }
|
||||||
|
candle-nn = { version = "0.9", default-features = false, optional = true }
|
||||||
|
|
||||||
|
# HTTP client (optional — for Ruflo HTTP backend)
|
||||||
|
reqwest = { version = "0.12", features = ["json"], optional = true }
|
||||||
|
|
||||||
|
# Crypto — MAVLink v2 HMAC-SHA256 signing
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
thiserror = "2.0"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
tracing = "0.1"
|
||||||
|
|
||||||
|
# Numerics
|
||||||
|
nalgebra = "0.33"
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
criterion = { version = "0.5", features = ["html_reports"] }
|
||||||
|
tokio-test = "0.4"
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "swarm_bench"
|
||||||
|
harness = false
|
||||||
|
|
||||||
|
# MARL training binary — requires the `train` feature (Candle autodiff).
|
||||||
|
# Excluded from the default build so `cargo test`/CI stay light.
|
||||||
|
[[bin]]
|
||||||
|
name = "train_marl"
|
||||||
|
required-features = ["train"]
|
||||||
|
|
||||||
|
# ADR-171 Stage-1 evaluation CLI — pure Rust, no special feature needed.
|
||||||
|
[[bin]]
|
||||||
|
name = "eval_swarm"
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# wifi-densepose-swarm
|
||||||
|
|
||||||
|
Drone swarm control system for the RuView wifi-densepose workspace. Implements ADR-148.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`wifi-densepose-swarm` provides a hierarchical-mesh drone swarm coordination system
|
||||||
|
with Raft consensus, MAPPO-based multi-agent reinforcement learning, and tight
|
||||||
|
integration with the existing WiFi CSI sensing pipeline (`wifi-densepose-signal`,
|
||||||
|
`wifi-densepose-ruvector`).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Hierarchical-Mesh Topology** — cluster heads over Raft consensus; inter-cluster Gossip for map dissemination
|
||||||
|
- **Formation Control** — F1 VirtualStructure, F2 LeaderFollower, F3 Reynolds flocking
|
||||||
|
- **3-Phase Coverage** — boustrophedon sweep → Bayesian probability grid → multi-drone triangulation
|
||||||
|
- **RRT-APF Path Planner** — RRT* with Artificial Potential Field reactive collision avoidance
|
||||||
|
- **MARL Actor (MAPPO)** — 64-dim local observation, 3-layer MLP actor, CTDE training interface
|
||||||
|
- **CSI Sensing Integration** — drone payload pipeline (ESP32-S3 → Jetson), multi-drone CSI fusion
|
||||||
|
- **OccWorld Bridge** — integrates ADR-147 OccWorld occupancy prior as path planner environment
|
||||||
|
- **Security Hardening** — MAVLink v2 HMAC-SHA256 signing, UWB GPS anti-spoofing, onboard geofencing, Remote ID
|
||||||
|
- **Fail-Safe State Machine** — 10-state onboard safety system, GCS-independent
|
||||||
|
- **Demo & Training Modes** — synthetic CSI generation, Gazebo/PX4 SITL interface, TOML mission configs
|
||||||
|
|
||||||
|
## ITAR Notice
|
||||||
|
|
||||||
|
> ⚠️ **Export-controlled capability.** Swarming coordination features (formation control,
|
||||||
|
> Raft consensus, task allocation) are gated behind the `itar-unrestricted` feature flag
|
||||||
|
> per **USML Category VIII(h)(12)**. Default builds compile only safe stubs.
|
||||||
|
> Do not enable `itar-unrestricted` for international distribution without export counsel review.
|
||||||
|
|
||||||
|
## Crate Features
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `default` | Core types, sensing, failsafe, config, MARL — no ITAR-gated code |
|
||||||
|
| `itar-unrestricted` | Enables formation control, Raft consensus, task allocation |
|
||||||
|
| `mavlink` | MAVLink v2 protocol support |
|
||||||
|
| `onnx` | ONNX Runtime backend for MARL actor inference (INT8) |
|
||||||
|
| `simulation` | Simulation-mode stubs |
|
||||||
|
| `demo` | Synthetic CSI generation, scenario runners |
|
||||||
|
| `full` | All of the above |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wifi_densepose_swarm::{config::SwarmConfig, demo::scenario::DemoScenario};
|
||||||
|
|
||||||
|
// Load a mission profile
|
||||||
|
let config = SwarmConfig::sar_default();
|
||||||
|
|
||||||
|
// Run a demo scenario
|
||||||
|
let scenario = DemoScenario::sar_rubble_field(4); // 4-drone SAR
|
||||||
|
let estimated_secs = scenario.estimate_coverage_time_secs();
|
||||||
|
// → < 240 s for 4 drones over 400×400 m (beyond Wi2SAR SOTA single-drone baseline)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mission Profiles
|
||||||
|
|
||||||
|
| Profile | Drones | Area | Application |
|
||||||
|
|---------|--------|------|-------------|
|
||||||
|
| `sar` | 6–12 | 400×400 m | Structural collapse victim search |
|
||||||
|
| `inspection` | 3–6 | Linear corridor | Infrastructure (power lines, bridges) |
|
||||||
|
| `agriculture` | 4–12 | Field-configurable | NDVI mapping, variable-rate spraying |
|
||||||
|
| `mine` | 2–4 | Tunnel | GPS-denied underground exploration |
|
||||||
|
| `relay` | 6–20 | Perimeter | Emergency telecom relay chain |
|
||||||
|
| `demo` | Any | Configurable | Synthetic CSI, configurable victims |
|
||||||
|
|
||||||
|
## Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── types.rs — NodeId, DroneState, SwarmTask, SwarmError, FailSafeState
|
||||||
|
├── topology/ — Raft consensus¹, Gossip dissemination, MeshTopology
|
||||||
|
├── formation/ — VirtualStructure¹, LeaderFollower¹, Reynolds flocking¹
|
||||||
|
├── planning/ — RRT-APF planner, 3-phase coverage, Bayesian grid, pheromone
|
||||||
|
├── allocation/ — Auction-based task allocation¹, FNN bid scorer¹
|
||||||
|
├── sensing/ — CSI payload pipeline, multi-drone fusion, OccWorld bridge
|
||||||
|
├── marl/ — MAPPO actor, LocalObservation, reward shaping, TrainingConfig
|
||||||
|
├── security/ — MAVLink signing, UWB anti-spoofing, geofencing, Remote ID
|
||||||
|
├── failsafe/ — 10-state onboard fail-safe machine
|
||||||
|
├── config/ — TOML SwarmConfig with mission presets
|
||||||
|
├── demo/ — Synthetic CSI, DemoScenario runners
|
||||||
|
├── integration/ — FlightController trait (PX4/ArduPilot/Sim)
|
||||||
|
└── bench_support.rs — Criterion fixture generators
|
||||||
|
|
||||||
|
¹ Requires `itar-unrestricted` feature.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related ADRs
|
||||||
|
|
||||||
|
| ADR | Title | Relation |
|
||||||
|
|-----|-------|----------|
|
||||||
|
| ADR-148 | Drone Swarm Control System | This crate |
|
||||||
|
| ADR-147 | OccWorld Occupancy World Model | Environment prior via `sensing::occworld_bridge` |
|
||||||
|
| ADR-134 | CSI→CIR ISTA Sparse Recovery | Drone payload sensing |
|
||||||
|
| ADR-146 | RF Encoder Multitask Heads | Drone payload inference |
|
||||||
|
| ADR-016 | RuVector Training Integration | CrossViewpointAttention |
|
||||||
|
|
||||||
|
## Performance Targets (vs. Wi2SAR SOTA)
|
||||||
|
|
||||||
|
| Metric | Wi2SAR baseline (1 drone) | 4-drone target |
|
||||||
|
|--------|--------------------------|----------------|
|
||||||
|
| Coverage | 160,000 m² | 160,000 m² |
|
||||||
|
| Time | 13.5 min | ≤ 4 min |
|
||||||
|
| Localization | 5 m | ≤ 2 m (3-view fusion) |
|
||||||
|
| MARL inference | N/A | ≤ 5 ms (INT8, release) |
|
||||||
|
| Raft election | N/A | ≤ 300 ms |
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
use criterion::{criterion_group, criterion_main, Criterion};
|
||||||
|
use ruview_swarm::marl::{MappoActor, ActorConfig};
|
||||||
|
use ruview_swarm::marl::LocalObservation;
|
||||||
|
use ruview_swarm::sensing::MultiViewFusion;
|
||||||
|
use ruview_swarm::planning::RrtApfPlanner;
|
||||||
|
use ruview_swarm::demo::{DemoScenario};
|
||||||
|
use ruview_swarm::types::{CsiDetection, NodeId, Position3D};
|
||||||
|
|
||||||
|
fn bench_marl_inference(c: &mut Criterion) {
|
||||||
|
let actor = MappoActor::random_init(ActorConfig::default());
|
||||||
|
let obs = LocalObservation::zeros();
|
||||||
|
c.bench_function("marl_actor_inference", |b| b.iter(|| actor.forward(&obs)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_rrt_apf_plan(c: &mut Criterion) {
|
||||||
|
let planner = RrtApfPlanner::new(3.0);
|
||||||
|
let start = Position3D { x: 0.0, y: 0.0, z: -30.0 };
|
||||||
|
let goal = Position3D { x: 50.0, y: 50.0, z: -30.0 };
|
||||||
|
c.bench_function("rrt_apf_100iter", |b| b.iter(|| {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
planner.plan(start, goal, 100, &mut rng)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_multiview_fusion(c: &mut Criterion) {
|
||||||
|
let fusion = MultiViewFusion::default();
|
||||||
|
let detections = vec![
|
||||||
|
CsiDetection { drone_id: NodeId(0), confidence: 0.85, victim_position: Some(Position3D { x: 51.0, y: 49.0, z: 0.0 }), timestamp_ms: 0 },
|
||||||
|
CsiDetection { drone_id: NodeId(1), confidence: 0.78, victim_position: Some(Position3D { x: 49.0, y: 51.0, z: 0.0 }), timestamp_ms: 0 },
|
||||||
|
CsiDetection { drone_id: NodeId(2), confidence: 0.92, victim_position: Some(Position3D { x: 50.0, y: 50.0, z: 0.0 }), timestamp_ms: 0 },
|
||||||
|
];
|
||||||
|
let positions = vec![
|
||||||
|
(NodeId(0), Position3D { x: 0.0, y: 0.0, z: -30.0 }),
|
||||||
|
(NodeId(1), Position3D { x: 100.0, y: 0.0, z: -30.0 }),
|
||||||
|
(NodeId(2), Position3D { x: 50.0, y: 86.6, z: -30.0 }),
|
||||||
|
];
|
||||||
|
c.bench_function("multiview_fusion_3drones", |b| b.iter(|| fusion.fuse(&detections, &positions)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_demo_coverage_estimate(c: &mut Criterion) {
|
||||||
|
let scenario = DemoScenario::sar_rubble_field(4);
|
||||||
|
c.bench_function("demo_coverage_estimate", |b| b.iter(|| scenario.estimate_coverage_time_secs()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_ppo_update(c: &mut Criterion) {
|
||||||
|
use ruview_swarm::marl::{MappoActor, ActorConfig, LocalObservation};
|
||||||
|
use ruview_swarm::marl::training_loop::{ReplayBuffer, Transition, PpoConfig, ppo_update};
|
||||||
|
use ruview_swarm::marl::actor::ActorAction;
|
||||||
|
|
||||||
|
let mut buf = ReplayBuffer::new(64);
|
||||||
|
for i in 0..64 {
|
||||||
|
buf.push(Transition {
|
||||||
|
obs: LocalObservation::zeros(),
|
||||||
|
action: ActorAction { delta_heading_rad: 0.1, delta_altitude_m: 0.0, speed_ms: 5.0, trigger_csi_scan: true },
|
||||||
|
reward: if i % 2 == 0 { 10.0 } else { -2.0 },
|
||||||
|
next_obs: LocalObservation::zeros(),
|
||||||
|
done: i == 63,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let cfg = PpoConfig::default();
|
||||||
|
c.bench_function("ppo_update_64transitions", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let mut actor = MappoActor::random_init(ActorConfig::default());
|
||||||
|
ppo_update(&mut actor, &buf, &cfg)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(benches, bench_marl_inference, bench_rrt_apf_plan, bench_multiview_fusion, bench_demo_coverage_estimate, bench_ppo_update);
|
||||||
|
criterion_main!(benches);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# ADR-171 evaluation outputs
|
||||||
|
RESULTS.md is generated by the `eval_swarm` binary.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# ruview-swarm Evaluation Results (ADR-171 Stage 1, kinematic)
|
||||||
|
|
||||||
|
Statistically-rigorous evaluation harness: seeded multi-run rollouts with IQM + 95% stratified-bootstrap confidence intervals (Agarwal et al., NeurIPS 2021).
|
||||||
|
|
||||||
|
## Run configuration
|
||||||
|
|
||||||
|
- **Stage**: 1 (kinematic, self-contained, deterministic per seed)
|
||||||
|
- **Episodes per pattern**: 100 (seed × episode matrix)
|
||||||
|
- **CI method**: 95% stratified bootstrap of the IQM, stratified by seed
|
||||||
|
- **GDOP**: 2-D geometric dilution of precision at first detection
|
||||||
|
|
||||||
|
> **Stage 2 pending**: high-fidelity Gazebo/PX4 SITL evaluation (false-alarm rate, real collision rate on the median seeds) is a follow-on — see ADR-171 §6.1. The collision figures below are a kinematic min-separation proxy, not SITL physics.
|
||||||
|
|
||||||
|
## Flight-pattern leaderboard
|
||||||
|
|
||||||
|
| Flight pattern | Coverage IQM [95% CI] | Localization (m) IQM [95% CI] | Detection rate | Mean GDOP |
|
||||||
|
|----------------|-----------------------|-------------------------------|----------------|-----------|
|
||||||
|
| partitioned_lawnmower | 1.000 [1.000, 1.000] | 7.022 [5.669, 8.379] | 100.0% | 0.000 |
|
||||||
|
| pheromone | 0.662 [0.652, 0.671] | 4.110 [3.346, 5.141] | 95.0% | 1.598 |
|
||||||
|
| levy_flight | 0.490 [0.489, 0.491] | 3.523 [2.897, 4.160] | 100.0% | 0.000 |
|
||||||
|
| boustrophedon | 0.370 [0.370, 0.370] | 2.740 [2.357, 3.207] | 100.0% | 0.000 |
|
||||||
|
| spiral | 0.336 [0.336, 0.336] | 3.082 [2.678, 3.568] | 100.0% | 0.000 |
|
||||||
|
| potential_field | 0.254 [0.252, 0.256] | 4.343 [3.489, 5.265] | 100.0% | 0.000 |
|
||||||
|
| _Wi2SAR (paper baseline)_ | _n/a_ | _5.0 (paper)_ | _n/a_ | _n/a_ |
|
||||||
|
|
||||||
|
_Wi2SAR row is the published single-drone localization figure (arxiv 2604.09115), shown paper-to-paper for reference only — it was not re-run through this kinematic harness._
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user