feat(voicewake): add gateway-owned wake words sync
This commit is contained in:
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