mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(deadcode): move voicewake settings to sqlite
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user