mirror of
https://github.com/clawdbot/clawdbot.git
synced 2026-02-01 03:47:45 +01:00
feat(voicewake): add gateway-owned wake words sync
This commit is contained in:
62
docs/voicewake.md
Normal file
62
docs/voicewake.md
Normal 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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
46
src/infra/voicewake.test.ts
Normal file
46
src/infra/voicewake.test.ts
Normal 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
96
src/infra/voicewake.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user