diff --git a/docs/nodes/voicewake.md b/docs/nodes/voicewake.md index d91a5e3b11c9..2cd39d055840 100644 --- a/docs/nodes/voicewake.md +++ b/docs/nodes/voicewake.md @@ -15,15 +15,18 @@ OpenClaw treats **wake words as a single global list** owned by the **Gateway**. ## Storage (Gateway host) -Wake words are stored on the gateway machine at: +Wake words and routing rules are stored in the gateway state database: -- `~/.openclaw/settings/voicewake.json` +- `~/.openclaw/state/openclaw.sqlite` -Shape: +The active tables are: -```json -{ "triggers": ["openclaw", "claude", "computer"], "updatedAtMs": 1730000000000 } -``` +- `voicewake_triggers` +- `voicewake_routing_config` +- `voicewake_routing_routes` + +Legacy `settings/voicewake.json` and `settings/voicewake-routing.json` files are +doctor migration inputs only; runtime reads and writes the SQLite tables. ## Protocol diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index e4f418989672..17529b4ee857 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -105,9 +105,7 @@ const findLegacyGatewayServices = vi.fn().mockResolvedValue([]) as unknown as Mo const uninstallLegacyGatewayServices = vi.fn().mockResolvedValue([]) as unknown as MockFn; const findExtraGatewayServices = vi.fn().mockResolvedValue([]) as unknown as MockFn; const findSystemGatewayServices = vi.fn().mockResolvedValue([]) as unknown as MockFn; -const renderGatewayServiceCleanupHints = vi - .fn() - .mockReturnValue(["cleanup"]) as unknown as MockFn; +const renderGatewayServiceCleanupHints = vi.fn().mockReturnValue(["cleanup"]) as unknown as MockFn; const auditGatewayServiceConfig = vi .fn() .mockResolvedValue({ ok: true, issues: [] }) as unknown as MockFn; @@ -244,6 +242,11 @@ function createLegacyStateMigrationDetectionResult(params?: { sessionPath: "/tmp/state/session-delivery-queue", hasLegacy: false, }, + voiceWake: { + triggersPath: "/tmp/state/settings/voicewake.json", + routingPath: "/tmp/state/settings/voicewake-routing.json", + hasLegacy: false, + }, execApprovals: { sourcePath: "/tmp/state/exec-approvals.legacy.json", targetPath: "/tmp/state/exec-approvals.json", diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index 9316152cd94c..9b4162609cfe 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -379,11 +379,9 @@ describe("gateway server models + voicewake", () => { expect(after.ok).toBe(true); expect(after.payload?.triggers).toEqual(["hi", "there"]); - const onDisk = JSON.parse( - await fs.readFile(path.join(homeDir, ".openclaw", "settings", "voicewake.json"), "utf8"), - ) as { triggers?: unknown; updatedAtMs?: unknown }; - expect(onDisk.triggers).toEqual(["hi", "there"]); - expect(typeof onDisk.updatedAtMs).toBe("number"); + await expect( + fs.readFile(path.join(homeDir, ".openclaw", "settings", "voicewake.json"), "utf8"), + ).rejects.toThrow(/ENOENT/u); }); }, ); @@ -459,13 +457,9 @@ describe("gateway server models + voicewake", () => { { trigger: "robot wake", target: { agentId: "main" } }, ]); - const onDisk = JSON.parse( - await fs.readFile( - path.join(homeDir, ".openclaw", "settings", "voicewake-routing.json"), - "utf8", - ), - ) as { routes?: unknown }; - expect(onDisk.routes).toEqual([{ trigger: "robot wake", target: { agentId: "main" } }]); + await expect( + fs.readFile(path.join(homeDir, ".openclaw", "settings", "voicewake-routing.json"), "utf8"), + ).rejects.toThrow(/ENOENT/u); const invalid = await rpcReq(ws, "voicewake.routing.set", { config: null }); expect(invalid.ok).toBe(false); diff --git a/src/infra/infra-store.test.ts b/src/infra/infra-store.test.ts index e9f343f87cb3..dc190e423675 100644 --- a/src/infra/infra-store.test.ts +++ b/src/infra/infra-store.test.ts @@ -110,7 +110,7 @@ describe("infra store", () => { }); }); - it("sanitizes malformed persisted config values", async () => { + it("ignores retired JSON trigger files at runtime", async () => { await withTempDir("openclaw-voicewake-", async (baseDir) => { await fs.mkdir(path.join(baseDir, "settings"), { recursive: true }); await fs.writeFile( @@ -123,7 +123,7 @@ describe("infra store", () => { ); const loaded = await loadVoiceWakeConfig(baseDir); - expect(loaded.triggers).toEqual(["wake"]); + expect(loaded.triggers).toEqual(defaultVoiceWakeTriggers()); expect(loaded.updatedAtMs).toBe(0); }); }); diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index 634550bfac7a..1b952c1a87ea 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -7,7 +7,13 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveChannelAllowFromPath } from "../pairing/pairing-store.js"; import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js"; import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; -import { detectLegacyStateMigrations, runLegacyStateMigrations } from "./state-migrations.js"; +import { + autoMigrateLegacyState, + detectLegacyStateMigrations, + runLegacyStateMigrations, +} from "./state-migrations.js"; +import { loadVoiceWakeRoutingConfig, setVoiceWakeRoutingConfig } from "./voicewake-routing.js"; +import { loadVoiceWakeConfig, setVoiceWakeTriggers } from "./voicewake.js"; vi.mock("../channels/plugins/bundled.js", () => { function fileExists(filePath: string): boolean { @@ -401,6 +407,117 @@ describe("state migrations", () => { await expectMissingPath(path.join(stateDir, "session-delivery-queue")); }); + it("migrates legacy voice wake JSON settings into shared SQLite state", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const cfg = createConfig(); + const settingsDir = path.join(stateDir, "settings"); + const triggersPath = path.join(settingsDir, "voicewake.json"); + const routingPath = path.join(settingsDir, "voicewake-routing.json"); + await fs.mkdir(settingsDir, { recursive: true }); + await fs.writeFile( + triggersPath, + JSON.stringify({ triggers: [" wake ", "", "there"], updatedAtMs: -1 }), + "utf8", + ); + await fs.writeFile( + routingPath, + JSON.stringify({ + defaultTarget: { mode: "current" }, + routes: [ + { trigger: " Robot Wake ", target: { agentId: "Main Agent" } }, + { trigger: "", target: { sessionKey: "agent:main:voice" } }, + ], + }), + "utf8", + ); + + const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); + expect(detected.voiceWake.hasLegacy).toBe(true); + expect(detected.preview).toContain( + "- Voice Wake settings: legacy JSON files → shared SQLite state", + ); + + const result = await runLegacyStateMigrations({ detected, config: cfg }); + + expect(result.warnings).toStrictEqual([]); + expect(result.changes).toContain("Migrated 2 voice wake triggers → shared SQLite state"); + expect(result.changes).toContain( + "Migrated voice wake routing config with 1 route → shared SQLite state", + ); + await expect(loadVoiceWakeConfig(stateDir)).resolves.toMatchObject({ + triggers: ["wake", "there"], + }); + await expect(loadVoiceWakeRoutingConfig(stateDir)).resolves.toMatchObject({ + defaultTarget: { mode: "current" }, + routes: [{ trigger: "robot wake", target: { agentId: "main-agent" } }], + }); + await expectMissingPath(triggersPath); + await expectMissingPath(routingPath); + await expect(fs.readFile(`${triggersPath}.migrated`, "utf8")).resolves.toContain("wake"); + await expect(fs.readFile(`${routingPath}.migrated`, "utf8")).resolves.toContain("Robot"); + }); + + it("archives legacy voice wake JSON when shared SQLite already matches", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const cfg = createConfig(); + const settingsDir = path.join(stateDir, "settings"); + const triggersPath = path.join(settingsDir, "voicewake.json"); + const routingPath = path.join(settingsDir, "voicewake-routing.json"); + await setVoiceWakeTriggers(["wake"], stateDir); + await setVoiceWakeRoutingConfig( + { + defaultTarget: { mode: "current" }, + routes: [{ trigger: "robot wake", target: { agentId: "main" } }], + }, + stateDir, + ); + await fs.mkdir(settingsDir, { recursive: true }); + await fs.writeFile(triggersPath, JSON.stringify({ triggers: ["wake"] }), "utf8"); + await fs.writeFile( + routingPath, + JSON.stringify({ + defaultTarget: { mode: "current" }, + routes: [{ trigger: "robot wake", target: { agentId: "main" } }], + }), + "utf8", + ); + + const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + + expect(result.warnings).toStrictEqual([]); + await expectMissingPath(triggersPath); + await expectMissingPath(routingPath); + await expect(fs.readFile(`${triggersPath}.migrated`, "utf8")).resolves.toContain("wake"); + await expect(fs.readFile(`${routingPath}.migrated`, "utf8")).resolves.toContain("robot wake"); + }); + + it("auto-migrates standalone legacy voice wake JSON settings", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const env = createEnv(stateDir); + const cfg = createConfig(); + const settingsDir = path.join(stateDir, "settings"); + await fs.mkdir(settingsDir, { recursive: true }); + await fs.writeFile( + path.join(settingsDir, "voicewake.json"), + JSON.stringify({ triggers: ["wake"] }), + "utf8", + ); + + const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root }); + + expect(result.skipped).toBe(false); + expect(result.migrated).toBe(true); + expect(result.warnings).toStrictEqual([]); + await expect(loadVoiceWakeConfig(stateDir)).resolves.toMatchObject({ triggers: ["wake"] }); + await expectMissingPath(path.join(settingsDir, "voicewake.json")); + }); + it("keeps legacy delivery queue files when shared SQLite already has a conflicting row", async () => { const root = await createTempDir(); const stateDir = path.join(root, ".openclaw"); diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index a6dd8208453f..fb00b23254d1 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -84,6 +84,7 @@ import { type SessionEntryLike, safeReadDir, } from "./state-migrations.fs.js"; +import { normalizeVoiceWakeRoutingConfig } from "./voicewake-routing.js"; export type LegacyStateDetection = { targetAgentId: string; @@ -139,6 +140,11 @@ export type LegacyStateDetection = { sessionPath: string; hasLegacy: boolean; }; + voiceWake: { + triggersPath: string; + routingPath: string; + hasLegacy: boolean; + }; execApprovals: { sourcePath: string; targetPath: string; @@ -177,6 +183,10 @@ type LegacyPluginStateSidecarRow = { }; type LegacyPluginStateImportDatabase = Pick; +type LegacyVoiceWakeImportDatabase = Pick< + OpenClawStateKyselyDatabase, + "voicewake_routing_config" | "voicewake_routing_routes" | "voicewake_triggers" +>; type SqliteBindRow = Record; type DetectedPluginDoctorStateMigrationPlan = { @@ -1495,6 +1505,295 @@ async function migrateLegacyDeliveryQueues(params: { return { changes, warnings }; } +const VOICEWAKE_CONFIG_KEY = "default"; +const DEFAULT_VOICEWAKE_TRIGGERS = ["openclaw", "claude", "computer"]; + +function resolveLegacyVoiceWakeTriggersPath(stateDir: string): string { + return path.join(stateDir, "settings", "voicewake.json"); +} + +function resolveLegacyVoiceWakeRoutingPath(stateDir: string): string { + return path.join(stateDir, "settings", "voicewake-routing.json"); +} + +function readLegacyJsonObject(sourcePath: string): unknown { + return JSON.parse(fs.readFileSync(sourcePath, "utf8")) as unknown; +} + +function normalizeLegacyVoiceWakeTriggers(input: unknown): string[] { + const rec = input && typeof input === "object" ? (input as { triggers?: unknown }) : {}; + const triggers = Array.isArray(rec.triggers) + ? rec.triggers + .flatMap((entry) => (typeof entry === "string" ? [entry.trim()] : [])) + .filter((entry) => entry.length > 0) + : []; + return triggers.length > 0 ? triggers : DEFAULT_VOICEWAKE_TRIGGERS; +} + +function legacyVoiceWakeTriggersMatch( + rows: Array<{ trigger: string }>, + triggers: string[], +): boolean { + return ( + rows.length === triggers.length && rows.every((row, index) => row.trigger === triggers[index]) + ); +} + +function legacyVoiceWakeTargetColumns(target: { + agentId?: string; + mode?: "current"; + sessionKey?: string; +}): { + targetAgentId: string | null; + targetMode: string; + targetSessionKey: string | null; +} { + if (target.agentId) { + return { targetAgentId: target.agentId, targetMode: "agent", targetSessionKey: null }; + } + if (target.sessionKey) { + return { targetAgentId: null, targetMode: "session", targetSessionKey: target.sessionKey }; + } + return { targetAgentId: null, targetMode: "current", targetSessionKey: null }; +} + +function legacyVoiceWakeTargetColumnsMatch( + left: ReturnType, + right: { + target_agent_id?: string | null; + target_mode?: string | null; + target_session_key?: string | null; + }, +): boolean { + return ( + left.targetAgentId === (right.target_agent_id ?? null) && + left.targetMode === right.target_mode && + left.targetSessionKey === (right.target_session_key ?? null) + ); +} + +function legacyVoiceWakeRoutingMatches( + configRow: { + default_target_agent_id: string | null; + default_target_mode: string; + default_target_session_key: string | null; + }, + routeRows: Array<{ + target_agent_id: string | null; + target_mode: string; + target_session_key: string | null; + trigger: string; + }>, + routingConfig: ReturnType, +): boolean { + const defaultTarget = legacyVoiceWakeTargetColumns(routingConfig.defaultTarget); + if ( + !legacyVoiceWakeTargetColumnsMatch(defaultTarget, { + target_agent_id: configRow.default_target_agent_id, + target_mode: configRow.default_target_mode, + target_session_key: configRow.default_target_session_key, + }) + ) { + return false; + } + return ( + routeRows.length === routingConfig.routes.length && + routeRows.every((row, index) => { + const route = routingConfig.routes[index]; + if (!route || row.trigger !== route.trigger) { + return false; + } + return legacyVoiceWakeTargetColumnsMatch(legacyVoiceWakeTargetColumns(route.target), row); + }) + ); +} + +function migrateLegacyVoiceWakeSettings(params: { + detected: LegacyStateDetection["voiceWake"]; + stateDir: string; +}): { changes: string[]; warnings: string[] } { + const changes: string[] = []; + const warnings: string[] = []; + const env = { ...process.env, OPENCLAW_STATE_DIR: params.stateDir }; + if (fileExists(params.detected.triggersPath)) { + let triggers: string[]; + try { + triggers = normalizeLegacyVoiceWakeTriggers( + readLegacyJsonObject(params.detected.triggersPath), + ); + } catch (err) { + warnings.push( + `Failed reading legacy voice wake triggers ${params.detected.triggersPath}: ${String(err)}`, + ); + triggers = []; + } + if (triggers.length > 0) { + let imported = false; + let shouldArchive = false; + try { + runOpenClawStateWriteTransaction( + ({ db }) => { + const stateDb = getNodeSqliteKysely(db); + const existing = executeSqliteQuerySync( + db, + stateDb + .selectFrom("voicewake_triggers") + .select(["trigger"]) + .where("config_key", "=", VOICEWAKE_CONFIG_KEY) + .orderBy("position", "asc"), + ).rows; + if (existing.length > 0) { + if (!legacyVoiceWakeTriggersMatch(existing, triggers)) { + warnings.push( + `Left legacy voice wake triggers in place because shared SQLite state already has different triggers: ${params.detected.triggersPath}`, + ); + } else { + shouldArchive = true; + } + return; + } + const updatedAtMs = Date.now(); + executeSqliteQuerySync( + db, + stateDb.insertInto("voicewake_triggers").values( + triggers.map((trigger, position) => ({ + config_key: VOICEWAKE_CONFIG_KEY, + position, + trigger, + updated_at_ms: updatedAtMs, + })), + ), + ); + imported = true; + shouldArchive = true; + }, + { env }, + ); + } catch (err) { + warnings.push(`Failed migrating legacy voice wake triggers: ${String(err)}`); + } + if (imported) { + changes.push( + `Migrated ${triggers.length} voice wake ${triggers.length === 1 ? "trigger" : "triggers"} → shared SQLite state`, + ); + } + if (shouldArchive) { + archiveLegacyImportSource({ + sourcePath: params.detected.triggersPath, + label: "voice wake triggers", + changes, + warnings, + }); + } + } + } + + if (fileExists(params.detected.routingPath)) { + let routingConfig: ReturnType | null = null; + try { + routingConfig = normalizeVoiceWakeRoutingConfig( + readLegacyJsonObject(params.detected.routingPath), + ); + } catch (err) { + warnings.push( + `Failed reading legacy voice wake routing ${params.detected.routingPath}: ${String(err)}`, + ); + } + if (routingConfig) { + let imported = false; + let shouldArchive = false; + try { + runOpenClawStateWriteTransaction( + ({ db }) => { + const stateDb = getNodeSqliteKysely(db); + const existing = executeSqliteQueryTakeFirstSync( + db, + stateDb + .selectFrom("voicewake_routing_config") + .select([ + "default_target_agent_id", + "default_target_mode", + "default_target_session_key", + ]) + .where("config_key", "=", VOICEWAKE_CONFIG_KEY), + ); + if (existing) { + const routeRows = executeSqliteQuerySync( + db, + stateDb + .selectFrom("voicewake_routing_routes") + .select(["target_agent_id", "target_mode", "target_session_key", "trigger"]) + .where("config_key", "=", VOICEWAKE_CONFIG_KEY) + .orderBy("position", "asc"), + ).rows; + if (legacyVoiceWakeRoutingMatches(existing, routeRows, routingConfig)) { + shouldArchive = true; + } else { + warnings.push( + `Left legacy voice wake routing in place because shared SQLite routing already exists with different routes: ${params.detected.routingPath}`, + ); + } + return; + } + const updatedAtMs = Date.now(); + const defaultTarget = legacyVoiceWakeTargetColumns(routingConfig.defaultTarget); + executeSqliteQuerySync( + db, + stateDb.insertInto("voicewake_routing_config").values({ + config_key: VOICEWAKE_CONFIG_KEY, + version: 1, + default_target_mode: defaultTarget.targetMode, + default_target_agent_id: defaultTarget.targetAgentId, + default_target_session_key: defaultTarget.targetSessionKey, + updated_at_ms: updatedAtMs, + }), + ); + if (routingConfig.routes.length > 0) { + executeSqliteQuerySync( + db, + stateDb.insertInto("voicewake_routing_routes").values( + routingConfig.routes.map((route, position) => { + const target = legacyVoiceWakeTargetColumns(route.target); + return { + config_key: VOICEWAKE_CONFIG_KEY, + position, + trigger: route.trigger, + target_mode: target.targetMode, + target_agent_id: target.targetAgentId, + target_session_key: target.targetSessionKey, + updated_at_ms: updatedAtMs, + }; + }), + ), + ); + } + imported = true; + shouldArchive = true; + }, + { env }, + ); + } catch (err) { + warnings.push(`Failed migrating legacy voice wake routing: ${String(err)}`); + } + if (imported) { + changes.push( + `Migrated voice wake routing config with ${routingConfig.routes.length} ${routingConfig.routes.length === 1 ? "route" : "routes"} → shared SQLite state`, + ); + } + if (shouldArchive) { + archiveLegacyImportSource({ + sourcePath: params.detected.routingPath, + label: "voice wake routing", + changes, + warnings, + }); + } + } + } + + return { changes, warnings }; +} + async function migrateLegacyPluginStateSidecar(params: { stateDir: string; }): Promise<{ changes: string[]; warnings: string[] }> { @@ -2914,6 +3213,11 @@ export async function detectLegacyStateMigrations(params: { listLegacyDeliveryQueueDeliveredMarkers(deliveryQueuePaths.outboundPath).length > 0 || listLegacyDeliveryQueueFiles(deliveryQueuePaths.sessionPath).length > 0 || listLegacyDeliveryQueueDeliveredMarkers(deliveryQueuePaths.sessionPath).length > 0; + const voiceWake = { + triggersPath: resolveLegacyVoiceWakeTriggersPath(stateDir), + routingPath: resolveLegacyVoiceWakeRoutingPath(stateDir), + }; + const hasVoiceWake = fileExists(voiceWake.triggersPath) || fileExists(voiceWake.routingPath); const channelPlans = await collectChannelLegacyStateMigrationPlans({ cfg: params.cfg, env, @@ -2975,6 +3279,9 @@ export async function detectLegacyStateMigrations(params: { if (hasDeliveryQueues) { preview.push("- Delivery queues: legacy JSON queue files → shared SQLite state"); } + if (hasVoiceWake) { + preview.push("- Voice Wake settings: legacy JSON files → shared SQLite state"); + } if (execApprovals.hasLegacy) { preview.push(`- Exec approvals: ${execApprovals.sourcePath} → ${execApprovals.targetPath}`); } @@ -3034,6 +3341,10 @@ export async function detectLegacyStateMigrations(params: { ...deliveryQueuePaths, hasLegacy: hasDeliveryQueues, }, + voiceWake: { + ...voiceWake, + hasLegacy: hasVoiceWake, + }, execApprovals, preview, }; @@ -3552,6 +3863,10 @@ export async function runLegacyStateMigrations(params: { const deliveryQueues = await migrateLegacyDeliveryQueues({ stateDir: detected.stateDir, }); + const voiceWake = migrateLegacyVoiceWakeSettings({ + detected: detected.voiceWake, + stateDir: detected.stateDir, + }); const execApprovals = migrateLegacyExecApprovals(detected.execApprovals); const preSessionChannelPlans = await runLegacyMigrationPlans( detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"), @@ -3582,6 +3897,7 @@ export async function runLegacyStateMigrations(params: { ...debugProxyCaptureSidecar.changes, ...taskStateSidecars.changes, ...deliveryQueues.changes, + ...voiceWake.changes, ...execApprovals.changes, ...preSessionChannelPlans.changes, ...pluginPlans.changes, @@ -3597,6 +3913,7 @@ export async function runLegacyStateMigrations(params: { ...debugProxyCaptureSidecar.warnings, ...taskStateSidecars.warnings, ...deliveryQueues.warnings, + ...voiceWake.warnings, ...execApprovals.warnings, ...preSessionChannelPlans.warnings, ...pluginPlans.warnings, @@ -3918,6 +4235,10 @@ export async function autoMigrateLegacyState(params: { const deliveryQueues = await migrateLegacyDeliveryQueues({ stateDir: detected.stateDir, }); + const voiceWake = migrateLegacyVoiceWakeSettings({ + detected: detected.voiceWake, + stateDir: detected.stateDir, + }); const execApprovals = migrateLegacyExecApprovals(detected.execApprovals); const preSessionChannelPlans = await runLegacyMigrationPlans( detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"), @@ -3936,6 +4257,7 @@ export async function autoMigrateLegacyState(params: { ...debugProxyCaptureSidecar.changes, ...taskStateSidecars.changes, ...deliveryQueues.changes, + ...voiceWake.changes, ...execApprovals.changes, ...preSessionChannelPlans.changes, ...pluginPlans.changes, @@ -3950,6 +4272,7 @@ export async function autoMigrateLegacyState(params: { ...debugProxyCaptureSidecar.warnings, ...taskStateSidecars.warnings, ...deliveryQueues.warnings, + ...voiceWake.warnings, ...execApprovals.warnings, ...preSessionChannelPlans.warnings, ...pluginPlans.warnings, @@ -3966,6 +4289,7 @@ export async function autoMigrateLegacyState(params: { debugProxyCaptureSidecar.changes.length > 0 || taskStateSidecars.changes.length > 0 || deliveryQueues.changes.length > 0 || + voiceWake.changes.length > 0 || execApprovals.changes.length > 0 || preSessionChannelPlans.changes.length > 0 || pluginPlans.changes.length > 0, @@ -3985,6 +4309,7 @@ export async function autoMigrateLegacyState(params: { !detected.stateSchema.hasLegacy && !detected.taskStateSidecars.hasLegacy && !detected.deliveryQueues.hasLegacy && + !detected.voiceWake.hasLegacy && !detected.execApprovals.hasLegacy ) { const changes = [ @@ -4029,6 +4354,10 @@ export async function autoMigrateLegacyState(params: { const deliveryQueues = await migrateLegacyDeliveryQueues({ stateDir: detected.stateDir, }); + const voiceWake = migrateLegacyVoiceWakeSettings({ + detected: detected.voiceWake, + stateDir: detected.stateDir, + }); const execApprovals = migrateLegacyExecApprovals(detected.execApprovals); const preSessionChannelPlans = await runLegacyMigrationPlans( detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"), @@ -4059,6 +4388,7 @@ export async function autoMigrateLegacyState(params: { ...debugProxyCaptureSidecar.changes, ...taskStateSidecars.changes, ...deliveryQueues.changes, + ...voiceWake.changes, ...execApprovals.changes, ...preSessionChannelPlans.changes, ...pluginPlans.changes, @@ -4077,6 +4407,7 @@ export async function autoMigrateLegacyState(params: { ...debugProxyCaptureSidecar.warnings, ...taskStateSidecars.warnings, ...deliveryQueues.warnings, + ...voiceWake.warnings, ...execApprovals.warnings, ...preSessionChannelPlans.warnings, ...pluginPlans.warnings, diff --git a/src/infra/voicewake-routing.ts b/src/infra/voicewake-routing.ts index 8f6c885bd280..2be91f93be98 100644 --- a/src/infra/voicewake-routing.ts +++ b/src/infra/voicewake-routing.ts @@ -1,14 +1,21 @@ // Persists and resolves voice wake routing rules. -import path from "node:path"; import { isRecord as isPlainObject } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { resolveStateDir } from "../config/paths.js"; import { classifySessionKeyShape, isValidAgentId, normalizeAgentId, } from "../routing/session-key.js"; -import { createAsyncLock, tryReadJson, writeJson } from "./json-files.js"; +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; +import { + openOpenClawStateDatabase, + runOpenClawStateWriteTransaction, +} from "../state/openclaw-state-db.js"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "./kysely-sync.js"; // Voice wake routing maps normalized wake phrases to an agent, session key, or // current session target and persists the mapping under state settings. @@ -31,6 +38,7 @@ export type VoiceWakeRoutingConfig = { const MAX_VOICEWAKE_ROUTES = 32; const MAX_VOICEWAKE_TRIGGER_LENGTH = 64; +const VOICEWAKE_ROUTING_CONFIG_KEY = "default"; const DEFAULT_ROUTING: VoiceWakeRoutingConfig = { version: 1, @@ -39,9 +47,15 @@ const DEFAULT_ROUTING: VoiceWakeRoutingConfig = { updatedAtMs: 0, }; -function resolvePath(baseDir?: string) { - const root = baseDir ?? resolveStateDir(); - return path.join(root, "settings", "voicewake-routing.json"); +type VoiceWakeRoutingDatabase = Pick< + OpenClawStateKyselyDatabase, + "voicewake_routing_config" | "voicewake_routing_routes" +>; + +function openStateDatabase(stateDir?: string) { + return openOpenClawStateDatabase({ + env: stateDir ? { ...process.env, OPENCLAW_STATE_DIR: stateDir } : process.env, + }); } /** Normalize a voice wake trigger phrase for matching and duplicate checks. */ @@ -256,18 +270,75 @@ export function normalizeVoiceWakeRoutingConfig(input: unknown): VoiceWakeRoutin }; } -const withLock = createAsyncLock(); +function targetColumns(target: VoiceWakeRouteTarget): { + targetAgentId: string | null; + targetMode: string; + targetSessionKey: string | null; +} { + if ("agentId" in target && target.agentId) { + return { targetAgentId: target.agentId, targetMode: "agent", targetSessionKey: null }; + } + if ("sessionKey" in target && target.sessionKey) { + return { targetAgentId: null, targetMode: "session", targetSessionKey: target.sessionKey }; + } + return { targetAgentId: null, targetMode: "current", targetSessionKey: null }; +} + +function targetFromColumns(params: { + agentId: string | null; + mode: string; + sessionKey: string | null; +}): VoiceWakeRouteTarget { + if (params.mode === "agent" && params.agentId) { + return { agentId: params.agentId }; + } + if (params.mode === "session" && params.sessionKey) { + return { sessionKey: params.sessionKey }; + } + return { mode: "current" }; +} /** Load persisted voice wake routing config from state. */ export async function loadVoiceWakeRoutingConfig( baseDir?: string, ): Promise { - const filePath = resolvePath(baseDir); - const existing = await tryReadJson(filePath); - if (!existing) { + const database = openStateDatabase(baseDir); + const routingDb = getNodeSqliteKysely(database.db); + const configRow = executeSqliteQueryTakeFirstSync( + database.db, + routingDb + .selectFrom("voicewake_routing_config") + .selectAll() + .where("config_key", "=", VOICEWAKE_ROUTING_CONFIG_KEY), + ); + if (!configRow) { return { ...DEFAULT_ROUTING }; } - return normalizeVoiceWakeRoutingConfig(existing); + const routeRows = executeSqliteQuerySync( + database.db, + routingDb + .selectFrom("voicewake_routing_routes") + .selectAll() + .where("config_key", "=", VOICEWAKE_ROUTING_CONFIG_KEY) + .orderBy("position", "asc"), + ).rows; + return { + version: 1, + defaultTarget: targetFromColumns({ + agentId: configRow.default_target_agent_id, + mode: configRow.default_target_mode, + sessionKey: configRow.default_target_session_key, + }), + routes: routeRows.map((row) => ({ + trigger: row.trigger, + target: targetFromColumns({ + agentId: row.target_agent_id, + mode: row.target_mode, + sessionKey: row.target_session_key, + }), + })), + updatedAtMs: configRow.updated_at_ms, + }; } /** Persist normalized voice wake routing config. */ @@ -276,15 +347,61 @@ export async function setVoiceWakeRoutingConfig( baseDir?: string, ): Promise { const normalized = normalizeVoiceWakeRoutingConfig(config); - const filePath = resolvePath(baseDir); - return await withLock(async () => { - const next: VoiceWakeRoutingConfig = { - ...normalized, - updatedAtMs: Date.now(), - }; - await writeJson(filePath, next); - return next; - }); + const updatedAtMs = Date.now(); + const next: VoiceWakeRoutingConfig = { + ...normalized, + updatedAtMs, + }; + runOpenClawStateWriteTransaction( + ({ db }) => { + const routingDb = getNodeSqliteKysely(db); + executeSqliteQuerySync( + db, + routingDb + .deleteFrom("voicewake_routing_routes") + .where("config_key", "=", VOICEWAKE_ROUTING_CONFIG_KEY), + ); + executeSqliteQuerySync( + db, + routingDb + .deleteFrom("voicewake_routing_config") + .where("config_key", "=", VOICEWAKE_ROUTING_CONFIG_KEY), + ); + const defaultTarget = targetColumns(next.defaultTarget); + executeSqliteQuerySync( + db, + routingDb.insertInto("voicewake_routing_config").values({ + config_key: VOICEWAKE_ROUTING_CONFIG_KEY, + version: 1, + default_target_mode: defaultTarget.targetMode, + default_target_agent_id: defaultTarget.targetAgentId, + default_target_session_key: defaultTarget.targetSessionKey, + updated_at_ms: updatedAtMs, + }), + ); + if (next.routes.length > 0) { + executeSqliteQuerySync( + db, + routingDb.insertInto("voicewake_routing_routes").values( + next.routes.map((route, position) => { + const target = targetColumns(route.target); + return { + config_key: VOICEWAKE_ROUTING_CONFIG_KEY, + position, + trigger: route.trigger, + target_mode: target.targetMode, + target_agent_id: target.targetAgentId, + target_session_key: target.targetSessionKey, + updated_at_ms: updatedAtMs, + }; + }), + ), + ); + } + }, + baseDir ? { env: { ...process.env, OPENCLAW_STATE_DIR: baseDir } } : {}, + ); + return next; } type VoiceWakeResolvedRoute = { mode: "current" } | { agentId: string } | { sessionKey: string }; diff --git a/src/infra/voicewake.test.ts b/src/infra/voicewake.test.ts index dc0945579b91..d2987d9f3061 100644 --- a/src/infra/voicewake.test.ts +++ b/src/infra/voicewake.test.ts @@ -32,11 +32,8 @@ describe("voicewake config", () => { }); }); - it("falls back to defaults for empty or malformed persisted values", async () => { + it("does not read retired JSON trigger files at runtime", async () => { await withTempDir("openclaw-voicewake-", async (baseDir) => { - const emptySaved = await setVoiceWakeTriggers(["", " "], baseDir); - expect(emptySaved.triggers).toEqual(defaultVoiceWakeTriggers()); - await fs.mkdir(path.join(baseDir, "settings"), { recursive: true }); await fs.writeFile( path.join(baseDir, "settings", "voicewake.json"), @@ -48,9 +45,18 @@ describe("voicewake config", () => { ); await expect(loadVoiceWakeConfig(baseDir)).resolves.toEqual({ - triggers: ["wake"], + triggers: defaultVoiceWakeTriggers(), updatedAtMs: 0, }); }); }); + + it("does not recreate the retired JSON trigger file", async () => { + await withTempDir("openclaw-voicewake-", async (baseDir) => { + await setVoiceWakeTriggers(["wake"], baseDir); + await expect(fs.readFile(path.join(baseDir, "settings", "voicewake.json"))).rejects.toThrow( + /ENOENT/u, + ); + }); + }); }); diff --git a/src/infra/voicewake.ts b/src/infra/voicewake.ts index c010125e45f6..b8e91c76fdf3 100644 --- a/src/infra/voicewake.ts +++ b/src/infra/voicewake.ts @@ -1,8 +1,11 @@ // Stores voice wake trigger configuration. -import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { resolveStateDir } from "../config/paths.js"; -import { createAsyncLock, tryReadJson, writeJson } from "./json-files.js"; +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; +import { + openOpenClawStateDatabase, + runOpenClawStateWriteTransaction, +} from "../state/openclaw-state-db.js"; +import { executeSqliteQuerySync, getNodeSqliteKysely } from "./kysely-sync.js"; // Voice wake config stores trigger words used by local voice integrations. type VoiceWakeConfig = { @@ -11,11 +14,9 @@ type VoiceWakeConfig = { }; const DEFAULT_TRIGGERS = ["openclaw", "claude", "computer"]; +const VOICEWAKE_CONFIG_KEY = "default"; -function resolvePath(baseDir?: string) { - const root = baseDir ?? resolveStateDir(); - return path.join(root, "settings", "voicewake.json"); -} +type VoiceWakeDatabase = Pick; function sanitizeTriggers(triggers: string[] | undefined | null): string[] { const cleaned = (triggers ?? []) @@ -24,7 +25,11 @@ function sanitizeTriggers(triggers: string[] | undefined | null): string[] { return cleaned.length > 0 ? cleaned : DEFAULT_TRIGGERS; } -const withLock = createAsyncLock(); +function openStateDatabase(stateDir?: string) { + return openOpenClawStateDatabase({ + env: stateDir ? { ...process.env, OPENCLAW_STATE_DIR: stateDir } : process.env, + }); +} /** Return the built-in voice wake trigger list. */ export function defaultVoiceWakeTriggers() { @@ -33,17 +38,22 @@ export function defaultVoiceWakeTriggers() { /** Load persisted voice wake triggers, falling back to defaults. */ export async function loadVoiceWakeConfig(baseDir?: string): Promise { - const filePath = resolvePath(baseDir); - const existing = await tryReadJson(filePath); - if (!existing) { + const database = openStateDatabase(baseDir); + const voicewakeDb = getNodeSqliteKysely(database.db); + const rows = executeSqliteQuerySync( + database.db, + voicewakeDb + .selectFrom("voicewake_triggers") + .select(["trigger", "updated_at_ms"]) + .where("config_key", "=", VOICEWAKE_CONFIG_KEY) + .orderBy("position", "asc"), + ).rows; + if (rows.length === 0) { return { triggers: defaultVoiceWakeTriggers(), updatedAtMs: 0 }; } return { - triggers: sanitizeTriggers(existing.triggers), - updatedAtMs: - typeof existing.updatedAtMs === "number" && existing.updatedAtMs > 0 - ? existing.updatedAtMs - : 0, + triggers: sanitizeTriggers(rows.map((row) => row.trigger)), + updatedAtMs: Math.max(0, ...rows.map((row) => row.updated_at_ms)), }; } @@ -53,13 +63,30 @@ export async function setVoiceWakeTriggers( baseDir?: string, ): Promise { const sanitized = sanitizeTriggers(triggers); - const filePath = resolvePath(baseDir); - return await withLock(async () => { - const next: VoiceWakeConfig = { - triggers: sanitized, - updatedAtMs: Date.now(), - }; - await writeJson(filePath, next); - return next; - }); + const updatedAtMs = Date.now(); + runOpenClawStateWriteTransaction( + ({ db }) => { + const voicewakeDb = getNodeSqliteKysely(db); + executeSqliteQuerySync( + db, + voicewakeDb.deleteFrom("voicewake_triggers").where("config_key", "=", VOICEWAKE_CONFIG_KEY), + ); + executeSqliteQuerySync( + db, + voicewakeDb.insertInto("voicewake_triggers").values( + sanitized.map((trigger, position) => ({ + config_key: VOICEWAKE_CONFIG_KEY, + position, + trigger, + updated_at_ms: updatedAtMs, + })), + ), + ); + }, + baseDir ? { env: { ...process.env, OPENCLAW_STATE_DIR: baseDir } } : {}, + ); + return { + triggers: sanitized, + updatedAtMs, + }; }