feat(voicewake): add gateway-owned wake words sync

This commit is contained in:
Peter Steinberger
2025-12-14 05:05:06 +00:00
parent 26a05292b9
commit 1a92127dfa
5 changed files with 413 additions and 2 deletions

62
docs/voicewake.md Normal file
View File

@@ -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.

View File

@@ -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<T = unknown>(
}
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;

View File

@@ -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<string, unknown>;
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;

View File

@@ -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());
});
});

96
src/infra/voicewake.ts Normal file
View File

@@ -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<T>(filePath: string): Promise<T | null> {
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<void> = Promise.resolve();
async function withLock<T>(fn: () => Promise<T>): Promise<T> {
const prev = lock;
let release: (() => void) | undefined;
lock = new Promise<void>((resolve) => {
release = resolve;
});
await prev;
try {
return await fn();
} finally {
release?.();
}
}
export function defaultVoiceWakeTriggers() {
return [...DEFAULT_TRIGGERS];
}
export async function loadVoiceWakeConfig(
baseDir?: string,
): Promise<VoiceWakeConfig> {
const filePath = resolvePath(baseDir);
const existing = await readJSON<VoiceWakeConfig>(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<VoiceWakeConfig> {
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;
});
}