mirror of
https://github.com/ruvnet/RuView
synced 2026-08-01 19:01:42 +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,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"
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user