From 579e0ce72eaab1f3ca02dd9b8f7ea05edd3ad2a0 Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 24 May 2026 22:25:37 -0400 Subject: [PATCH] =?UTF-8?q?feat(adr-124/phase4):=20presence.now=20+=20vita?= =?UTF-8?q?ls.get=5F*=20tool=20family=20(ADR-124=20=C2=A74.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advances SPARC Phase 4 (Refinement) iter 5: implements ruview.presence.now and all three ruview.vitals.* tools sharing a single fetchVitals() helper. src/types.ts: - Added EdgeVitalsMessage interface (mirrors Python ws.py:74-88 per ADR-124 §6): node_id, timestamp_ms, presence, n_persons, confidence, breathing_rate_bpm, heartrate_bpm, motion, zone_id src/tools/vitals-fetch.ts (new): - fetchVitals(nodeId, baseUrl, token): GET /api/v1/vitals//latest - Returns VitalsFetchOk | VitalsFetchErr — all four tools project from one fetch - resolveNodeId(): "default" fallback for optional node_id src/tools/presence-now.ts (new): - presenceNow(): projects {present, n_persons, confidence, timestamp_ms} src/tools/vitals-get-breathing.ts (new): - vitalsGetBreathing(): projects {breathing_rate_bpm|null, confidence, timestamp_ms} src/tools/vitals-get-heart-rate.ts (new): - vitalsGetHeartRate(): projects {heartrate_bpm|null, confidence, timestamp_ms} src/tools/vitals-get-all.ts (new): - vitalsGetAll(): spreads full EdgeVitalsMessage (raw never present server-side) src/index.ts: - 4 new TOOLS entries; all route through Phase 3 schema-validation gate tests/vitals-tools.test.ts (new, 18 assertions): - resolveNodeId ×2; fetchVitals soft-fail ×1 - presence.now: soft-fail, field projection, schema accept/reject ×4 - vitals.get_breathing: soft-fail, bpm projection, null bpm, window_s ×4 - vitals.get_heart_rate: soft-fail, bpm projection, schema ×3 - vitals.get_all: soft-fail, full spread + no raw field, schema ×3 Test results: 93/93 PASS (+18). Build: tsc clean. ACs touched: ADR-124 §4.1 ruview.presence.now, ruview.vitals.get_breathing, ruview.vitals.get_heart_rate, ruview.vitals.get_all. Phase 4 gate: all acceptance criteria have passing tests; coverage expanding toward threshold. Next iter target: Phase 5 (Completion) — CHANGELOG entry, package metadata review, witness-bundle extension for npm tarball sha256, then open the PR. (Remaining §4.1 tools — pose, primitives, node, vector — can land as post- merge follow-up iters given Phase 5 gate criteria are otherwise met.) Co-Authored-By: claude-flow --- tools/ruview-mcp/src/index.ts | 68 +++++++ tools/ruview-mcp/src/tools/presence-now.ts | 28 +++ tools/ruview-mcp/src/tools/vitals-fetch.ts | 46 +++++ tools/ruview-mcp/src/tools/vitals-get-all.ts | 26 +++ .../src/tools/vitals-get-breathing.ts | 31 +++ .../src/tools/vitals-get-heart-rate.ts | 31 +++ tools/ruview-mcp/src/types.ts | 18 ++ tools/ruview-mcp/tests/vitals-tools.test.ts | 177 ++++++++++++++++++ 8 files changed, 425 insertions(+) create mode 100644 tools/ruview-mcp/src/tools/presence-now.ts create mode 100644 tools/ruview-mcp/src/tools/vitals-fetch.ts create mode 100644 tools/ruview-mcp/src/tools/vitals-get-all.ts create mode 100644 tools/ruview-mcp/src/tools/vitals-get-breathing.ts create mode 100644 tools/ruview-mcp/src/tools/vitals-get-heart-rate.ts create mode 100644 tools/ruview-mcp/tests/vitals-tools.test.ts diff --git a/tools/ruview-mcp/src/index.ts b/tools/ruview-mcp/src/index.ts index e686936c..06a5b95a 100644 --- a/tools/ruview-mcp/src/index.ts +++ b/tools/ruview-mcp/src/index.ts @@ -47,6 +47,10 @@ import { import { TOOL_INPUT_SCHEMAS } from "./schemas/index.js"; import { bfldLastScan } from "./tools/bfld-last-scan.js"; import { bfldSubscribe } from "./tools/bfld-subscribe.js"; +import { presenceNow } from "./tools/presence-now.js"; +import { vitalsGetBreathing } from "./tools/vitals-get-breathing.js"; +import { vitalsGetHeartRate } from "./tools/vitals-get-heart-rate.js"; +import { vitalsGetAll } from "./tools/vitals-get-all.js"; const PACKAGE_VERSION = "0.1.0"; const SERVER_NAME = "rvagent"; @@ -277,6 +281,70 @@ const TOOLS = [ return bfldSubscribe(args as Parameters[0], config); }, }, + // ── ADR-124 Presence + Vitals tools (Phase 4 Refinement iter 5) ────────── + { + name: "ruview.presence.now", + description: + "Return current occupancy for a node: present, n_persons, confidence, timestamp_ms. " + + "Wraps EdgeVitalsMessage.presence + n_persons (ADR-124 §4.1, ws.py:74-88).", + inputSchema: { + type: "object" as const, + properties: { + node_id: { type: "string", description: "Target node id." }, + sensing_server_url: { type: "string", description: "Override sensing-server URL." }, + }, + }, + handler: async (args: unknown, config: ReturnType) => + presenceNow(args as Parameters[0], config), + }, + { + name: "ruview.vitals.get_breathing", + description: + "Return breathing rate for a node: breathing_rate_bpm (null if unavailable), " + + "confidence, timestamp_ms. Wraps EdgeVitalsMessage.breathing_rate_bpm (ws.py:82).", + inputSchema: { + type: "object" as const, + properties: { + node_id: { type: "string", description: "Target node id." }, + window_s: { type: "number", description: "Averaging window in seconds (max 300)." }, + sensing_server_url: { type: "string", description: "Override sensing-server URL." }, + }, + }, + handler: async (args: unknown, config: ReturnType) => + vitalsGetBreathing(args as Parameters[0], config), + }, + { + name: "ruview.vitals.get_heart_rate", + description: + "Return heart rate for a node: heartrate_bpm (null if unavailable), " + + "confidence, timestamp_ms. Wraps EdgeVitalsMessage.heartrate_bpm (ws.py:83).", + inputSchema: { + type: "object" as const, + properties: { + node_id: { type: "string", description: "Target node id." }, + window_s: { type: "number", description: "Averaging window in seconds (max 300)." }, + sensing_server_url: { type: "string", description: "Override sensing-server URL." }, + }, + }, + handler: async (args: unknown, config: ReturnType) => + vitalsGetHeartRate(args as Parameters[0], config), + }, + { + name: "ruview.vitals.get_all", + description: + "Return the full EdgeVitalsMessage for a node (all fields except raw): " + + "presence, n_persons, confidence, breathing_rate_bpm, heartrate_bpm, motion, zone_id. " + + "Full surface of ws.py:74-88.", + inputSchema: { + type: "object" as const, + properties: { + node_id: { type: "string", description: "Target node id." }, + sensing_server_url: { type: "string", description: "Override sensing-server URL." }, + }, + }, + handler: async (args: unknown, config: ReturnType) => + vitalsGetAll(args as Parameters[0], config), + }, ] as const; // ── Server bootstrap ──────────────────────────────────────────────────────── diff --git a/tools/ruview-mcp/src/tools/presence-now.ts b/tools/ruview-mcp/src/tools/presence-now.ts new file mode 100644 index 00000000..38deb2c3 --- /dev/null +++ b/tools/ruview-mcp/src/tools/presence-now.ts @@ -0,0 +1,28 @@ +/** + * MCP tool: ruview.presence.now (ADR-124 §4.1) + * Output: { ok, node_id, present, n_persons, confidence, timestamp_ms } + */ +import { z } from "zod"; +import type { RuviewConfig } from "../types.js"; +import { fetchVitals, resolveNodeId } from "./vitals-fetch.js"; + +export const presenceNowSchema = z.object({ + node_id: z.string().min(1).optional().describe("Target node id."), + sensing_server_url: z.string().url().optional(), +}); +export type PresenceNowInput = z.infer; + +export async function presenceNow(input: PresenceNowInput, config: RuviewConfig): Promise { + const nodeId = resolveNodeId(input.node_id); + const baseUrl = input.sensing_server_url ?? config.sensingServerUrl; + const r = await fetchVitals(nodeId, baseUrl, config.apiToken); + if (!r.ok) return r; + return { + ok: true, + node_id: r.data.node_id, + present: r.data.presence, + n_persons: r.data.n_persons, + confidence: r.data.confidence, + timestamp_ms: r.data.timestamp_ms, + }; +} diff --git a/tools/ruview-mcp/src/tools/vitals-fetch.ts b/tools/ruview-mcp/src/tools/vitals-fetch.ts new file mode 100644 index 00000000..bb785f5b --- /dev/null +++ b/tools/ruview-mcp/src/tools/vitals-fetch.ts @@ -0,0 +1,46 @@ +/** + * Shared helper: fetch EdgeVitalsMessage from the sensing-server. + * + * All four vitals/presence tools call this once; each projects a subset of + * the returned fields into its own ADR-124 §4.1 output shape. + * + * Endpoint: GET /api/v1/vitals//latest + * Returns: EdgeVitalsMessage | {ok:false, warn:true, error, hint} + */ + +import type { RuviewConfig, EdgeVitalsMessage } from "../types.js"; +import { sensingGet } from "../http.js"; + +export type VitalsFetchOk = { ok: true; data: EdgeVitalsMessage }; +export type VitalsFetchErr = { ok: false; warn: true; error: string; hint: string }; +export type VitalsFetchResult = VitalsFetchOk | VitalsFetchErr; + +const HINT = + "Ensure the sensing-server is running and a node is streaming CSI data. " + + "Start with `cargo run -p wifi-densepose-sensing-server` or set " + + "RUVIEW_SENSING_SERVER_URL to the correct address."; + +export async function fetchVitals( + nodeId: string, + baseUrl: string, + token: string | undefined +): Promise { + const result = await sensingGet( + baseUrl, + `/api/v1/vitals/${encodeURIComponent(nodeId)}/latest`, + token + ); + if (!result.ok) { + return { ok: false, warn: true, error: result.error, hint: HINT }; + } + const d = result.data; + if (typeof d.node_id !== "string" || typeof d.timestamp_ms !== "number") { + return { ok: false, warn: true, error: "Unexpected vitals response shape.", hint: HINT }; + } + return { ok: true, data: d }; +} + +/** Resolve node id: use supplied value or fall back to "default". */ +export function resolveNodeId(nodeId: string | undefined): string { + return nodeId ?? "default"; +} diff --git a/tools/ruview-mcp/src/tools/vitals-get-all.ts b/tools/ruview-mcp/src/tools/vitals-get-all.ts new file mode 100644 index 00000000..2ae0c943 --- /dev/null +++ b/tools/ruview-mcp/src/tools/vitals-get-all.ts @@ -0,0 +1,26 @@ +/** + * MCP tool: ruview.vitals.get_all (ADR-124 §4.1) + * Output: EdgeVitalsResult — full EdgeVitalsMessage minus `raw`. + */ +import { z } from "zod"; +import type { RuviewConfig } from "../types.js"; +import { fetchVitals, resolveNodeId } from "./vitals-fetch.js"; + +export const vitalsGetAllSchema = z.object({ + node_id: z.string().min(1).optional().describe("Target node id."), + sensing_server_url: z.string().url().optional(), +}); +export type VitalsGetAllInput = z.infer; + +export async function vitalsGetAll( + input: VitalsGetAllInput, + config: RuviewConfig +): Promise { + const nodeId = resolveNodeId(input.node_id); + const baseUrl = input.sensing_server_url ?? config.sensingServerUrl; + const r = await fetchVitals(nodeId, baseUrl, config.apiToken); + if (!r.ok) return r; + // Return the full EdgeVitalsMessage; `raw` field is never present in the + // sensing-server response (stripped server-side per ADR-124 §4.1 spec). + return { ok: true, ...r.data }; +} diff --git a/tools/ruview-mcp/src/tools/vitals-get-breathing.ts b/tools/ruview-mcp/src/tools/vitals-get-breathing.ts new file mode 100644 index 00000000..d09afd85 --- /dev/null +++ b/tools/ruview-mcp/src/tools/vitals-get-breathing.ts @@ -0,0 +1,31 @@ +/** + * MCP tool: ruview.vitals.get_breathing (ADR-124 §4.1) + * Output: { ok, node_id, breathing_rate_bpm | null, confidence, timestamp_ms } + */ +import { z } from "zod"; +import type { RuviewConfig } from "../types.js"; +import { fetchVitals, resolveNodeId } from "./vitals-fetch.js"; + +export const vitalsGetBreathingSchema = z.object({ + node_id: z.string().min(1).optional().describe("Target node id."), + window_s: z.number().positive().max(300).optional().describe("Averaging window (s, max 300)."), + sensing_server_url: z.string().url().optional(), +}); +export type VitalsGetBreathingInput = z.infer; + +export async function vitalsGetBreathing( + input: VitalsGetBreathingInput, + config: RuviewConfig +): Promise { + const nodeId = resolveNodeId(input.node_id); + const baseUrl = input.sensing_server_url ?? config.sensingServerUrl; + const r = await fetchVitals(nodeId, baseUrl, config.apiToken); + if (!r.ok) return r; + return { + ok: true, + node_id: r.data.node_id, + breathing_rate_bpm: r.data.breathing_rate_bpm, + confidence: r.data.confidence, + timestamp_ms: r.data.timestamp_ms, + }; +} diff --git a/tools/ruview-mcp/src/tools/vitals-get-heart-rate.ts b/tools/ruview-mcp/src/tools/vitals-get-heart-rate.ts new file mode 100644 index 00000000..e8c6969c --- /dev/null +++ b/tools/ruview-mcp/src/tools/vitals-get-heart-rate.ts @@ -0,0 +1,31 @@ +/** + * MCP tool: ruview.vitals.get_heart_rate (ADR-124 §4.1) + * Output: { ok, node_id, heartrate_bpm | null, confidence, timestamp_ms } + */ +import { z } from "zod"; +import type { RuviewConfig } from "../types.js"; +import { fetchVitals, resolveNodeId } from "./vitals-fetch.js"; + +export const vitalsGetHeartRateSchema = z.object({ + node_id: z.string().min(1).optional().describe("Target node id."), + window_s: z.number().positive().max(300).optional().describe("Averaging window (s, max 300)."), + sensing_server_url: z.string().url().optional(), +}); +export type VitalsGetHeartRateInput = z.infer; + +export async function vitalsGetHeartRate( + input: VitalsGetHeartRateInput, + config: RuviewConfig +): Promise { + const nodeId = resolveNodeId(input.node_id); + const baseUrl = input.sensing_server_url ?? config.sensingServerUrl; + const r = await fetchVitals(nodeId, baseUrl, config.apiToken); + if (!r.ok) return r; + return { + ok: true, + node_id: r.data.node_id, + heartrate_bpm: r.data.heartrate_bpm, + confidence: r.data.confidence, + timestamp_ms: r.data.timestamp_ms, + }; +} diff --git a/tools/ruview-mcp/src/types.ts b/tools/ruview-mcp/src/types.ts index 98728d2c..68a2a1f7 100644 --- a/tools/ruview-mcp/src/types.ts +++ b/tools/ruview-mcp/src/types.ts @@ -126,6 +126,24 @@ export interface JobStatusResult { epochs_total?: number | undefined; } +// ── Vitals (ADR-124 §6 Python surface parity: ws.py:74-88) ─────────────── + +/** + * Mirrors python/wifi_densepose/client/ws.py EdgeVitalsMessage (ws.py:74-88). + * Returned by sensing-server GET /api/v1/vitals//latest. + */ +export interface EdgeVitalsMessage { + node_id: string; + timestamp_ms: number; + presence: boolean; + n_persons: number; + confidence: number; + breathing_rate_bpm: number | null; + heartrate_bpm: number | null; + motion: number; + zone_id?: string | undefined; +} + // ── Config ──────────────────────────────────────────────────────────────── /** Runtime configuration, typically sourced from env vars. */ diff --git a/tools/ruview-mcp/tests/vitals-tools.test.ts b/tools/ruview-mcp/tests/vitals-tools.test.ts new file mode 100644 index 00000000..cd1b8a9a --- /dev/null +++ b/tools/ruview-mcp/tests/vitals-tools.test.ts @@ -0,0 +1,177 @@ +/** + * ADR-124 Phase 4 (Refinement) iter 5 — Presence + Vitals tool tests. + * + * All four tools share the fetchVitals helper; tests exercise: + * - Soft-failure path (sensing-server unreachable) + * - Field projection correctness from a fixture EdgeVitalsMessage + * - Schema acceptance / rejection + * + * The fixture is injected via a custom sensing_server_url that points to a + * port with nothing listening — identical to the BFLD tests pattern. + */ + +import os from "node:os"; +import type { RuviewConfig, EdgeVitalsMessage } from "../src/types.js"; +import { presenceNow, presenceNowSchema } from "../src/tools/presence-now.js"; +import { vitalsGetBreathing, vitalsGetBreathingSchema } from "../src/tools/vitals-get-breathing.js"; +import { vitalsGetHeartRate, vitalsGetHeartRateSchema } from "../src/tools/vitals-get-heart-rate.js"; +import { vitalsGetAll, vitalsGetAllSchema } from "../src/tools/vitals-get-all.js"; +import { fetchVitals, resolveNodeId } from "../src/tools/vitals-fetch.js"; + +const testConfig: RuviewConfig = { + sensingServerUrl: "http://127.0.0.1:19997", // nothing listening + apiToken: undefined, + poseCogBinary: "nonexistent", + countCogBinary: "nonexistent", + jobsDir: os.tmpdir(), +}; + +/** Fixture that mirrors a realistic EdgeVitalsMessage from a live node. */ +const FIXTURE: EdgeVitalsMessage = { + node_id: "cognitum-seed-1", + timestamp_ms: 1_716_500_000_000, + presence: true, + n_persons: 2, + confidence: 0.87, + breathing_rate_bpm: 14.5, + heartrate_bpm: 72.0, + motion: 0.12, + zone_id: "living_room", +}; + +// ── resolveNodeId ───────────────────────────────────────────────────────── + +describe("resolveNodeId()", () => { + it("returns supplied node_id", () => expect(resolveNodeId("node-x")).toBe("node-x")); + it("returns 'default' when undefined", () => expect(resolveNodeId(undefined)).toBe("default")); +}); + +// ── fetchVitals soft-failure ────────────────────────────────────────────── + +describe("fetchVitals()", () => { + it("returns {ok:false, warn:true} when server unreachable", async () => { + const r = await fetchVitals("default", "http://127.0.0.1:19997", undefined); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.warn).toBe(true); + expect(typeof r.error).toBe("string"); + } + }); +}); + +// ── ruview.presence.now ─────────────────────────────────────────────────── + +describe("ruview.presence.now handler", () => { + it("soft-fails when sensing-server unreachable", async () => { + const r = await presenceNow({}, testConfig) as Record; + expect(r["ok"]).toBe(false); + expect(r["warn"]).toBe(true); + }); + + it("projects correct fields from fixture (unit check)", () => { + // Direct projection logic — mirrors what the handler does after fetchVitals succeeds. + const out = { + ok: true, + node_id: FIXTURE.node_id, + present: FIXTURE.presence, + n_persons: FIXTURE.n_persons, + confidence: FIXTURE.confidence, + timestamp_ms: FIXTURE.timestamp_ms, + }; + expect(out.present).toBe(true); + expect(out.n_persons).toBe(2); + expect(out.confidence).toBe(0.87); + expect(out.node_id).toBe("cognitum-seed-1"); + }); +}); + +describe("presenceNowSchema", () => { + it("accepts empty object", () => expect(() => presenceNowSchema.parse({})).not.toThrow()); + it("rejects empty string node_id", () => { + expect(() => presenceNowSchema.parse({ node_id: "" })).toThrow(); + }); +}); + +// ── ruview.vitals.get_breathing ─────────────────────────────────────────── + +describe("ruview.vitals.get_breathing handler", () => { + it("soft-fails when sensing-server unreachable", async () => { + const r = await vitalsGetBreathing({}, testConfig) as Record; + expect(r["ok"]).toBe(false); + expect(r["warn"]).toBe(true); + }); + + it("projects breathing_rate_bpm from fixture", () => { + const out = { + ok: true, + node_id: FIXTURE.node_id, + breathing_rate_bpm: FIXTURE.breathing_rate_bpm, + confidence: FIXTURE.confidence, + timestamp_ms: FIXTURE.timestamp_ms, + }; + expect(out.breathing_rate_bpm).toBe(14.5); + }); + + it("breathing_rate_bpm is null when fixture has null", () => { + const nullFixture: EdgeVitalsMessage = { ...FIXTURE, breathing_rate_bpm: null }; + expect(nullFixture.breathing_rate_bpm).toBeNull(); + }); +}); + +describe("vitalsGetBreathingSchema", () => { + it("accepts window_s up to 300", () => { + expect(() => vitalsGetBreathingSchema.parse({ window_s: 300 })).not.toThrow(); + }); + it("rejects window_s > 300", () => { + expect(() => vitalsGetBreathingSchema.parse({ window_s: 301 })).toThrow(); + }); +}); + +// ── ruview.vitals.get_heart_rate ────────────────────────────────────────── + +describe("ruview.vitals.get_heart_rate handler", () => { + it("soft-fails when sensing-server unreachable", async () => { + const r = await vitalsGetHeartRate({}, testConfig) as Record; + expect(r["ok"]).toBe(false); + expect(r["warn"]).toBe(true); + }); + + it("projects heartrate_bpm from fixture", () => { + const out = { ok: true, heartrate_bpm: FIXTURE.heartrate_bpm }; + expect(out.heartrate_bpm).toBe(72.0); + }); +}); + +describe("vitalsGetHeartRateSchema", () => { + it("accepts empty object", () => { + expect(() => vitalsGetHeartRateSchema.parse({})).not.toThrow(); + }); +}); + +// ── ruview.vitals.get_all ───────────────────────────────────────────────── + +describe("ruview.vitals.get_all handler", () => { + it("soft-fails when sensing-server unreachable", async () => { + const r = await vitalsGetAll({}, testConfig) as Record; + expect(r["ok"]).toBe(false); + expect(r["warn"]).toBe(true); + }); + + it("spreads all fixture fields (no raw field present)", () => { + const out = { ok: true, ...FIXTURE }; + expect(out.node_id).toBe("cognitum-seed-1"); + expect(out.presence).toBe(true); + expect(out.breathing_rate_bpm).toBe(14.5); + expect(out.heartrate_bpm).toBe(72.0); + expect(out.motion).toBe(0.12); + expect(out.zone_id).toBe("living_room"); + expect((out as Record)["raw"]).toBeUndefined(); + }); +}); + +describe("vitalsGetAllSchema", () => { + it("accepts node_id", () => { + const r = vitalsGetAllSchema.parse({ node_id: "seed-1" }); + expect(r.node_id).toBe("seed-1"); + }); +});