mirror of
https://github.com/ruvnet/RuView.git
synced 2026-06-02 00:58:56 +02:00
feat(tools): scaffold ruview MCP server + CLI + ADR-104 (#705)
Adds two new npm packages that expose RuView's WiFi-DensePose sensing capabilities outside the Cognitum appliance ecosystem: - tools/ruview-mcp/ (@ruv/ruview-mcp) — MCP server with 6 tools: ruview_csi_latest, ruview_pose_infer, ruview_count_infer, ruview_registry_list, ruview_train_count, ruview_job_status. Uses @modelcontextprotocol/sdk with stdio transport. 6/6 smoke tests pass. TypeScript strict mode, Node 20. - tools/ruview-cli/ (@ruv/ruview-cli) — Yargs CLI with matching subcommands: csi tail, pose infer, count infer, cogs list, train count, job status. Same fail-open pattern as the cog binaries (WARN to stderr, exit 0 on unavailable sensing-server). - docs/adr/ADR-104-ruview-mcp-cli-distribution.md — design rationale, 6-row threat table, packaging plan, acceptance gates, failure modes. - docs/research/sota-2026-05-22/HORIZON.md — 12-hour horizon plan with 7 milestones tracked (M1 complete in this commit). Both packages are private:true pending the user's publish decision. Inference is via subprocess to the signed cog binaries (ADR-100/101/103) — no JS/WASM ML engine bundled.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
# ADR-104: RuView MCP Server + CLI Distribution
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-05-21
|
||||
- **Deciders:** ruv
|
||||
- **Related:** ADR-100 (Cog packaging), ADR-101 (pose cog), ADR-102 (edge registry), ADR-103 (count cog)
|
||||
- **Implementation:** `tools/ruview-mcp/`, `tools/ruview-cli/`
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The Cognitum cog ecosystem ships binaries to appliances via a signed GCS catalog (ADR-100). The cogs themselves run inside `/var/lib/cognitum/apps/` on a Pi 5 or Pi+Hailo cluster node. This is the right deployment target for production inference — sub-5 ms per frame, Hailo hardware acceleration, offline operation.
|
||||
|
||||
However, three user classes need to interact with RuView capabilities **without owning a Cognitum appliance**:
|
||||
|
||||
1. **Developer agents** — Claude Code, Cursor, Codex instances that want to call `ruview_pose_infer` during a research session (e.g. the SOTA loop in `docs/research/sota-2026-05-22/PROGRESS.md`).
|
||||
2. **CI pipelines** — automated tests that want to assert "a synthetic CSI window produces a finite pose output" without a full appliance setup.
|
||||
3. **Shell scripts and researchers** — `npx ruview pose infer --window ./window.json` from any machine with Node 20, no Rust toolchain, no Cognitum account, no clone of this repo required.
|
||||
|
||||
The existing surface does not serve these users:
|
||||
- The sensing-server REST API (`/api/v1/sensing/latest`, `/api/v1/edge/registry`) is a Rust binary that requires building from source.
|
||||
- The cog binaries are signed Linux aarch64/x86_64 executables — no macOS/Windows builds, no `npx` entrypoint.
|
||||
- There is no MCP server — Claude Code cannot call RuView capabilities as tools without one.
|
||||
|
||||
This ADR defines two new distribution artifacts:
|
||||
- `@ruv/ruview-mcp` — an MCP server exposing RuView as tools.
|
||||
- `@ruv/ruview-cli` — a CLI exposing the same surface as `npx ruview <subcommand>`.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
### MCP server: `@ruv/ruview-mcp`
|
||||
|
||||
A Node 20 TypeScript package implementing the Model Context Protocol using `@modelcontextprotocol/sdk`. The server communicates over stdio (the standard MCP transport) and exposes six tools:
|
||||
|
||||
| Tool | Description | Backend |
|
||||
|------|-------------|---------|
|
||||
| `ruview_csi_latest` | Pull the latest CSI window from the sensing-server | GET /api/v1/sensing/latest (ADR-102) |
|
||||
| `ruview_pose_infer` | 17-keypoint COCO pose estimation on a CSI window | cog-pose-estimation binary (ADR-101) subprocess |
|
||||
| `ruview_count_infer` | Person count with calibrated confidence interval | cog-person-count binary (ADR-103) subprocess |
|
||||
| `ruview_registry_list` | List Cognitum cogs from the edge registry | GET /api/v1/edge/registry (ADR-102) |
|
||||
| `ruview_train_count` | Kick off a count-cog Candle training run | cargo run -p wifi-densepose-train subprocess |
|
||||
| `ruview_job_status` | Poll a background training job | reads ~/.ruview/jobs/<id>.log |
|
||||
|
||||
**Fail-open principle:** every tool returns `{ok: false, warn: true, error: "...", hint: "..."}` rather than throwing. This matches the pattern used by the Cog binaries (ADR-100 §"Failure modes") and ensures a broken sensing-server does not crash a research agent's session.
|
||||
|
||||
### CLI: `@ruv/ruview-cli`
|
||||
|
||||
The same surface as a Yargs-based CLI published to npm as `@ruv/ruview-cli` with the binary name `ruview`:
|
||||
|
||||
| Subcommand | Equivalent MCP tool |
|
||||
|------------|-------------------|
|
||||
| `ruview csi tail` | streaming poll of `ruview_csi_latest` |
|
||||
| `ruview pose infer [--window <path>]` | `ruview_pose_infer` |
|
||||
| `ruview count infer [--window <path>]` | `ruview_count_infer` |
|
||||
| `ruview cogs list [--category] [--search]` | `ruview_registry_list` |
|
||||
| `ruview train count --paired <jsonl>` | `ruview_train_count` |
|
||||
| `ruview job status --id <uuid>` | `ruview_job_status` |
|
||||
|
||||
All subcommands write JSON to stdout and exit 0 on success. WARN-level outputs (missing cog binary, unreachable sensing-server) go to stderr; exit code stays 0 so pipelines are not broken by transient unavailability.
|
||||
|
||||
### Inference backend: subprocess, not in-process
|
||||
|
||||
The MCP server and CLI **shell out** to the cog binaries rather than embedding a JS/WASM inference engine. Reasons:
|
||||
|
||||
1. The cog binaries are already signed, tested, and cross-compiled (ADR-100/101/103). Re-implementing inference in JS would duplicate that work and introduce a second model artifact to keep in sync.
|
||||
2. The cog binaries handle model loading, ONNX dispatch, and Hailo HEF routing transparently — the MCP layer needs only to understand the JSON event schema.
|
||||
3. For training, `cargo run -p wifi-densepose-train` is the proven path (2.1 s on RTX 5080, ADR-103). Replicating the Candle training loop in JS would be a significant engineering investment with no user benefit.
|
||||
|
||||
The npm packages therefore act as a **thin orchestration layer** over the existing Rust/cog infrastructure. No ML framework is bundled.
|
||||
|
||||
### ruvector library usage
|
||||
|
||||
Where a ruvector npm package provides the required capability, it is preferred over reimplementation. The subcarrier-saliency analysis in `examples/research-sota/r5_subcarrier_saliency.py` already depends on `ruvector-mincut` (Rust crate) for Stoer-Wagner min-cut. On the npm side:
|
||||
|
||||
- `@ruv/rvcsi` — the typed CSI frame schema and validation. When available at install time, `ruview_csi_latest` will validate incoming frames against the `rvcsi-core` schema. If not installed, falls back to opaque JSON passthrough.
|
||||
- HNSW, RaBitQ, and contrastive embedding primitives are Rust-native; the npm packages do not replicate them. Instead, `ruview_pose_infer` and `ruview_count_infer` delegate to the cog binary which embeds the Candle inference engine.
|
||||
|
||||
### Source layout
|
||||
|
||||
```
|
||||
tools/
|
||||
├── ruview-mcp/ # @ruv/ruview-mcp
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ ├── jest.config.js
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # MCP server entry + tool registry
|
||||
│ │ ├── types.ts # shared domain types
|
||||
│ │ ├── config.ts # env-var config loader
|
||||
│ │ ├── http.ts # fetch wrapper with timeout + Result<T>
|
||||
│ │ ├── cog.ts # subprocess wrapper for cog binaries
|
||||
│ │ └── tools/
|
||||
│ │ ├── csi-latest.ts # ruview_csi_latest
|
||||
│ │ ├── pose-infer.ts # ruview_pose_infer
|
||||
│ │ ├── count-infer.ts # ruview_count_infer
|
||||
│ │ ├── registry-list.ts # ruview_registry_list
|
||||
│ │ └── train-count.ts # ruview_train_count + ruview_job_status
|
||||
│ └── tests/
|
||||
│ └── tools.test.ts # stub smoke tests (M1) + integration tests (M6)
|
||||
└── ruview-cli/ # @ruv/ruview-cli
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── src/
|
||||
│ ├── index.ts # yargs CLI entry + command registration
|
||||
│ ├── config.ts # env-var config loader
|
||||
│ ├── http.ts # fetch wrapper
|
||||
│ ├── cog.ts # subprocess wrapper
|
||||
│ └── commands/
|
||||
│ ├── csi.ts # ruview csi tail
|
||||
│ ├── pose.ts # ruview pose infer
|
||||
│ ├── count.ts # ruview count infer
|
||||
│ ├── cogs.ts # ruview cogs list
|
||||
│ ├── train.ts # ruview train count
|
||||
│ └── job.ts # ruview job status
|
||||
└── tests/ # (M6)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
### Authentication
|
||||
|
||||
The sensing-server uses a Bearer token (`RUVIEW_API_TOKEN`) for all `/api/v1/*` routes when the token is configured. The MCP server and CLI propagate this token in the `Authorization` header for every sensing-server call. Token is sourced **only from environment variables** — never from CLI flags or tool arguments (which could appear in logs or agent histories).
|
||||
|
||||
The cog binaries are called as local subprocesses. No network authentication is involved in cog invocation — the binary is trusted by virtue of being installed on the local machine (and having passed Ed25519 signature verification at install time, per ADR-100).
|
||||
|
||||
### Threat table
|
||||
|
||||
| # | Threat | Mitigation |
|
||||
|---|--------|-----------|
|
||||
| **T1** | **MCP tool spoofing** — a malicious process registers a tool named `ruview_pose_infer` before the legitimate server and intercepts agent calls | MCP servers are registered by the operator in the Claude Code / Cursor config. The operator must explicitly `claude mcp add ruview -- node …`. Impersonation requires compromising the operator's shell config. |
|
||||
| **T2** | **CLI subcommand injection** — a caller passes a crafted `--paired` path containing shell metacharacters to escape the `cargo` invocation | All subprocess arguments are passed as an array (never through a shell string) via Node's `spawn(binary, args, {})` — no shell expansion. Path metacharacters cannot escape. |
|
||||
| **T3** | **Token leakage** — `RUVIEW_API_TOKEN` appears in process arguments, agent histories, or log files | Token is only used in the `Authorization` HTTP header, which is set programmatically. It is never printed, never passed as a CLI argument, and never written to `~/.ruview/jobs/<id>.log`. |
|
||||
| **T4** | **Model substitution** — an attacker replaces the cog binary with a malicious version | The cog binary must pass Ed25519 signature verification (`binary_sha256` + `binary_signature`) at install time per ADR-100. The MCP/CLI layer does not re-verify at invocation time — this is the cog-gateway's job. |
|
||||
| **T5** | **Output validation bypass** — cog returns malformed JSON and the MCP server forwards it without validation | `ruview_pose_infer` and `ruview_count_infer` parse cog stdout as JSON and validate the schema against `PoseInferResult` / `CountInferResult` types (Zod, M2+). On parse failure, return `{ok:false, error: "unexpected cog output: …"}`. |
|
||||
| **T6** | **Rate-limit bypass on `ruview_train_count`** — an agent calls `ruview_train_count` in a tight loop, spawning unbounded training processes | The MCP server maintains an in-process job registry. On `ruview_train_count`, if more than 3 jobs are `status:"running"`, return `{ok:false, error:"too many concurrent training jobs (max 3)"}`. Training jobs are CPU/GPU-bound and self-limit on the host. |
|
||||
|
||||
### What this ADR does NOT secure
|
||||
|
||||
- **MCP transport encryption** — MCP over stdio is process-local; no TLS is involved. If the MCP server is exposed over a TCP socket in future, TLS must be added.
|
||||
- **Cog binary authentication at invocation** — we trust the OS file permissions and the at-install-time signature check (ADR-100). If a binary is replaced after install, the MCP layer will not detect it.
|
||||
- **Multi-tenant token isolation** — the server process serves all connected clients under a single token. Multi-user deployments must run one MCP server instance per user.
|
||||
|
||||
---
|
||||
|
||||
## Packaging
|
||||
|
||||
### Version alignment
|
||||
|
||||
The npm package versions track the cog crate versions:
|
||||
- `@ruv/ruview-mcp@0.0.1` ships when `cog-pose-estimation@0.0.1` + `cog-person-count@0.0.2` are on GCS.
|
||||
- Semver: major bump when the MCP tool schema changes (breaking for calling agents); minor for new tools; patch for bug fixes.
|
||||
|
||||
### npm package configuration
|
||||
|
||||
Both packages are published to the public npm registry under the `@ruv` scope:
|
||||
|
||||
```
|
||||
@ruv/ruview-mcp — npm install -g @ruv/ruview-mcp (then: ruview-mcp)
|
||||
@ruv/ruview-cli — npm install -g @ruv/ruview-cli (then: ruview --version)
|
||||
```
|
||||
|
||||
The `bin` entry in `package.json` points to `dist/index.js` (compiled from TypeScript). Both packages target Node 20 (`"engines": {"node": ">=20.0.0"}`).
|
||||
|
||||
`private: true` is set during development; **the user must flip this to `false` before publishing** (or delete the field). The `publishConfig.access: "public"` is already set.
|
||||
|
||||
### MCP registration
|
||||
|
||||
After installing (global or npx):
|
||||
|
||||
```bash
|
||||
# Via npx (no install required):
|
||||
claude mcp add ruview -- npx @ruv/ruview-mcp
|
||||
|
||||
# Via global install:
|
||||
npm install -g @ruv/ruview-mcp
|
||||
claude mcp add ruview -- ruview-mcp
|
||||
|
||||
# Verify:
|
||||
claude mcp list # should show "ruview"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Distribution
|
||||
|
||||
`npx ruview …` works from any machine with Node 20 installed. No clone of this repository, no Rust toolchain, no Cognitum appliance is required to run the CLI commands that do not depend on a cog binary (e.g. `ruview cogs list` only needs a sensing-server URL).
|
||||
|
||||
For commands that call a cog binary (`ruview pose infer`, `ruview count infer`), the cog binary must be downloaded from GCS and placed in a directory on `PATH` or pointed to via `RUVIEW_POSE_COG_BINARY` / `RUVIEW_COUNT_COG_BINARY`. The download URL follows ADR-100 naming:
|
||||
|
||||
```
|
||||
https://storage.googleapis.com/cognitum-apps/cogs/x86_64/cog-pose-estimation-x86_64
|
||||
https://storage.googleapis.com/cognitum-apps/cogs/arm/cog-pose-estimation-arm
|
||||
https://storage.googleapis.com/cognitum-apps/cogs/x86_64/cog-person-count-x86_64
|
||||
https://storage.googleapis.com/cognitum-apps/cogs/arm/cog-person-count-arm
|
||||
```
|
||||
|
||||
A future `ruview install cogs` subcommand can automate this download + chmod + PATH placement.
|
||||
|
||||
---
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Scenario | Behaviour |
|
||||
|---|---|
|
||||
| Sensing-server not running | `ruview_csi_latest` / `ruview_registry_list` return `{ok:false, warn:true, error:"…", hint:"…"}`. Exit code 0 on CLI. MCP tool returns isError:false (it's a warn, not a crash). |
|
||||
| Cog binary not installed | `ruview_pose_infer` / `ruview_count_infer` return `{ok:false, warn:true, error:"…", hint:"…"}` with install instructions. |
|
||||
| Cog binary returns non-zero | Propagated as `{ok:false, error:"Cog exited with code N. stderr: …"}`. |
|
||||
| Training job crashes immediately | Log file records `# exit code: <N>`. `ruview_job_status` returns `{status:"failed", recent_log:[…]}`. |
|
||||
| MCP server process dies mid-session | In-process job registry is lost. Jobs that were running continue in background (detached); operator reads log files directly. |
|
||||
| Node < 20 | `fetch` is unavailable. The CLI prints a clear error: "Node 20+ required for built-in fetch". |
|
||||
|
||||
---
|
||||
|
||||
## Acceptance gates
|
||||
|
||||
| Gate | Test |
|
||||
|------|------|
|
||||
| `npx ruview --version` works | `ruview --version` prints `0.0.1` and exits 0. |
|
||||
| `ruview_pose_infer` returns finite output for synthetic CSI | M2 integration test: spawn MCP server, call tool with a synthetic window JSON, assert `result.n_persons >= 0` and all keypoint values in `[0, 1]`. |
|
||||
| MCP server passes `claude mcp list` check | `claude mcp add ruview -- node dist/index.js && claude mcp list` shows `ruview` with 6 tools. |
|
||||
| `npm run build` clean in both packages | TypeScript compilation exits 0, no errors. |
|
||||
| Stub smoke tests pass (M1) | `npm test` in `tools/ruview-mcp/` passes all 6 stub tests. |
|
||||
| Integration tests pass (M6) | 6 tool calls with mocked sensing-server + real node binary as cog stub all return `{ok: true}`. |
|
||||
|
||||
---
|
||||
|
||||
## Migration / rollout
|
||||
|
||||
1. **This PR** — land scaffold (`tools/ruview-mcp/`, `tools/ruview-cli/`) + ADR-104. Both packages at `private: true`.
|
||||
2. **M2** — wire real inference: sensing-server CSI window → cog subprocess → parsed output. Remove `stub: true` from responses.
|
||||
3. **M3** — wire `ruview_csi_latest` + `ruview_registry_list` with live sensing-server round-trip test.
|
||||
4. **M4** — wire `ruview_train_count` with real cargo invocation; verify job log populates.
|
||||
5. **M6** — integration tests green. Update acceptance gates.
|
||||
6. **User publish step** — flip `private` from `true` to `false` in both `package.json` files, then:
|
||||
|
||||
```bash
|
||||
# Publish MCP server:
|
||||
cd tools/ruview-mcp
|
||||
npm version patch # or minor/major per semver
|
||||
npm publish --access public
|
||||
|
||||
# Publish CLI:
|
||||
cd tools/ruview-cli
|
||||
npm version patch
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- ADR-100: Cognitum Cog Packaging Specification — the signing + GCS distribution model this ADR sits on top of.
|
||||
- ADR-101: Pose Estimation Cog — the binary invoked by `ruview_pose_infer`.
|
||||
- ADR-102: Edge Module Registry — the `/api/v1/edge/registry` endpoint used by `ruview_registry_list`.
|
||||
- ADR-103: Learned Multi-Person Counter Cog — the binary invoked by `ruview_count_infer`.
|
||||
- `docs/research/sota-2026-05-22/PROGRESS.md` — the SOTA research loop that motivated the MCP server.
|
||||
- `v2/crates/cog-pose-estimation/` — Rust source for the pose-estimation cog.
|
||||
- `v2/crates/cog-person-count/` — Rust source for the person-count cog.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Horizon: 12-hour Autonomous SOTA Run — 2026-05-22
|
||||
|
||||
**Horizon ID:** `sota-2026-05-22`
|
||||
**Started:** 2026-05-21 ~20:00 ET
|
||||
**Auto-stop:** 2026-05-22 08:00 ET
|
||||
**Cron:** `d6e5c473` (`*/10 * * * *`) — single-tick research contributions running in parallel
|
||||
|
||||
---
|
||||
|
||||
## Three concurrent objectives
|
||||
|
||||
| Objective | Description | Primary branch |
|
||||
|-----------|-------------|---------------|
|
||||
| **A** | Keep the cron research loop productive — curate PROGRESS.md between ticks | (main, via PR) |
|
||||
| **B** | Build `ruview` MCP server + CLI (`tools/ruview-mcp/`, `tools/ruview-cli/`) | `feat/ruview-mcp-cli` |
|
||||
| **C** | Write ADR-104: ruview MCP/CLI distribution decision record | (same branch as B) |
|
||||
|
||||
---
|
||||
|
||||
## Milestones
|
||||
|
||||
### M1 — Scaffold `tools/ruview-mcp/` + `tools/ruview-cli/`
|
||||
**Target:** +1h (by ~21:00 ET)
|
||||
**Status:** `in_progress`
|
||||
**Branch:** `feat/ruview-mcp-cli`
|
||||
|
||||
Deliverables:
|
||||
- `tools/ruview-mcp/package.json` — `@ruv/ruview-mcp`, TypeScript, `@modelcontextprotocol/sdk`
|
||||
- `tools/ruview-mcp/src/index.ts` — minimal MCP server with 5 tool stubs
|
||||
- `tools/ruview-mcp/src/tools/` — one file per tool
|
||||
- `tools/ruview-cli/package.json` — `@ruv/ruview-cli` + `ruview` bin
|
||||
- `tools/ruview-cli/src/index.ts` — 4-verb CLI stub via yargs/commander
|
||||
- `tsconfig.json` for both packages
|
||||
- Shared `tools/ruview-shared/` for HTTP client + types
|
||||
|
||||
Completion criteria: `npm run build` succeeds in both packages, MCP server can be registered with `claude mcp add`.
|
||||
|
||||
---
|
||||
|
||||
### M2 — Wire `ruview_pose_infer` + `ruview_count_infer`
|
||||
**Target:** +3h (by ~23:00 ET)
|
||||
**Status:** `pending`
|
||||
|
||||
Wire inference via subprocess to cog binaries (`cog-pose-estimation`, `cog-person-count`). MCP tools and CLI subcommands both delegate to the cog binary's `health` + a synthetic-frame run.
|
||||
|
||||
Completion criteria: `ruview_pose_infer` returns finite keypoint array; `ruview_count_infer` returns `{count, confidence}`.
|
||||
|
||||
---
|
||||
|
||||
### M3 — Wire `ruview_csi_latest` + `ruview_registry_list`
|
||||
**Target:** +5h (by ~01:00 ET)
|
||||
**Status:** `pending`
|
||||
|
||||
Connect to sensing-server `/api/v1/sensing/latest` (ADR-102 endpoint) and `/api/v1/edge/registry`. CLI: `npx ruview csi tail` streams live frames.
|
||||
|
||||
Completion criteria: both tools return structured JSON from a running sensing-server (or graceful 503 WARN if server not reachable).
|
||||
|
||||
---
|
||||
|
||||
### M4 — Wire `ruview_train_count`
|
||||
**Target:** +7h (by ~03:00 ET)
|
||||
**Status:** `pending`
|
||||
|
||||
Fire the Candle training pipeline as a background subprocess; return a job ID; expose `ruview_job_status` to poll. Training output streamed to `~/.ruview/jobs/<id>.log`.
|
||||
|
||||
Completion criteria: `ruview_train_count` returns `{job_id, status: "queued"}` within 200 ms.
|
||||
|
||||
---
|
||||
|
||||
### M5 — ADR-104: ruview MCP/CLI distribution
|
||||
**Target:** +8h (by ~04:00 ET)
|
||||
**Status:** `pending`
|
||||
|
||||
Full ADR covering: problem, design (5 MCP tools + 5 CLI subcommands + library mapping), security (6-row threat table), packaging (npm `@ruv/ruview-mcp` + `@ruv/ruview-cli`), distribution, failure modes, acceptance gates.
|
||||
|
||||
Completion criteria: ADR file at `docs/adr/ADR-104-ruview-mcp-cli-distribution.md`, merged to main.
|
||||
|
||||
---
|
||||
|
||||
### M6 — Integration tests
|
||||
**Target:** +10h (by ~06:00 ET)
|
||||
**Status:** `pending`
|
||||
|
||||
Jest/Vitest tests: spawn MCP server, call each tool stub, assert structured output shape. CI-green on Node 20.
|
||||
|
||||
Completion criteria: `npm test` passes in `tools/ruview-mcp/`.
|
||||
|
||||
---
|
||||
|
||||
### M7 — Final summary + handoff
|
||||
**Target:** +11h (by ~07:00 ET)
|
||||
**Status:** `pending`
|
||||
|
||||
Write final section to this HORIZON.md: what shipped, what deferred, exact `npm publish` commands.
|
||||
|
||||
---
|
||||
|
||||
## Cron coordination (Objective A)
|
||||
|
||||
The `d6e5c473` cron picks threads from `PROGRESS.md` independently. Rules for safe co-operation:
|
||||
- Horizon-tracker writes to HORIZON.md, not PROGRESS.md, except for cross-link notes.
|
||||
- When a cron tick lands a new artifact, horizon-tracker distills its finding into PROGRESS.md's "Done" section + adds cross-links (e.g. R5 → R8 RSSI feasibility).
|
||||
- If a thread shows 2+ consecutive ticks without a new artifact, horizon-tracker adds `blocked: <reason>` to that thread's section.
|
||||
|
||||
Current cross-links identified at session start:
|
||||
- **R5 → R8**: band-spread top-8 saliency distribution raises RSSI-only ceiling to ~60% of full-CSI upper-bound.
|
||||
- **R5 → R7**: top-8 subcarriers are exactly the ones a defender must corroborate across nodes.
|
||||
- **R5 → R1**: saliency map should be re-run on multi-static captures (different geometry = different salient subcarriers?).
|
||||
|
||||
---
|
||||
|
||||
## Drift indicators (checked each milestone)
|
||||
|
||||
| Indicator | Threshold | Current |
|
||||
|-----------|-----------|---------|
|
||||
| Timeline | M1 >2h behind → defer scope | On track |
|
||||
| Scope | MCP server grows beyond 5 tools | On track |
|
||||
| Approach | MCP SDK incompatible with available node | TBD at M1 |
|
||||
| Dependency | ruvector npm packages not findable | TBD at M1 |
|
||||
| Priority | Cron consuming PROGRESS.md locks | None yet |
|
||||
|
||||
---
|
||||
|
||||
## Session log
|
||||
|
||||
### Session 1 — 2026-05-21 (horizon init)
|
||||
|
||||
**Started:** Initial read of PROGRESS.md, ADR-100/101/102/103, R5 saliency note.
|
||||
**Plan:** Three-objective parallel run. M1 scaffold first.
|
||||
**Status:** HORIZON.md written, branch `feat/ruview-mcp-cli` created. Beginning M1.
|
||||
@@ -0,0 +1,18 @@
|
||||
/** @type {import('jest').Config} */
|
||||
export default {
|
||||
preset: "ts-jest/presets/default-esm",
|
||||
testEnvironment: "node",
|
||||
extensionsToTreatAsEsm: [".ts"],
|
||||
moduleNameMapper: {
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1",
|
||||
},
|
||||
transform: {
|
||||
"^.+\\.tsx?$": [
|
||||
"ts-jest",
|
||||
{
|
||||
useESM: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
testMatch: ["**/tests/**/*.test.ts"],
|
||||
};
|
||||
Generated
+3843
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@ruv/ruview-cli",
|
||||
"version": "0.0.1",
|
||||
"description": "RuView CLI — shell access to WiFi-DensePose sensing, inference, and training capabilities",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"ruview": "dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"ruview",
|
||||
"wifi",
|
||||
"csi",
|
||||
"pose-estimation",
|
||||
"cognitum",
|
||||
"cli"
|
||||
],
|
||||
"author": "ruv <ruv@ruv.net>",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Subprocess wrapper for Cognitum Cog binaries (CLI variant).
|
||||
* Mirrors tools/ruview-mcp/src/cog.ts.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export type Result<T> = { ok: true; data: T } | { ok: false; error: string };
|
||||
|
||||
const COG_TIMEOUT_MS = 15_000;
|
||||
|
||||
export async function runCog(binary: string, args: string[]): Promise<Result<string>> {
|
||||
return new Promise((resolve) => {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
const child = spawn(binary, args, {
|
||||
timeout: COG_TIMEOUT_MS,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
|
||||
child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
|
||||
|
||||
child.on("error", (e) => {
|
||||
resolve(err(
|
||||
`Failed to launch "${binary}" (${args.join(" ")}): ${e.message}. ` +
|
||||
`Set RUVIEW_POSE_COG_BINARY / RUVIEW_COUNT_COG_BINARY or install the cog.`
|
||||
));
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
resolve(err(`Cog "${binary} ${args.join(" ")}" exited with code ${code}. stderr: ${stderr.trim() || "(empty)"}`));
|
||||
} else {
|
||||
resolve({ ok: true, data: stdout });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function err(error: string): { ok: false; error: string } {
|
||||
return { ok: false, error };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* ruview cogs — Cognitum edge module registry commands.
|
||||
*
|
||||
* cogs list — list cogs from the registry (via sensing-server ADR-102 proxy).
|
||||
*/
|
||||
|
||||
import type { Argv } from "yargs";
|
||||
import { sensingGet } from "../http.js";
|
||||
import { loadConfig } from "../config.js";
|
||||
|
||||
export function cogsCommand(cli: Argv): void {
|
||||
cli.command(
|
||||
"cogs <action>",
|
||||
"Edge module registry commands",
|
||||
(y) =>
|
||||
y
|
||||
.positional("action", {
|
||||
choices: ["list"] as const,
|
||||
description: "Action to perform",
|
||||
})
|
||||
.option("category", {
|
||||
type: "string",
|
||||
description:
|
||||
"Filter by category: health, security, building, retail, industrial, " +
|
||||
"research, ai, swarm, signal, network, developer",
|
||||
})
|
||||
.option("search", {
|
||||
type: "string",
|
||||
description: "Search substring matched against cog id and name (case-insensitive)",
|
||||
})
|
||||
.option("refresh", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Bypass the 1-hour registry cache",
|
||||
})
|
||||
.option("url", {
|
||||
type: "string",
|
||||
description: "Override the sensing-server URL",
|
||||
}),
|
||||
async (args) => {
|
||||
const config = loadConfig();
|
||||
const baseUrl = (args["url"] as string | undefined) ?? config.sensingServerUrl;
|
||||
|
||||
if (args.action === "list") {
|
||||
const qs = args.refresh ? "?refresh=1" : "";
|
||||
const result = await sensingGet<{
|
||||
registry?: { cogs?: object[]; apps?: object[] };
|
||||
}>(baseUrl, `/api/v1/edge/registry${qs}`, config.apiToken);
|
||||
|
||||
if (!result.ok) {
|
||||
process.stderr.write(`[WARN] ${result.error}\n`);
|
||||
process.stdout.write(
|
||||
JSON.stringify({ ok: false, warn: true, error: result.error }) + "\n"
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const payload = result.data;
|
||||
let cogs: object[] =
|
||||
payload.registry?.cogs ?? payload.registry?.apps ?? [];
|
||||
|
||||
if (args.category) {
|
||||
const cat = (args.category as string).toLowerCase();
|
||||
cogs = cogs.filter(
|
||||
(c) =>
|
||||
(c as Record<string, unknown>)["category"]
|
||||
?.toString()
|
||||
.toLowerCase() === cat
|
||||
);
|
||||
}
|
||||
if (args.search) {
|
||||
const q = (args.search as string).toLowerCase();
|
||||
cogs = cogs.filter((c) => {
|
||||
const rec = c as Record<string, unknown>;
|
||||
return (
|
||||
rec["id"]?.toString().toLowerCase().includes(q) ||
|
||||
rec["name"]?.toString().toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({ ok: true, total: cogs.length, cogs }, null, 2) + "\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* ruview count — Person count commands.
|
||||
*
|
||||
* count infer — run single-shot person-count inference.
|
||||
*/
|
||||
|
||||
import type { Argv } from "yargs";
|
||||
import { runCog } from "../cog.js";
|
||||
import { loadConfig } from "../config.js";
|
||||
|
||||
export function countCommand(cli: Argv): void {
|
||||
cli.command(
|
||||
"count <action>",
|
||||
"Person count commands",
|
||||
(y) =>
|
||||
y
|
||||
.positional("action", {
|
||||
choices: ["infer"] as const,
|
||||
description: "Action to perform",
|
||||
})
|
||||
.option("window", {
|
||||
type: "string",
|
||||
description: "Path to a CSI window JSON file (omit to use live sensing-server)",
|
||||
})
|
||||
.option("binary", {
|
||||
type: "string",
|
||||
description: "Path to cog-person-count binary (default: RUVIEW_COUNT_COG_BINARY)",
|
||||
})
|
||||
.option("max-persons", {
|
||||
type: "number",
|
||||
default: 7,
|
||||
description: "Upper bound on person count (1–7, default: 7)",
|
||||
}),
|
||||
async (args) => {
|
||||
const config = loadConfig();
|
||||
const binary = (args["binary"] as string | undefined) ?? config.countCogBinary;
|
||||
|
||||
if (args.action === "infer") {
|
||||
const health = await runCog(binary, ["health"]);
|
||||
if (!health.ok) {
|
||||
process.stderr.write(
|
||||
`[WARN] Cog health check failed: ${health.error}\n` +
|
||||
`Set RUVIEW_COUNT_COG_BINARY or install cog-person-count (ADR-103).\n`
|
||||
);
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
warn: true,
|
||||
error: health.error,
|
||||
stub: true,
|
||||
result: {
|
||||
count: 0,
|
||||
confidence: 0,
|
||||
count_p95_low: 0,
|
||||
count_p95_high: 0,
|
||||
backend: "stub",
|
||||
latency_ms: 0,
|
||||
},
|
||||
}) + "\n"
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
stub: true,
|
||||
note: "M1 stub — real inference wired in M2. Cog health passed.",
|
||||
result: {
|
||||
ts: Date.now() / 1000,
|
||||
count: 0,
|
||||
confidence: 0,
|
||||
count_p95_low: 0,
|
||||
count_p95_high: 0,
|
||||
backend: "stub",
|
||||
latency_ms: 0,
|
||||
},
|
||||
}) + "\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* ruview csi — CSI frame commands.
|
||||
*
|
||||
* csi tail — stream live CSI frames from the sensing-server.
|
||||
*/
|
||||
|
||||
import type { Argv } from "yargs";
|
||||
import { sensingGet } from "../http.js";
|
||||
import { loadConfig } from "../config.js";
|
||||
|
||||
export function csiCommand(cli: Argv): void {
|
||||
cli.command(
|
||||
"csi <action>",
|
||||
"CSI frame commands",
|
||||
(y) =>
|
||||
y
|
||||
.positional("action", {
|
||||
choices: ["tail"] as const,
|
||||
description: "Action to perform",
|
||||
})
|
||||
.option("url", {
|
||||
type: "string",
|
||||
description:
|
||||
"Sensing-server URL (default: RUVIEW_SENSING_SERVER_URL or http://localhost:3000)",
|
||||
})
|
||||
.option("interval", {
|
||||
type: "number",
|
||||
default: 500,
|
||||
description: "Polling interval in milliseconds (default: 500)",
|
||||
}),
|
||||
async (args) => {
|
||||
const config = loadConfig();
|
||||
const baseUrl = (args["url"] as string | undefined) ?? config.sensingServerUrl;
|
||||
|
||||
if (args.action === "tail") {
|
||||
process.stderr.write(
|
||||
`[ruview csi tail] Streaming from ${baseUrl} every ${args.interval}ms. Ctrl-C to stop.\n`
|
||||
);
|
||||
|
||||
// Streaming poll loop.
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const result = await sensingGet<object>(
|
||||
baseUrl,
|
||||
"/api/v1/sensing/latest",
|
||||
config.apiToken
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
process.stderr.write(
|
||||
`[WARN] ${result.error} — retrying in ${args.interval}ms\n`
|
||||
);
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify(result.data) + "\n");
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, args.interval as number)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* ruview job — Job management commands.
|
||||
*
|
||||
* job status --id <job_id> — poll a background training job.
|
||||
*/
|
||||
|
||||
import type { Argv } from "yargs";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { loadConfig } from "../config.js";
|
||||
|
||||
export function jobCommand(cli: Argv): void {
|
||||
cli.command(
|
||||
"job <action>",
|
||||
"Job management commands",
|
||||
(y) =>
|
||||
y
|
||||
.positional("action", {
|
||||
choices: ["status"] as const,
|
||||
description: "Action to perform",
|
||||
})
|
||||
.option("id", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "Job ID returned by ruview train count",
|
||||
}),
|
||||
async (args) => {
|
||||
const config = loadConfig();
|
||||
|
||||
if (args.action === "status") {
|
||||
const jobId = args.id as string;
|
||||
const { default: path } = await import("node:path");
|
||||
const logPath = path.join(config.jobsDir, `${jobId}.log`);
|
||||
|
||||
if (!existsSync(logPath)) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: `Job ${jobId} not found at ${logPath}. ` +
|
||||
"The CLI process that started the job may have been restarted.",
|
||||
}) + "\n"
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const content = readFileSync(logPath, "utf8");
|
||||
const lines = content.split("\n");
|
||||
const recentLog = lines.slice(Math.max(0, lines.length - 20));
|
||||
|
||||
// Derive status from the log content.
|
||||
let status: string = "running";
|
||||
if (content.includes("# exit code: 0")) {
|
||||
status = "done";
|
||||
} else if (content.includes("# exit code:") || content.includes("# ERROR:")) {
|
||||
status = "failed";
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
job_id: jobId,
|
||||
status,
|
||||
log_path: logPath,
|
||||
recent_log: recentLog,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + "\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* ruview pose — Pose estimation commands.
|
||||
*
|
||||
* pose infer — run single-shot 17-keypoint inference.
|
||||
*/
|
||||
|
||||
import type { Argv } from "yargs";
|
||||
import { runCog } from "../cog.js";
|
||||
import { loadConfig } from "../config.js";
|
||||
|
||||
export function poseCommand(cli: Argv): void {
|
||||
cli.command(
|
||||
"pose <action>",
|
||||
"Pose estimation commands",
|
||||
(y) =>
|
||||
y
|
||||
.positional("action", {
|
||||
choices: ["infer"] as const,
|
||||
description: "Action to perform",
|
||||
})
|
||||
.option("window", {
|
||||
type: "string",
|
||||
description: "Path to a CSI window JSON file (omit to use live sensing-server)",
|
||||
})
|
||||
.option("binary", {
|
||||
type: "string",
|
||||
description: "Path to cog-pose-estimation binary (default: RUVIEW_POSE_COG_BINARY)",
|
||||
}),
|
||||
async (args) => {
|
||||
const config = loadConfig();
|
||||
const binary = (args["binary"] as string | undefined) ?? config.poseCogBinary;
|
||||
|
||||
if (args.action === "infer") {
|
||||
// M1: verify health, emit stub.
|
||||
const health = await runCog(binary, ["health"]);
|
||||
if (!health.ok) {
|
||||
process.stderr.write(
|
||||
`[WARN] Cog health check failed: ${health.error}\n` +
|
||||
`Set RUVIEW_POSE_COG_BINARY or install cog-pose-estimation (ADR-101).\n`
|
||||
);
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
warn: true,
|
||||
error: health.error,
|
||||
stub: true,
|
||||
result: { n_persons: 0, persons: [], backend: "stub", latency_ms: 0 },
|
||||
}) + "\n"
|
||||
);
|
||||
process.exit(0); // Fail-open; non-zero would break pipelines.
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
stub: true,
|
||||
note: "M1 stub — real inference wired in M2. Cog health passed.",
|
||||
result: {
|
||||
ts: Date.now() / 1000,
|
||||
n_persons: 0,
|
||||
persons: [],
|
||||
backend: "stub",
|
||||
latency_ms: 0,
|
||||
},
|
||||
}) + "\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* ruview train — Training commands.
|
||||
*
|
||||
* train count --paired <jsonl> — kick off a count-cog training run.
|
||||
*/
|
||||
|
||||
import type { Argv } from "yargs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, appendFileSync, openSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { spawn } from "node:child_process";
|
||||
import { loadConfig } from "../config.js";
|
||||
|
||||
export function trainCommand(cli: Argv): void {
|
||||
cli.command(
|
||||
"train <task>",
|
||||
"Training commands",
|
||||
(y) =>
|
||||
y
|
||||
.positional("task", {
|
||||
choices: ["count"] as const,
|
||||
description: "Which cog to train",
|
||||
})
|
||||
.option("paired", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description:
|
||||
"Path to the paired JSONL training file (produced by scripts/align-ground-truth.js)",
|
||||
})
|
||||
.option("epochs", {
|
||||
type: "number",
|
||||
default: 400,
|
||||
description: "Training epochs (default: 400)",
|
||||
})
|
||||
.option("lr", {
|
||||
type: "number",
|
||||
default: 1e-3,
|
||||
description: "Initial learning rate (default: 0.001)",
|
||||
})
|
||||
.option("output-dir", {
|
||||
type: "string",
|
||||
description: "Output directory for model artifacts",
|
||||
}),
|
||||
async (args) => {
|
||||
const config = loadConfig();
|
||||
const jobId = randomUUID();
|
||||
const logDir = config.jobsDir;
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
const logPath = path.join(logDir, `${jobId}.log`);
|
||||
const queuedAt = Date.now() / 1000;
|
||||
|
||||
const outputDir =
|
||||
(args["output-dir"] as string | undefined) ??
|
||||
"v2/crates/cog-person-count/cog/artifacts";
|
||||
|
||||
const header = [
|
||||
`# RuView training job ${jobId}`,
|
||||
`# started: ${new Date().toISOString()}`,
|
||||
`# task: ${args.task}`,
|
||||
`# paired: ${args.paired}`,
|
||||
`# epochs: ${args.epochs}`,
|
||||
`# lr: ${args.lr}`,
|
||||
`# output-dir: ${outputDir}`,
|
||||
"",
|
||||
].join("\n");
|
||||
appendFileSync(logPath, header);
|
||||
|
||||
const logFdOut = openSync(logPath, "a");
|
||||
const logFdErr = openSync(logPath, "a");
|
||||
|
||||
const cargoArgs = [
|
||||
"run",
|
||||
"--release",
|
||||
"-p",
|
||||
"wifi-densepose-train",
|
||||
"--",
|
||||
"--task",
|
||||
"count",
|
||||
"--paired",
|
||||
args.paired as string,
|
||||
"--epochs",
|
||||
String(args.epochs),
|
||||
"--lr",
|
||||
String(args.lr),
|
||||
"--output-dir",
|
||||
outputDir,
|
||||
];
|
||||
|
||||
const child = spawn("cargo", cargoArgs, {
|
||||
detached: true,
|
||||
stdio: ["ignore", logFdOut, logFdErr],
|
||||
});
|
||||
child.unref();
|
||||
|
||||
child.on("error", (e) => {
|
||||
appendFileSync(logPath, `\n# ERROR: ${e.message}\n`);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
appendFileSync(logPath, `\n# exit code: ${code}\n`);
|
||||
});
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
job_id: jobId,
|
||||
status: "running",
|
||||
log_path: logPath,
|
||||
queued_at: queuedAt,
|
||||
note: `Poll with: ruview job status --id ${jobId}`,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + "\n"
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Configuration loader for the RuView CLI.
|
||||
* Mirrors tools/ruview-mcp/src/config.ts — sourced from environment variables.
|
||||
*/
|
||||
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export interface RuviewCliConfig {
|
||||
sensingServerUrl: string;
|
||||
apiToken: string | undefined;
|
||||
poseCogBinary: string;
|
||||
countCogBinary: string;
|
||||
jobsDir: string;
|
||||
}
|
||||
|
||||
function envOrDefault(key: string, fallback: string): string {
|
||||
return process.env[key] ?? fallback;
|
||||
}
|
||||
|
||||
export function loadConfig(): RuviewCliConfig {
|
||||
return {
|
||||
sensingServerUrl: envOrDefault(
|
||||
"RUVIEW_SENSING_SERVER_URL",
|
||||
"http://localhost:3000"
|
||||
),
|
||||
apiToken: process.env["RUVIEW_API_TOKEN"],
|
||||
poseCogBinary: envOrDefault("RUVIEW_POSE_COG_BINARY", "cog-pose-estimation"),
|
||||
countCogBinary: envOrDefault("RUVIEW_COUNT_COG_BINARY", "cog-person-count"),
|
||||
jobsDir: envOrDefault(
|
||||
"RUVIEW_JOBS_DIR",
|
||||
path.join(os.homedir(), ".ruview", "jobs")
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Lightweight HTTP client (re-used in CLI commands).
|
||||
* Identical to tools/ruview-mcp/src/http.ts but kept separate to avoid a
|
||||
* workspace dependency — both packages are standalone and independently publishable.
|
||||
*/
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
export type Ok<T> = { ok: true; data: T };
|
||||
export type Err = { ok: false; error: string };
|
||||
export type Result<T> = Ok<T> | Err;
|
||||
|
||||
export function ok<T>(data: T): Ok<T> {
|
||||
return { ok: true, data };
|
||||
}
|
||||
|
||||
export function err(error: string): Err {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
export async function sensingGet<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
token: string | undefined
|
||||
): Promise<Result<T>> {
|
||||
const url = `${baseUrl.replace(/\/$/, "")}${path}`;
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { headers, signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) {
|
||||
return err(`HTTP ${res.status} from ${url}: ${await res.text().catch(() => "(no body)")}`);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
return err(`Non-JSON response from ${url}`);
|
||||
}
|
||||
return ok(body as T);
|
||||
} catch (e: unknown) {
|
||||
clearTimeout(timer);
|
||||
if (e instanceof Error && e.name === "AbortError") {
|
||||
return err(`Request to ${url} timed out after ${REQUEST_TIMEOUT_MS}ms`);
|
||||
}
|
||||
return err(`Network error fetching ${url}: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @ruv/ruview-cli — RuView CLI
|
||||
*
|
||||
* Shell access to RuView sensing, inference, and training capabilities.
|
||||
*
|
||||
* Subcommands:
|
||||
* ruview csi tail [--url <url>] stream live CSI frames
|
||||
* ruview pose infer [--window <path>] 17-keypoint pose estimation
|
||||
* ruview count infer [--window <path>] person-count inference
|
||||
* ruview cogs list [--category <cat>] [--search q] list edge module registry
|
||||
* ruview train count --paired <jsonl> kick off count-cog training
|
||||
* ruview job status --id <job_id> poll a training job
|
||||
*
|
||||
* All subcommands write JSON to stdout and exit 0 on success.
|
||||
* WARN-level outputs write to stderr; the exit code is still 0 so pipelines
|
||||
* are not broken by a temporarily unreachable sensing-server.
|
||||
*
|
||||
* Usage:
|
||||
* npx ruview --version
|
||||
* npx ruview csi tail
|
||||
* npx ruview pose infer --window ./window.json
|
||||
* RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 npx ruview cogs list
|
||||
*
|
||||
* See ADR-104 for the full design rationale and security model.
|
||||
*/
|
||||
|
||||
import yargs from "yargs";
|
||||
import { hideBin } from "yargs/helpers";
|
||||
import { csiCommand } from "./commands/csi.js";
|
||||
import { poseCommand } from "./commands/pose.js";
|
||||
import { countCommand } from "./commands/count.js";
|
||||
import { cogsCommand } from "./commands/cogs.js";
|
||||
import { trainCommand } from "./commands/train.js";
|
||||
import { jobCommand } from "./commands/job.js";
|
||||
|
||||
const cli = yargs(hideBin(process.argv))
|
||||
.scriptName("ruview")
|
||||
.version("0.0.1")
|
||||
.usage("$0 <command> [options]")
|
||||
.strict()
|
||||
.help()
|
||||
.wrap(100);
|
||||
|
||||
// Register all top-level commands.
|
||||
csiCommand(cli);
|
||||
poseCommand(cli);
|
||||
countCommand(cli);
|
||||
cogsCommand(cli);
|
||||
trainCommand(cli);
|
||||
jobCommand(cli);
|
||||
|
||||
cli.demandCommand(1, "Specify a subcommand. Use --help for a list.").parse();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** @type {import('jest').Config} */
|
||||
export default {
|
||||
preset: "ts-jest/presets/default-esm",
|
||||
testEnvironment: "node",
|
||||
extensionsToTreatAsEsm: [".ts"],
|
||||
moduleNameMapper: {
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1",
|
||||
},
|
||||
transform: {
|
||||
"^.+\\.tsx?$": [
|
||||
"ts-jest",
|
||||
{
|
||||
useESM: true,
|
||||
tsconfig: "tests/tsconfig.json",
|
||||
},
|
||||
],
|
||||
},
|
||||
testMatch: ["**/tests/**/*.test.ts"],
|
||||
collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts"],
|
||||
};
|
||||
Generated
+5133
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@ruv/ruview-mcp",
|
||||
"version": "0.0.1",
|
||||
"description": "RuView MCP server — expose WiFi-DensePose sensing capabilities as MCP tools for Claude Code, Cursor, and other MCP-compatible agents",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"ruview-mcp": "dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"start": "node dist/index.js",
|
||||
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --forceExit",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"ruview",
|
||||
"wifi",
|
||||
"csi",
|
||||
"pose-estimation",
|
||||
"cognitum"
|
||||
],
|
||||
"author": "ruv <ruv@ruv.net>",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Subprocess wrapper for Cognitum Cog binaries.
|
||||
*
|
||||
* The cog binaries implement the ADR-100 runtime contract:
|
||||
* cog-<id> version
|
||||
* cog-<id> manifest
|
||||
* cog-<id> health
|
||||
* cog-<id> run --config <path>
|
||||
*
|
||||
* This module shells out to those binaries. If the binary is absent or returns
|
||||
* a non-zero exit code, the call fails-open with a WARN-level structured error
|
||||
* (same pattern cog-pose-estimation uses for missing model weights).
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import type { Result } from "./http.js";
|
||||
import { ok, err } from "./http.js";
|
||||
|
||||
const COG_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Run a cog binary with the given subcommand arguments.
|
||||
* Returns stdout as a string on success, or an error message.
|
||||
*/
|
||||
export async function runCog(
|
||||
binary: string,
|
||||
args: string[]
|
||||
): Promise<Result<string>> {
|
||||
return new Promise((resolve) => {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
const child = spawn(binary, args, {
|
||||
timeout: COG_TIMEOUT_MS,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on("error", (e) => {
|
||||
resolve(
|
||||
err(
|
||||
`Failed to launch cog binary "${binary}" (${args.join(" ")}): ${e.message}. ` +
|
||||
`Set RUVIEW_POSE_COG_BINARY / RUVIEW_COUNT_COG_BINARY to the installed path, ` +
|
||||
`or install the cog on the Cognitum appliance first.`
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
resolve(
|
||||
err(
|
||||
`Cog "${binary} ${args.join(" ")}" exited with code ${code}. ` +
|
||||
`stderr: ${stderr.trim() || "(empty)"}`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
resolve(ok(stdout));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Call `cog-<id> health` and return the exit code + output.
|
||||
*/
|
||||
export async function cogHealth(binary: string): Promise<Result<string>> {
|
||||
return runCog(binary, ["health"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call `cog-<id> version` and return the version string.
|
||||
*/
|
||||
export async function cogVersion(binary: string): Promise<Result<string>> {
|
||||
return runCog(binary, ["version"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a cog inference with a synthetic CSI window piped via a temp config.
|
||||
*
|
||||
* The ADR-100 contract doesn't define a single-shot "infer" subcommand — the
|
||||
* cog's `run` subcommand is long-running. Instead, we:
|
||||
* 1. Verify health returns 0.
|
||||
* 2. Emit a WARN explaining that single-shot inference requires a live
|
||||
* sensing-server connection, then return a stub result.
|
||||
*
|
||||
* Full single-shot inference (M2 milestone) will use the sensing-server's
|
||||
* `/api/v1/sensing/latest` to build a real CSI window and feed it through the
|
||||
* cog via a short-lived `run` session.
|
||||
*/
|
||||
export async function cogInferStub(
|
||||
binary: string,
|
||||
taskLabel: string
|
||||
): Promise<Result<{ backend: string; latency_ms: number; stub: true }>> {
|
||||
const health = await cogHealth(binary);
|
||||
if (!health.ok) {
|
||||
return err(
|
||||
`[WARN] ${taskLabel} cog health check failed — ${health.error}. ` +
|
||||
`Returning stub result. Install the cog or set the correct binary path.`
|
||||
);
|
||||
}
|
||||
return ok({
|
||||
backend: "stub",
|
||||
latency_ms: 0,
|
||||
stub: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Configuration loader for the RuView MCP server.
|
||||
*
|
||||
* All settings can be overridden via environment variables. No config file is
|
||||
* required — the server is designed to work out of the box with a locally-running
|
||||
* sensing-server on the default port.
|
||||
*/
|
||||
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { RuviewConfig } from "./types.js";
|
||||
|
||||
function env(key: string): string | undefined {
|
||||
return process.env[key];
|
||||
}
|
||||
|
||||
function envOrDefault(key: string, fallback: string): string {
|
||||
return env(key) ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the effective RuviewConfig from environment variables.
|
||||
*
|
||||
* Environment variables:
|
||||
* RUVIEW_SENSING_SERVER_URL — base URL of the sensing-server (default: http://localhost:3000)
|
||||
* RUVIEW_API_TOKEN — Bearer token for /api/v1/* routes (no default; auth disabled when absent)
|
||||
* RUVIEW_POSE_COG_BINARY — path to cog-pose-estimation binary
|
||||
* RUVIEW_COUNT_COG_BINARY — path to cog-person-count binary
|
||||
* RUVIEW_JOBS_DIR — directory for job logs (default: ~/.ruview/jobs)
|
||||
*/
|
||||
export function loadConfig(): RuviewConfig {
|
||||
return {
|
||||
sensingServerUrl: envOrDefault(
|
||||
"RUVIEW_SENSING_SERVER_URL",
|
||||
"http://localhost:3000"
|
||||
),
|
||||
apiToken: env("RUVIEW_API_TOKEN"),
|
||||
poseCogBinary: envOrDefault(
|
||||
"RUVIEW_POSE_COG_BINARY",
|
||||
detectCogBinary("cog-pose-estimation")
|
||||
),
|
||||
countCogBinary: envOrDefault(
|
||||
"RUVIEW_COUNT_COG_BINARY",
|
||||
detectCogBinary("cog-person-count")
|
||||
),
|
||||
jobsDir: envOrDefault(
|
||||
"RUVIEW_JOBS_DIR",
|
||||
path.join(os.homedir(), ".ruview", "jobs")
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to locate a cog binary on PATH or in common install locations.
|
||||
* Returns the bare binary name if not found (will fail gracefully at invocation).
|
||||
*/
|
||||
function detectCogBinary(name: string): string {
|
||||
// Common install paths for Cognitum cog binaries on Linux/macOS appliances.
|
||||
const candidates = [
|
||||
`/var/lib/cognitum/apps/${name.replace("cog-", "")}/cog-${name.replace("cog-", "")}-arm`,
|
||||
`/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;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Lightweight HTTP client for the RuView sensing-server.
|
||||
*
|
||||
* Uses Node's built-in `fetch` (available since Node 18). All requests respect
|
||||
* the optional RUVIEW_API_TOKEN bearer header and a 10-second hard timeout.
|
||||
*
|
||||
* Failure model: every public function returns a typed `Result<T>` tuple to
|
||||
* avoid try/catch proliferation in callers.
|
||||
*/
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
export type Ok<T> = { ok: true; data: T };
|
||||
export type Err = { ok: false; error: string };
|
||||
export type Result<T> = Ok<T> | Err;
|
||||
|
||||
export function ok<T>(data: T): Ok<T> {
|
||||
return { ok: true, data };
|
||||
}
|
||||
|
||||
export function err(error: string): Err {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an authenticated GET against the sensing-server.
|
||||
*/
|
||||
export async function sensingGet<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
token: string | undefined
|
||||
): Promise<Result<T>> {
|
||||
const url = `${baseUrl.replace(/\/$/, "")}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!res.ok) {
|
||||
return err(`HTTP ${res.status} from ${url}: ${await res.text().catch(() => "(no body)")}`);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
return err(`Non-JSON response from ${url}`);
|
||||
}
|
||||
|
||||
return ok(body as T);
|
||||
} catch (e: unknown) {
|
||||
clearTimeout(timer);
|
||||
if (e instanceof Error && e.name === "AbortError") {
|
||||
return err(`Request to ${url} timed out after ${REQUEST_TIMEOUT_MS} ms`);
|
||||
}
|
||||
return err(`Network error fetching ${url}: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @ruv/ruview-mcp — RuView MCP Server
|
||||
*
|
||||
* Exposes RuView's WiFi-DensePose sensing capabilities as Model Context Protocol
|
||||
* (MCP) tools that Claude Code, Cursor, Codex, and other MCP-compatible agents
|
||||
* can call directly.
|
||||
*
|
||||
* Tools exposed:
|
||||
* ruview_csi_latest — pull the latest CSI window from the sensing-server
|
||||
* ruview_pose_infer — single-shot 17-keypoint pose estimation
|
||||
* ruview_count_infer — single-shot person count with confidence interval
|
||||
* 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
|
||||
*
|
||||
* Usage:
|
||||
* node dist/index.js # stdio transport (default)
|
||||
* RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 node dist/index.js
|
||||
*
|
||||
* To register with Claude Code:
|
||||
* claude mcp add ruview -- node /path/to/tools/ruview-mcp/dist/index.js
|
||||
*
|
||||
* See ADR-104 for the full design rationale and security model.
|
||||
*/
|
||||
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import { loadConfig } from "./config.js";
|
||||
import { csiLatestSchema, csiLatest } from "./tools/csi-latest.js";
|
||||
import { poseInferSchema, poseInfer } from "./tools/pose-infer.js";
|
||||
import { countInferSchema, countInfer } from "./tools/count-infer.js";
|
||||
import { registryListSchema, registryList } from "./tools/registry-list.js";
|
||||
import {
|
||||
trainCountSchema,
|
||||
trainCount,
|
||||
jobStatusSchema,
|
||||
jobStatus,
|
||||
} from "./tools/train-count.js";
|
||||
|
||||
const PACKAGE_VERSION = "0.0.1";
|
||||
const SERVER_NAME = "ruview";
|
||||
|
||||
// ── Tool registry ──────────────────────────────────────────────────────────
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
name: "ruview_csi_latest",
|
||||
description:
|
||||
"Pull the latest CSI window from a running wifi-densepose-sensing-server. " +
|
||||
"Returns 56-subcarrier × 20-frame amplitude/phase arrays suitable for " +
|
||||
"downstream inference or research analysis.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
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",
|
||||
description:
|
||||
"Run a single-shot 17-keypoint COCO pose estimation inference using the " +
|
||||
"cog-pose-estimation Cog binary (ADR-101). Accepts a CSI window JSON file " +
|
||||
"or uses the live sensing-server if no window is provided. " +
|
||||
"Returns [{keypoints: [[x,y]×17], confidence}] per detected person.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
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",
|
||||
description:
|
||||
"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} " +
|
||||
"with a Stoer-Wagner multi-node fusion upper bound when multiple nodes are active.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
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",
|
||||
description:
|
||||
"List cogs from the Cognitum edge module registry (ADR-102). " +
|
||||
"Fetches /api/v1/edge/registry from the sensing-server, which proxies the " +
|
||||
"canonical GCS catalog (105 cogs, 11 categories). Supports category filter and search.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
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",
|
||||
description:
|
||||
"Kick off a cog-person-count training run using the Candle GPU trainer " +
|
||||
"(ADR-103). The paired JSONL file provides CSI windows + camera-derived " +
|
||||
"person-count labels. Returns a job_id to poll with ruview_job_status.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
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",
|
||||
description:
|
||||
"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.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
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);
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
// ── Server bootstrap ────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: SERVER_NAME,
|
||||
version: PACKAGE_VERSION,
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// List tools handler.
|
||||
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
||||
tools: TOOLS.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Call tool handler.
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
const tool = TOOLS.find((t) => t.name === name);
|
||||
|
||||
if (!tool) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
ok: false,
|
||||
error: `Unknown tool "${name}". Available tools: ${TOOLS.map((t) => t.name).join(", ")}`,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.handler(args ?? {}, config);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
ok: false,
|
||||
error: message,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Wire up stdio transport.
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
// Log to stderr so it doesn't interfere with the MCP stdio protocol.
|
||||
process.stderr.write(
|
||||
`[ruview-mcp] Server v${PACKAGE_VERSION} started. ` +
|
||||
`Sensing server: ${config.sensingServerUrl}\n`
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
process.stderr.write(`[ruview-mcp] Fatal: ${String(e)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* MCP tool: ruview_count_infer
|
||||
*
|
||||
* Run a single-shot person-count inference against a CSI window.
|
||||
*
|
||||
* Uses the cog-person-count binary (ADR-103). The output includes a
|
||||
* calibrated confidence score and a 95% prediction interval, matching the
|
||||
* Stoer-Wagner + confidence-weighted log-sum fusion design in ADR-103.
|
||||
*
|
||||
* M1 (this file): stubs the inference after verifying the cog binary is healthy.
|
||||
* M2 wires the real forward pass.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import type { RuviewConfig, CountInferResult } from "../types.js";
|
||||
import { cogInferStub } from "../cog.js";
|
||||
|
||||
export const countInferSchema = z.object({
|
||||
/**
|
||||
* Path to a CSI window JSON file.
|
||||
* Optional — when absent, uses the latest window from the sensing-server.
|
||||
*/
|
||||
window_path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Path to a CSI window JSON file. Omit to use the live sensing-server."),
|
||||
/** Override the cog binary path for this call. */
|
||||
cog_binary: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Path to cog-person-count binary. Default: RUVIEW_COUNT_COG_BINARY env var."),
|
||||
/**
|
||||
* Maximum number of persons to consider in the output distribution.
|
||||
* Capped at 7 per the count head's softmax over {0..7}.
|
||||
*/
|
||||
max_persons: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(7)
|
||||
.optional()
|
||||
.default(7)
|
||||
.describe("Upper bound on person count (1–7). Default: 7."),
|
||||
});
|
||||
|
||||
export type CountInferInput = z.infer<typeof countInferSchema>;
|
||||
|
||||
export async function countInfer(
|
||||
input: CountInferInput,
|
||||
config: RuviewConfig
|
||||
): Promise<object> {
|
||||
const binary = input.cog_binary ?? config.countCogBinary;
|
||||
|
||||
const stubResult = await cogInferStub(binary, "count");
|
||||
|
||||
if (!stubResult.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
warn: true,
|
||||
error: stubResult.error,
|
||||
hint:
|
||||
"Set RUVIEW_COUNT_COG_BINARY to the path of the cog-person-count binary. " +
|
||||
"Install it from gs://cognitum-apps/cogs/<arch>/cog-person-count-<arch>. " +
|
||||
"See ADR-103 for installation instructions.",
|
||||
};
|
||||
}
|
||||
|
||||
const ts = Date.now() / 1000;
|
||||
const result: CountInferResult = {
|
||||
ts,
|
||||
count: 0,
|
||||
confidence: 0,
|
||||
count_p95_low: 0,
|
||||
count_p95_high: 0,
|
||||
backend: stubResult.data.backend,
|
||||
latency_ms: stubResult.data.latency_ms,
|
||||
};
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
stub: stubResult.data.stub,
|
||||
note:
|
||||
"M1 stub — real inference wired in M2. " +
|
||||
"Cog health check passed; binary is reachable.",
|
||||
result,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* MCP tool: ruview_csi_latest
|
||||
*
|
||||
* Pull the most recent CSI window from the local sensing-server.
|
||||
* Wraps GET /api/v1/sensing/latest (ADR-102 endpoint, schema version 2).
|
||||
*
|
||||
* Returns the full CsiWindow JSON so the calling agent can inspect raw
|
||||
* subcarrier data, feed it to ruview_pose_infer, or store it for analysis.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import type { RuviewConfig, SensingLatestResponse } from "../types.js";
|
||||
import { sensingGet } from "../http.js";
|
||||
|
||||
export const csiLatestSchema = z.object({
|
||||
/** Override the sensing-server URL for this call only. */
|
||||
sensing_server_url: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.describe(
|
||||
"Base URL of the sensing-server (default: RUVIEW_SENSING_SERVER_URL or http://localhost:3000)"
|
||||
),
|
||||
});
|
||||
|
||||
export type CsiLatestInput = z.infer<typeof csiLatestSchema>;
|
||||
|
||||
export async function csiLatest(
|
||||
input: CsiLatestInput,
|
||||
config: RuviewConfig
|
||||
): Promise<object> {
|
||||
const baseUrl = input.sensing_server_url ?? config.sensingServerUrl;
|
||||
|
||||
const result = await sensingGet<SensingLatestResponse>(
|
||||
baseUrl,
|
||||
"/api/v1/sensing/latest",
|
||||
config.apiToken
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
warn: true,
|
||||
error: result.error,
|
||||
hint:
|
||||
"Ensure the wifi-densepose-sensing-server is running. " +
|
||||
"Start it with `cargo run -p wifi-densepose-sensing-server` or " +
|
||||
"set RUVIEW_SENSING_SERVER_URL to the correct address.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
ts: result.data.window.ts,
|
||||
schema_version: result.data.schema_version,
|
||||
captured_at: result.data.captured_at,
|
||||
n_paths: result.data.window.n_paths,
|
||||
node_mac: result.data.window.node_mac,
|
||||
subcarriers: 56,
|
||||
frames: result.data.window.amplitudes[0]?.length ?? 0,
|
||||
window: result.data.window,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* MCP tool: ruview_pose_infer
|
||||
*
|
||||
* Run a single-shot pose estimation inference against a CSI window.
|
||||
*
|
||||
* M1 (this file): stubs the inference after verifying the cog binary is healthy.
|
||||
* M2 wires the real forward pass via the sensing-server CSI window + cog `run`.
|
||||
*
|
||||
* The 17 COCO keypoints in the output follow the standard COCO body ordering:
|
||||
* 0=nose, 1=left_eye, 2=right_eye, 3=left_ear, 4=right_ear,
|
||||
* 5=left_shoulder, 6=right_shoulder, 7=left_elbow, 8=right_elbow,
|
||||
* 9=left_wrist, 10=right_wrist, 11=left_hip, 12=right_hip,
|
||||
* 13=left_knee, 14=right_knee, 15=left_ankle, 16=right_ankle
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import type { RuviewConfig, PoseInferResult } from "../types.js";
|
||||
import { cogInferStub } from "../cog.js";
|
||||
|
||||
export const poseInferSchema = z.object({
|
||||
/**
|
||||
* Path to a CSI window JSON file (as produced by ruview_csi_latest or
|
||||
* examples/research-sota/r5_subcarrier_saliency.py).
|
||||
* Optional — when absent, uses the latest window from the sensing-server.
|
||||
*/
|
||||
window_path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Path to a CSI window JSON file. Omit to use the live sensing-server."),
|
||||
/** Override the cog binary path for this call. */
|
||||
cog_binary: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Path to cog-pose-estimation binary. Default: RUVIEW_POSE_COG_BINARY env var."),
|
||||
});
|
||||
|
||||
export type PoseInferInput = z.infer<typeof poseInferSchema>;
|
||||
|
||||
export async function poseInfer(
|
||||
input: PoseInferInput,
|
||||
config: RuviewConfig
|
||||
): Promise<object> {
|
||||
const binary = input.cog_binary ?? config.poseCogBinary;
|
||||
|
||||
// M1: health-check the cog, return stub keypoints.
|
||||
// M2: replace stub with real CSI window + cog run session.
|
||||
const stubResult = await cogInferStub(binary, "pose");
|
||||
|
||||
if (!stubResult.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
warn: true,
|
||||
error: stubResult.error,
|
||||
hint:
|
||||
"Set RUVIEW_POSE_COG_BINARY to the path of the cog-pose-estimation binary. " +
|
||||
"Install it from gs://cognitum-apps/cogs/<arch>/cog-pose-estimation-<arch>. " +
|
||||
"See ADR-101 for installation instructions.",
|
||||
};
|
||||
}
|
||||
|
||||
const ts = Date.now() / 1000;
|
||||
const result: PoseInferResult = {
|
||||
ts,
|
||||
n_persons: 0,
|
||||
persons: [],
|
||||
backend: stubResult.data.backend,
|
||||
latency_ms: stubResult.data.latency_ms,
|
||||
};
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
stub: stubResult.data.stub,
|
||||
note:
|
||||
"M1 stub — real inference wired in M2. " +
|
||||
"Cog health check passed; binary is reachable.",
|
||||
result,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* MCP tool: ruview_registry_list
|
||||
*
|
||||
* List installed/available cogs from the Cognitum edge module registry.
|
||||
*
|
||||
* Fetches `/api/v1/edge/registry` from the sensing-server, which proxies the
|
||||
* canonical GCS catalog with a 1-hour TTL cache (ADR-102). The result is the
|
||||
* full 105-cog catalog as of the last upstream sync.
|
||||
*
|
||||
* Use the optional `category` filter to narrow results. Available categories
|
||||
* (from the v2.1.0 registry): health, security, building, retail, industrial,
|
||||
* research, ai, swarm, signal, network, developer.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import type { RuviewConfig, RegistryListResult, CogEntry } from "../types.js";
|
||||
import { sensingGet } from "../http.js";
|
||||
|
||||
export const registryListSchema = z.object({
|
||||
/** Filter cogs by category. */
|
||||
category: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Filter by category (health, security, building, retail, industrial, " +
|
||||
"research, ai, swarm, signal, network, developer). Omit for all."
|
||||
),
|
||||
/** Filter cogs whose id or name contains this substring (case-insensitive). */
|
||||
search: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Search substring matched against cog id and name (case-insensitive)."),
|
||||
/** Force-bypass the sensing-server's 1-hour cache. */
|
||||
refresh: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe("Bypass the 1-hour registry cache. Use sparingly."),
|
||||
/** Override the sensing-server URL for this call only. */
|
||||
sensing_server_url: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.describe("Override the sensing-server URL."),
|
||||
});
|
||||
|
||||
export type RegistryListInput = z.infer<typeof registryListSchema>;
|
||||
|
||||
// The upstream registry JSON shape (ADR-102).
|
||||
interface UpstreamRegistryPayload {
|
||||
registry: {
|
||||
cogs?: CogEntry[];
|
||||
apps?: CogEntry[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
fetched_at: number;
|
||||
ttl_seconds: number;
|
||||
stale: boolean;
|
||||
upstream_url: string;
|
||||
upstream_sha256: string;
|
||||
}
|
||||
|
||||
export async function registryList(
|
||||
input: RegistryListInput,
|
||||
config: RuviewConfig
|
||||
): Promise<object> {
|
||||
const baseUrl = input.sensing_server_url ?? config.sensingServerUrl;
|
||||
const qs = input.refresh ? "?refresh=1" : "";
|
||||
|
||||
const result = await sensingGet<UpstreamRegistryPayload>(
|
||||
baseUrl,
|
||||
`/api/v1/edge/registry${qs}`,
|
||||
config.apiToken
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
warn: true,
|
||||
error: result.error,
|
||||
hint:
|
||||
"Ensure the sensing-server is running and the edge registry endpoint is enabled. " +
|
||||
"See ADR-102 for configuration (--no-edge-registry disables it).",
|
||||
};
|
||||
}
|
||||
|
||||
const payload = result.data;
|
||||
// Registry entries may be under `cogs` or `apps` depending on the catalog version.
|
||||
let cogs: CogEntry[] = (payload.registry.cogs ?? payload.registry.apps ?? []) as CogEntry[];
|
||||
|
||||
// Apply filters.
|
||||
if (input.category) {
|
||||
const cat = input.category.toLowerCase();
|
||||
cogs = cogs.filter((c) => c.category?.toLowerCase() === cat);
|
||||
}
|
||||
if (input.search) {
|
||||
const q = input.search.toLowerCase();
|
||||
cogs = cogs.filter(
|
||||
(c) =>
|
||||
c.id?.toLowerCase().includes(q) || c.name?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
const out: RegistryListResult = {
|
||||
fetched_at: payload.fetched_at,
|
||||
ttl_seconds: payload.ttl_seconds,
|
||||
stale: payload.stale,
|
||||
upstream_url: payload.upstream_url,
|
||||
upstream_sha256: payload.upstream_sha256,
|
||||
cogs,
|
||||
};
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
total_cogs: cogs.length,
|
||||
...out,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* MCP tool: ruview_train_count + ruview_job_status
|
||||
*
|
||||
* Kick off a cog-person-count training run and poll its status.
|
||||
*
|
||||
* The training pipeline used here is the Candle GPU trainer from
|
||||
* `v2/crates/wifi-densepose-train` — the same one that produced
|
||||
* `count_v1.safetensors` in 2.1 s on the RTX 5080 (ADR-103).
|
||||
*
|
||||
* The MCP server shells out to `cargo run -p wifi-densepose-train --` with the
|
||||
* paired JSONL path as input, redirecting stdout/stderr to a log file. The
|
||||
* returned job_id can be used with ruview_job_status to poll progress.
|
||||
*
|
||||
* M1: job is enqueued (background process spawned, log file created).
|
||||
* M4: full training arguments + real output artifact path returned.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, appendFileSync, openSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import type { RuviewConfig, TrainJobResult, JobStatusResult } from "../types.js";
|
||||
|
||||
export const trainCountSchema = z.object({
|
||||
/**
|
||||
* Path to the paired JSONL file for training.
|
||||
* Produced by scripts/align-ground-truth.js.
|
||||
* E.g. data/paired/wiflow-p7-2026-05-19.paired.jsonl
|
||||
*/
|
||||
paired_jsonl: z
|
||||
.string()
|
||||
.describe("Absolute or relative path to the paired JSONL training file."),
|
||||
/** Number of training epochs (default: 400, matching ADR-103 recipe). */
|
||||
epochs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(10_000)
|
||||
.optional()
|
||||
.default(400)
|
||||
.describe("Training epochs (default: 400)."),
|
||||
/**
|
||||
* Learning rate. The ADR-103 recipe uses 1e-3 with frozen encoder for the
|
||||
* first 50 epochs, then 1e-4 for joint fine-tuning.
|
||||
*/
|
||||
learning_rate: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(1e-3)
|
||||
.describe("Initial learning rate (default: 0.001)."),
|
||||
/** Directory where the trained model artifacts are written. */
|
||||
output_dir: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Directory for model artifacts (default: v2/crates/cog-person-count/cog/artifacts/)."
|
||||
),
|
||||
});
|
||||
|
||||
export type TrainCountInput = z.infer<typeof trainCountSchema>;
|
||||
|
||||
export const jobStatusSchema = z.object({
|
||||
job_id: z.string().uuid().describe("Job ID returned by ruview_train_count."),
|
||||
});
|
||||
|
||||
export type JobStatusInput = z.infer<typeof jobStatusSchema>;
|
||||
|
||||
// In-process job registry (survives for the lifetime of the MCP server process).
|
||||
// For a production implementation, persist to ~/.ruview/jobs/<id>.json.
|
||||
const jobRegistry = new Map<
|
||||
string,
|
||||
{
|
||||
status: "queued" | "running" | "done" | "failed";
|
||||
log_path: string;
|
||||
queued_at: number;
|
||||
epochs_total: number;
|
||||
}
|
||||
>();
|
||||
|
||||
export async function trainCount(
|
||||
input: TrainCountInput,
|
||||
config: RuviewConfig
|
||||
): Promise<object> {
|
||||
const jobId = randomUUID();
|
||||
const logDir = config.jobsDir;
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
const logPath = path.join(logDir, `${jobId}.log`);
|
||||
const queuedAt = Date.now() / 1000;
|
||||
|
||||
// Default output directory matches ADR-103 repo layout.
|
||||
const outputDir =
|
||||
input.output_dir ?? "v2/crates/cog-person-count/cog/artifacts";
|
||||
|
||||
// Record the job immediately so ruview_job_status can find it.
|
||||
jobRegistry.set(jobId, {
|
||||
status: "queued",
|
||||
log_path: logPath,
|
||||
queued_at: queuedAt,
|
||||
epochs_total: input.epochs,
|
||||
});
|
||||
|
||||
// Write the header synchronously so the log file exists before spawn.
|
||||
const header = [
|
||||
`# RuView training job ${jobId}`,
|
||||
`# started: ${new Date().toISOString()}`,
|
||||
`# paired_jsonl: ${input.paired_jsonl}`,
|
||||
`# epochs: ${input.epochs}`,
|
||||
`# learning_rate: ${input.learning_rate}`,
|
||||
`# output_dir: ${outputDir}`,
|
||||
"",
|
||||
].join("\n");
|
||||
appendFileSync(logPath, header);
|
||||
|
||||
// Open log file descriptors synchronously (avoids WriteStream-before-open bug on Windows).
|
||||
const logFdOut = openSync(logPath, "a");
|
||||
const logFdErr = openSync(logPath, "a");
|
||||
|
||||
const args = [
|
||||
"run",
|
||||
"--release",
|
||||
"-p",
|
||||
"wifi-densepose-train",
|
||||
"--",
|
||||
"--task",
|
||||
"count",
|
||||
"--paired",
|
||||
input.paired_jsonl,
|
||||
"--epochs",
|
||||
String(input.epochs),
|
||||
"--lr",
|
||||
String(input.learning_rate),
|
||||
"--output-dir",
|
||||
outputDir,
|
||||
];
|
||||
|
||||
// M1: cargo may not be on PATH on non-Rust machines — spawn fails gracefully.
|
||||
const child = spawn("cargo", args, {
|
||||
detached: true,
|
||||
stdio: ["ignore", logFdOut, logFdErr],
|
||||
});
|
||||
|
||||
child.unref(); // Allow the MCP server process to exit without waiting for training.
|
||||
|
||||
const entry = jobRegistry.get(jobId);
|
||||
if (entry) {
|
||||
entry.status = "running";
|
||||
}
|
||||
|
||||
child.on("error", (e) => {
|
||||
appendFileSync(logPath, `\n# ERROR: ${e.message}\n`);
|
||||
const rec = jobRegistry.get(jobId);
|
||||
if (rec) rec.status = "failed";
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
appendFileSync(logPath, `\n# exit code: ${code}\n`);
|
||||
const rec = jobRegistry.get(jobId);
|
||||
if (rec) rec.status = code === 0 ? "done" : "failed";
|
||||
});
|
||||
|
||||
const result: TrainJobResult = {
|
||||
job_id: jobId,
|
||||
status: "running",
|
||||
log_path: logPath,
|
||||
queued_at: queuedAt,
|
||||
};
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
result,
|
||||
note:
|
||||
"Training job spawned in the background. " +
|
||||
`Poll progress with ruview_job_status({ job_id: "${jobId}" }). ` +
|
||||
`Live log: ${logPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function jobStatus(
|
||||
input: JobStatusInput,
|
||||
_config: RuviewConfig
|
||||
): Promise<object> {
|
||||
const job = jobRegistry.get(input.job_id);
|
||||
if (!job) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Job ${input.job_id} not found. ` +
|
||||
"The MCP server may have restarted — check the log directory directly.",
|
||||
};
|
||||
}
|
||||
|
||||
// Read the last 20 lines of the log file.
|
||||
let recentLog: string[] = [];
|
||||
try {
|
||||
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 {
|
||||
recentLog = ["(log not readable yet)"];
|
||||
}
|
||||
|
||||
const result: JobStatusResult = {
|
||||
job_id: input.job_id,
|
||||
status: job.status,
|
||||
log_path: job.log_path,
|
||||
recent_log: recentLog,
|
||||
epochs_total: job.epochs_total,
|
||||
};
|
||||
|
||||
return { ok: true, result };
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Shared domain types for the RuView MCP server.
|
||||
*
|
||||
* These mirror the JSON schemas emitted by cog-pose-estimation (ADR-101) and
|
||||
* cog-person-count (ADR-103), and the REST payloads from wifi-densepose-sensing-server
|
||||
* (ADR-102).
|
||||
*/
|
||||
|
||||
// ── CSI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single CSI window as stored in paired JSONL files.
|
||||
* 56 subcarriers × 20 frames per window (the standard ESP32-S3 shape).
|
||||
*/
|
||||
export interface CsiWindow {
|
||||
/** Timestamp of the last frame in the window (seconds since epoch). */
|
||||
ts: number;
|
||||
/** Subcarrier amplitudes [56][20]. */
|
||||
amplitudes: number[][];
|
||||
/** Subcarrier phases [56][20], unwrapped (radians). */
|
||||
phases: number[][];
|
||||
/** Number of TX/RX antenna paths captured (1×1 SISO = 1). */
|
||||
n_paths: number;
|
||||
/** Source node MAC address, if known. */
|
||||
node_mac?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sensing-server `/api/v1/sensing/latest` response shape.
|
||||
*/
|
||||
export interface SensingLatestResponse {
|
||||
window: CsiWindow;
|
||||
/** Sensing server schema version (pinned to 2 per ADR-101 frame_subscriber.rs). */
|
||||
schema_version: number;
|
||||
/** ISO-8601 wall timestamp when the server last received a frame. */
|
||||
captured_at: string;
|
||||
}
|
||||
|
||||
// ── Pose ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single detected person's 17 COCO keypoints.
|
||||
* Each keypoint is [x, y] in [0, 1] image-normalized coords.
|
||||
*/
|
||||
export interface PersonPose {
|
||||
/** 17 keypoints in COCO order (nose, left_eye, right_eye, …, right_ankle). */
|
||||
keypoints: [number, number][];
|
||||
/** Model confidence in this person's pose estimate [0, 1]. */
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
/** Output of ruview_pose_infer. */
|
||||
export interface PoseInferResult {
|
||||
ts: number;
|
||||
n_persons: number;
|
||||
persons: PersonPose[];
|
||||
/** Backend used ("candle-cuda" | "candle-cpu" | "onnx" | "stub"). */
|
||||
backend: string;
|
||||
/** Inference latency (ms). */
|
||||
latency_ms: number;
|
||||
}
|
||||
|
||||
// ── Person Count ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Output of ruview_count_infer (ADR-103 person-count cog). */
|
||||
export interface CountInferResult {
|
||||
ts: number;
|
||||
count: number;
|
||||
confidence: number;
|
||||
count_p95_low: number;
|
||||
count_p95_high: number;
|
||||
/** Per-node breakdown when multi-node fusion was applied. */
|
||||
per_node_breakdown?: Array<{ node_mac: string; count: number; confidence: number }> | undefined;
|
||||
backend: string;
|
||||
latency_ms: number;
|
||||
}
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** A single cog entry from the Cognitum app-registry.json. */
|
||||
export interface CogEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
version: string;
|
||||
description: string;
|
||||
size_kb: number;
|
||||
difficulty: string;
|
||||
sha256?: string | undefined;
|
||||
binary_size?: number | undefined;
|
||||
}
|
||||
|
||||
/** Output of ruview_registry_list. */
|
||||
export interface RegistryListResult {
|
||||
fetched_at: number;
|
||||
ttl_seconds: number;
|
||||
stale: boolean;
|
||||
upstream_url: string;
|
||||
upstream_sha256: string;
|
||||
cogs: CogEntry[];
|
||||
}
|
||||
|
||||
// ── Training ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Output of ruview_train_count — a job handle. */
|
||||
export interface TrainJobResult {
|
||||
job_id: string;
|
||||
status: "queued" | "running" | "done" | "failed";
|
||||
/** Absolute path to the job log file (~/.ruview/jobs/<id>.log). */
|
||||
log_path: string;
|
||||
/** Timestamp when the job was enqueued (seconds since epoch). */
|
||||
queued_at: number;
|
||||
}
|
||||
|
||||
/** Output of ruview_job_status. */
|
||||
export interface JobStatusResult {
|
||||
job_id: string;
|
||||
status: "queued" | "running" | "done" | "failed";
|
||||
progress_pct?: number | undefined;
|
||||
/** Most recent log lines (last 20). */
|
||||
recent_log: string[];
|
||||
log_path: string;
|
||||
/** Epoch count completed, if training. */
|
||||
epochs_done?: number | undefined;
|
||||
/** Total epochs scheduled. */
|
||||
epochs_total?: number | undefined;
|
||||
}
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Runtime configuration, typically sourced from env vars. */
|
||||
export interface RuviewConfig {
|
||||
/** Base URL of the local sensing-server (default: http://localhost:3000). */
|
||||
sensingServerUrl: string;
|
||||
/** Bearer token for /api/v1/* endpoints. Set RUVIEW_API_TOKEN to enable. */
|
||||
apiToken: string | undefined;
|
||||
/** Absolute path to the cog-pose-estimation binary. */
|
||||
poseCogBinary: string;
|
||||
/** Absolute path to the cog-person-count binary. */
|
||||
countCogBinary: string;
|
||||
/** Directory for job logs (default: ~/.ruview/jobs/). */
|
||||
jobsDir: string;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Smoke tests for ruview-mcp tool stubs.
|
||||
*
|
||||
* These tests run without a live sensing-server or cog binary — they verify
|
||||
* the tool handler plumbing returns the expected shape under error conditions.
|
||||
* M6 adds integration tests that spawn a real MCP server and call each tool.
|
||||
*/
|
||||
|
||||
import os from "node:os";
|
||||
import type { RuviewConfig } from "../src/types.js";
|
||||
import { csiLatest } from "../src/tools/csi-latest.js";
|
||||
import { poseInfer } from "../src/tools/pose-infer.js";
|
||||
import { countInfer } from "../src/tools/count-infer.js";
|
||||
import { registryList } from "../src/tools/registry-list.js";
|
||||
import { trainCount } from "../src/tools/train-count.js";
|
||||
|
||||
const testConfig: RuviewConfig = {
|
||||
sensingServerUrl: "http://127.0.0.1:19999", // nothing listening here
|
||||
apiToken: undefined,
|
||||
poseCogBinary: "nonexistent-cog-pose-estimation",
|
||||
countCogBinary: "nonexistent-cog-person-count",
|
||||
jobsDir: os.tmpdir(),
|
||||
};
|
||||
|
||||
describe("ruview_csi_latest", () => {
|
||||
it("returns {ok:false, warn:true} when sensing-server is not reachable", async () => {
|
||||
const result = await csiLatest({}, testConfig) as Record<string, unknown>;
|
||||
expect(result["ok"]).toBe(false);
|
||||
expect(result["warn"]).toBe(true);
|
||||
expect(typeof result["error"]).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ruview_pose_infer", () => {
|
||||
it("returns {ok:false, warn:true} when cog binary is not found", async () => {
|
||||
const result = await poseInfer({}, testConfig) as Record<string, unknown>;
|
||||
expect(result["ok"]).toBe(false);
|
||||
expect(result["warn"]).toBe(true);
|
||||
expect(typeof result["error"]).toBe("string");
|
||||
});
|
||||
|
||||
it("result shape contains expected fields on success (stub)", async () => {
|
||||
// Point to a real binary that returns exit 0 on any argument (using 'node').
|
||||
const result = await poseInfer(
|
||||
{ cog_binary: "node" },
|
||||
{ ...testConfig, poseCogBinary: "node" }
|
||||
) as Record<string, unknown>;
|
||||
// node --help exits 0, so health passes, but output may be unexpected.
|
||||
// We just verify the response is shaped correctly.
|
||||
expect(typeof result["ok"]).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ruview_count_infer", () => {
|
||||
it("returns {ok:false, warn:true} when cog binary is not found", async () => {
|
||||
const result = await countInfer({ max_persons: 7 }, testConfig) as Record<string, unknown>;
|
||||
expect(result["ok"]).toBe(false);
|
||||
expect(result["warn"]).toBe(true);
|
||||
expect(typeof result["error"]).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ruview_registry_list", () => {
|
||||
it("returns {ok:false, warn:true} when sensing-server is not reachable", async () => {
|
||||
const result = await registryList(
|
||||
{ refresh: false },
|
||||
testConfig
|
||||
) as Record<string, unknown>;
|
||||
expect(result["ok"]).toBe(false);
|
||||
expect(result["warn"]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ruview_train_count", () => {
|
||||
it("enqueues a job and returns a UUID job_id", async () => {
|
||||
const result = await trainCount(
|
||||
{
|
||||
paired_jsonl: "/tmp/test.paired.jsonl",
|
||||
epochs: 1,
|
||||
learning_rate: 0.001,
|
||||
},
|
||||
testConfig
|
||||
) as Record<string, unknown>;
|
||||
expect(result["ok"]).toBe(true);
|
||||
const res = result["result"] as Record<string, unknown>;
|
||||
expect(typeof res["job_id"]).toBe("string");
|
||||
// UUID format
|
||||
expect((res["job_id"] as string).split("-")).toHaveLength(5);
|
||||
expect(res["status"]).toBe("running");
|
||||
expect(typeof res["log_path"]).toBe("string");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
"types": ["jest", "node"],
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
"include": ["./**/*.ts", "../src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user