mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00: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,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