From 1a92127dfadf3949e45c055225fd2b665b5b1d27 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 14 Dec 2025 05:05:06 +0000 Subject: [PATCH] feat(voicewake): add gateway-owned wake words sync --- docs/voicewake.md | 62 ++++++++++++++++++++ src/gateway/server.test.ts | 109 +++++++++++++++++++++++++++++++++++- src/gateway/server.ts | 102 ++++++++++++++++++++++++++++++++- src/infra/voicewake.test.ts | 46 +++++++++++++++ src/infra/voicewake.ts | 96 +++++++++++++++++++++++++++++++ 5 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 docs/voicewake.md create mode 100644 src/infra/voicewake.test.ts create mode 100644 src/infra/voicewake.ts diff --git a/docs/voicewake.md b/docs/voicewake.md new file mode 100644 index 000000000..1a3ee14ec --- /dev/null +++ b/docs/voicewake.md @@ -0,0 +1,62 @@ +--- +summary: "Global voice wake words (Gateway-owned) and how they sync across nodes" +read_when: + - Changing voice wake words behavior or defaults + - Adding new node platforms that need wake word sync +--- +# Voice Wake (Global Wake Words) + +Clawdis treats **wake words as a single global list** owned by the **Gateway**. + +- There are **no per-node custom wake words**. +- **Any node/app UI may edit** the list; changes are persisted by the Gateway and broadcast to everyone. +- Each device still keeps its own **Voice Wake enabled/disabled** toggle (local UX + permissions differ). + +## Storage (Gateway host) + +Wake words are stored on the gateway machine at: + +- `~/.clawdis/settings/voicewake.json` + +Shape: + +```json +{ "triggers": ["clawd", "claude"], "updatedAtMs": 1730000000000 } +``` + +## Protocol + +### Methods + +- `voicewake.get` → `{ triggers: string[] }` +- `voicewake.set` with params `{ triggers: string[] }` → `{ triggers: string[] }` + +Notes: +- Triggers are normalized (trimmed, empties dropped). Empty lists fall back to defaults. +- Limits are enforced for safety (count/length caps). + +### Events + +- `voicewake.changed` payload `{ triggers: string[] }` + +Who receives it: +- All WebSocket clients (macOS app, WebChat, etc.) +- All connected bridge nodes (iOS/Android), and also on node connect as an initial “current state” push. + +## Client behavior + +### macOS app + +- Uses the global list to gate `VoiceWakeRuntime` triggers. +- Editing “Trigger words” in Voice Wake settings calls `voicewake.set` and then relies on the broadcast to keep other clients in sync. + +### iOS node (Iris) + +- Uses the global list for `VoiceWakeManager` trigger detection. +- Editing Wake Words in Settings calls `voicewake.set` (over the bridge) and also keeps local wake-word detection responsive. + +### Android node + +- Exposes a Wake Words editor in Settings. +- Calls `voicewake.set` over the bridge so edits sync everywhere. + diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 9cc635248..f3473ec79 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -47,6 +47,9 @@ const bridgeInvoke = vi.hoisted(() => error: null, })), ); +const bridgeListConnected = vi.hoisted(() => + vi.fn(() => [] as BridgeClientInfo[]), +); const bridgeSendEvent = vi.hoisted(() => vi.fn()); vi.mock("../infra/bridge/server.js", () => ({ startNodeBridgeServer: vi.fn(async (opts: BridgeStartOpts) => { @@ -54,7 +57,7 @@ vi.mock("../infra/bridge/server.js", () => ({ return { port: 18790, close: async () => {}, - listConnected: () => [], + listConnected: bridgeListConnected, invoke: bridgeInvoke, sendEvent: bridgeSendEvent, }; @@ -246,6 +249,110 @@ async function rpcReq( } describe("gateway server", () => { + test("voicewake.get returns defaults and voicewake.set broadcasts", async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-")); + const prevHome = process.env.HOME; + process.env.HOME = homeDir; + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const initial = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get"); + expect(initial.ok).toBe(true); + expect(initial.payload?.triggers).toEqual(["clawd", "claude"]); + + const changedP = onceMessage<{ + type: "event"; + event: string; + payload?: unknown; + }>(ws, (o) => o.type === "event" && o.event === "voicewake.changed"); + + const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", { + triggers: [" hi ", "", "there"], + }); + expect(setRes.ok).toBe(true); + expect(setRes.payload?.triggers).toEqual(["hi", "there"]); + + const changed = await changedP; + expect(changed.event).toBe("voicewake.changed"); + expect( + (changed.payload as { triggers?: unknown } | undefined)?.triggers, + ).toEqual(["hi", "there"]); + + const after = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get"); + expect(after.ok).toBe(true); + expect(after.payload?.triggers).toEqual(["hi", "there"]); + + const onDisk = JSON.parse( + await fs.readFile( + path.join(homeDir, ".clawdis", "settings", "voicewake.json"), + "utf8", + ), + ) as { triggers?: unknown; updatedAtMs?: unknown }; + expect(onDisk.triggers).toEqual(["hi", "there"]); + expect(typeof onDisk.updatedAtMs).toBe("number"); + + ws.close(); + await server.close(); + + if (prevHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = prevHome; + } + }); + + test("pushes voicewake.changed to nodes on connect and on updates", async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-")); + const prevHome = process.env.HOME; + process.env.HOME = homeDir; + + bridgeSendEvent.mockClear(); + bridgeListConnected.mockReturnValue([{ nodeId: "n1" }]); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const startCall = bridgeStartCalls.at(-1); + expect(startCall).toBeTruthy(); + + await startCall?.onAuthenticated?.({ nodeId: "n1" }); + + const first = bridgeSendEvent.mock.calls.find( + (c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1", + )?.[0] as { payloadJSON?: string | null } | undefined; + expect(first?.payloadJSON).toBeTruthy(); + const firstPayload = JSON.parse(String(first?.payloadJSON)) as { + triggers?: unknown; + }; + expect(firstPayload.triggers).toEqual(["clawd", "claude"]); + + bridgeSendEvent.mockClear(); + + const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", { + triggers: ["clawd", "computer"], + }); + expect(setRes.ok).toBe(true); + + const broadcast = bridgeSendEvent.mock.calls.find( + (c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1", + )?.[0] as { payloadJSON?: string | null } | undefined; + expect(broadcast?.payloadJSON).toBeTruthy(); + const broadcastPayload = JSON.parse(String(broadcast?.payloadJSON)) as { + triggers?: unknown; + }; + expect(broadcastPayload.triggers).toEqual(["clawd", "computer"]); + + ws.close(); + await server.close(); + + if (prevHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = prevHome; + } + }); + test("supports gateway-owned node pairing methods and events", async () => { const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-")); const prevHome = process.env.HOME; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 6d138f0c0..58438ad60 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -61,6 +61,11 @@ import { updateSystemPresence, upsertPresence, } from "../infra/system-presence.js"; +import { + defaultVoiceWakeTriggers, + loadVoiceWakeConfig, + setVoiceWakeTriggers, +} from "../infra/voicewake.js"; import { logError, logInfo, logWarn } from "../logger.js"; import { getChildLogger, @@ -168,6 +173,8 @@ type SessionsPatchResult = { const METHODS = [ "health", "status", + "voicewake.get", + "voicewake.set", "sessions.list", "sessions.patch", "last-heartbeat", @@ -207,6 +214,7 @@ const EVENTS = [ "cron", "node.pair.requested", "node.pair.resolved", + "voicewake.changed", ]; export type GatewayServer = { @@ -284,6 +292,16 @@ function formatForLog(value: unknown): string { } } +function normalizeVoiceWakeTriggers(input: unknown): string[] { + const raw = Array.isArray(input) ? input : []; + const cleaned = raw + .map((v) => (typeof v === "string" ? v.trim() : "")) + .filter((v) => v.length > 0) + .slice(0, 32) + .map((v) => v.slice(0, 64)); + return cleaned.length > 0 ? cleaned : defaultVoiceWakeTriggers(); +} + function readSessionMessages( sessionId: string, storePath: string | undefined, @@ -752,6 +770,20 @@ export async function startGatewayServer( } }; + const bridgeSendToAllConnected = (event: string, payload: unknown) => { + if (!bridge) return; + const payloadJSON = payload ? JSON.stringify(payload) : null; + for (const node of bridge.listConnected()) { + bridge.sendEvent({ nodeId: node.nodeId, event, payloadJSON }); + } + }; + + const broadcastVoiceWakeChanged = (triggers: string[]) => { + const payload = { triggers }; + broadcast("voicewake.changed", payload, { dropIfSlow: true }); + bridgeSendToAllConnected("voicewake.changed", payload); + }; + const handleBridgeRequest = async ( nodeId: string, req: { id: string; method: string; paramsJSON?: string | null }, @@ -773,6 +805,23 @@ export async function startGatewayServer( try { switch (method) { + case "voicewake.get": { + const cfg = await loadVoiceWakeConfig(); + return { + ok: true, + payloadJSON: JSON.stringify({ triggers: cfg.triggers }), + }; + } + case "voicewake.set": { + const params = parseParams(); + const triggers = normalizeVoiceWakeTriggers(params.triggers); + const cfg = await setVoiceWakeTriggers(triggers); + broadcastVoiceWakeChanged(cfg.triggers); + return { + ok: true, + payloadJSON: JSON.stringify({ triggers: cfg.triggers }), + }; + } case "health": { const now = Date.now(); const cached = healthCache; @@ -1170,7 +1219,7 @@ export async function startGatewayServer( port: bridgePort, serverName: machineDisplayName, onRequest: (nodeId, req) => handleBridgeRequest(nodeId, req), - onAuthenticated: (node) => { + onAuthenticated: async (node) => { const host = node.displayName?.trim() || node.nodeId; const ip = node.remoteIp?.trim(); const version = node.version?.trim() || "unknown"; @@ -1199,6 +1248,17 @@ export async function startGatewayServer( }, }, ); + + try { + const cfg = await loadVoiceWakeConfig(); + started.sendEvent({ + nodeId: node.nodeId, + event: "voicewake.changed", + payloadJSON: JSON.stringify({ triggers: cfg.triggers }), + }); + } catch { + // Best-effort only. + } }, onDisconnected: (node) => { bridgeUnsubscribeAll(node.nodeId); @@ -1676,6 +1736,46 @@ export async function startGatewayServer( ); break; } + case "voicewake.get": { + try { + const cfg = await loadVoiceWakeConfig(); + respond(true, { triggers: cfg.triggers }); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } + case "voicewake.set": { + const params = (req.params ?? {}) as Record; + if (!Array.isArray(params.triggers)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "voicewake.set requires triggers: string[]", + ), + ); + break; + } + try { + const triggers = normalizeVoiceWakeTriggers(params.triggers); + const cfg = await setVoiceWakeTriggers(triggers); + broadcastVoiceWakeChanged(cfg.triggers); + respond(true, { triggers: cfg.triggers }); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } case "health": { const now = Date.now(); const cached = healthCache; diff --git a/src/infra/voicewake.test.ts b/src/infra/voicewake.test.ts new file mode 100644 index 000000000..9317452a5 --- /dev/null +++ b/src/infra/voicewake.test.ts @@ -0,0 +1,46 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + defaultVoiceWakeTriggers, + loadVoiceWakeConfig, + setVoiceWakeTriggers, +} from "./voicewake.js"; + +describe("voicewake store", () => { + it("returns defaults when missing", async () => { + const baseDir = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdis-voicewake-"), + ); + const cfg = await loadVoiceWakeConfig(baseDir); + expect(cfg.triggers).toEqual(defaultVoiceWakeTriggers()); + expect(cfg.updatedAtMs).toBe(0); + }); + + it("sanitizes and persists triggers", async () => { + const baseDir = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdis-voicewake-"), + ); + const saved = await setVoiceWakeTriggers( + [" hi ", "", " there "], + baseDir, + ); + expect(saved.triggers).toEqual(["hi", "there"]); + expect(saved.updatedAtMs).toBeGreaterThan(0); + + const loaded = await loadVoiceWakeConfig(baseDir); + expect(loaded.triggers).toEqual(["hi", "there"]); + expect(loaded.updatedAtMs).toBeGreaterThan(0); + }); + + it("falls back to defaults when triggers empty", async () => { + const baseDir = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdis-voicewake-"), + ); + const saved = await setVoiceWakeTriggers(["", " "], baseDir); + expect(saved.triggers).toEqual(defaultVoiceWakeTriggers()); + }); +}); diff --git a/src/infra/voicewake.ts b/src/infra/voicewake.ts new file mode 100644 index 000000000..3e111df0b --- /dev/null +++ b/src/infra/voicewake.ts @@ -0,0 +1,96 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export type VoiceWakeConfig = { + triggers: string[]; + updatedAtMs: number; +}; + +const DEFAULT_TRIGGERS = ["clawd", "claude"]; + +function defaultBaseDir() { + return path.join(os.homedir(), ".clawdis"); +} + +function resolvePath(baseDir?: string) { + const root = baseDir ?? defaultBaseDir(); + return path.join(root, "settings", "voicewake.json"); +} + +function sanitizeTriggers(triggers: string[] | undefined | null): string[] { + const cleaned = (triggers ?? []) + .map((w) => (typeof w === "string" ? w.trim() : "")) + .filter((w) => w.length > 0); + return cleaned.length > 0 ? cleaned : DEFAULT_TRIGGERS; +} + +async function readJSON(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf8"); + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +async function writeJSONAtomic(filePath: string, value: unknown) { + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + const tmp = `${filePath}.${randomUUID()}.tmp`; + await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8"); + await fs.rename(tmp, filePath); +} + +let lock: Promise = Promise.resolve(); +async function withLock(fn: () => Promise): Promise { + const prev = lock; + let release: (() => void) | undefined; + lock = new Promise((resolve) => { + release = resolve; + }); + await prev; + try { + return await fn(); + } finally { + release?.(); + } +} + +export function defaultVoiceWakeTriggers() { + return [...DEFAULT_TRIGGERS]; +} + +export async function loadVoiceWakeConfig( + baseDir?: string, +): Promise { + const filePath = resolvePath(baseDir); + const existing = await readJSON(filePath); + if (!existing) { + return { triggers: defaultVoiceWakeTriggers(), updatedAtMs: 0 }; + } + return { + triggers: sanitizeTriggers(existing.triggers), + updatedAtMs: + typeof existing.updatedAtMs === "number" && existing.updatedAtMs > 0 + ? existing.updatedAtMs + : 0, + }; +} + +export async function setVoiceWakeTriggers( + triggers: string[], + baseDir?: string, +): Promise { + const sanitized = sanitizeTriggers(triggers); + const filePath = resolvePath(baseDir); + return await withLock(async () => { + const next: VoiceWakeConfig = { + triggers: sanitized, + updatedAtMs: Date.now(), + }; + await writeJSONAtomic(filePath, next); + return next; + }); +}