fix(deadcode): move voicewake settings to sqlite

This commit is contained in:
Vincent Koc
2026-06-21 20:25:40 +08:00
parent d9dfcd6c8a
commit bdf81a825f
9 changed files with 672 additions and 74 deletions
+9 -6
View File
@@ -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
+6 -3
View File
@@ -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",
@@ -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);
+2 -2
View File
@@ -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);
});
});
+118 -1
View File
@@ -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");
+331
View File
@@ -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<OpenClawStateKyselyDatabase, "plugin_state_entries">;
type LegacyVoiceWakeImportDatabase = Pick<
OpenClawStateKyselyDatabase,
"voicewake_routing_config" | "voicewake_routing_routes" | "voicewake_triggers"
>;
type SqliteBindRow = Record<string, SQLInputValue>;
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<typeof legacyVoiceWakeTargetColumns>,
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<typeof normalizeVoiceWakeRoutingConfig>,
): 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<LegacyVoiceWakeImportDatabase>(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<typeof normalizeVoiceWakeRoutingConfig> | 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<LegacyVoiceWakeImportDatabase>(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,
+137 -20
View File
@@ -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<VoiceWakeRoutingConfig> {
const filePath = resolvePath(baseDir);
const existing = await tryReadJson<unknown>(filePath);
if (!existing) {
const database = openStateDatabase(baseDir);
const routingDb = getNodeSqliteKysely<VoiceWakeRoutingDatabase>(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<VoiceWakeRoutingConfig> {
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<VoiceWakeRoutingDatabase>(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 };
+11 -5
View File
@@ -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,
);
});
});
});
+52 -25
View File
@@ -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<OpenClawStateKyselyDatabase, "voicewake_triggers">;
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<VoiceWakeConfig> {
const filePath = resolvePath(baseDir);
const existing = await tryReadJson<VoiceWakeConfig>(filePath);
if (!existing) {
const database = openStateDatabase(baseDir);
const voicewakeDb = getNodeSqliteKysely<VoiceWakeDatabase>(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<VoiceWakeConfig> {
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<VoiceWakeDatabase>(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,
};
}