@voicyclaw/voicyclaw 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yaoshen Luo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # VoicyClaw OpenClaw Plugin
2
+
3
+ This package is the standalone OpenClaw plugin for VoicyClaw.
4
+
5
+ Current scope:
6
+
7
+ - registers a real `voicyclaw` channel plugin
8
+ - owns an outbound WebSocket gateway connector
9
+ - performs the VoicyClaw `HELLO` / `WELCOME` handshake
10
+ - forwards final `STT_RESULT` turns into the OpenClaw agent runtime
11
+ - maps OpenClaw block/final replies back into VoicyClaw preview/text events
12
+ - tracks connection status through `voicyclaw.status`
13
+ - keeps a guarded `devEchoReplies` mode for transport smoke tests
14
+
15
+ What is not wired yet:
16
+
17
+ - audio frame handling beyond the protocol skeleton
18
+ - richer non-text payload delivery beyond text plus attachment URLs
19
+
20
+ ## Local Development
21
+
22
+ Install dependencies inside this directory. Because this package lives inside
23
+ the main VoicyClaw monorepo but stays intentionally outside the workspace, use
24
+ `--ignore-workspace` for the initial install so pnpm creates the standalone
25
+ lockfile here.
26
+
27
+ ```bash
28
+ pnpm install --ignore-workspace
29
+ ```
30
+
31
+ Run the local checks for the standalone package:
32
+
33
+ ```bash
34
+ pnpm lint
35
+ pnpm test
36
+ pnpm typecheck
37
+ ```
38
+
39
+ The test suite includes a wire-level smoke case that covers the minimal
40
+ VoicyClaw workflow:
41
+
42
+ - `HELLO`
43
+ - `WELCOME`
44
+ - `STT_RESULT`
45
+ - `BOT_PREVIEW`
46
+ - `TTS_TEXT`
47
+
48
+ ## Link Into OpenClaw
49
+
50
+ ```bash
51
+ openclaw plugins install --link /Users/lyshen/Desktop/project/VoicyClaw/extensions/voicyclaw
52
+ openclaw gateway restart
53
+ ```
54
+
55
+ ## Channel Config
56
+
57
+ Configure the connector under `channels.voicyclaw`.
58
+
59
+ Minimal local example:
60
+
61
+ ```json
62
+ {
63
+ "channels": {
64
+ "voicyclaw": {
65
+ "url": "http://127.0.0.1:3001",
66
+ "token": "vc_xxx",
67
+ "channelId": "demo-room",
68
+ "botId": "openclaw-local",
69
+ "displayName": "OpenClaw Local"
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ Important:
76
+
77
+ - `channels.voicyclaw.token` is the VoicyClaw API key issued by the VoicyClaw
78
+ server
79
+ - it is not the same thing as `gateway.auth.token` from OpenClaw
80
+ - if the wrong token is configured, the OpenClaw logs will show
81
+ `AUTH_FAILED Invalid or expired API key.`
82
+
83
+ Optional development-only transport echo:
84
+
85
+ ```json
86
+ {
87
+ "channels": {
88
+ "voicyclaw": {
89
+ "devEchoReplies": true
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
95
+ When `devEchoReplies` is enabled, the plugin sends a simple echoed `TTS_TEXT`
96
+ reply after a final transcript arrives, bypassing the real OpenClaw dispatch
97
+ path. That is only for isolating transport problems during local debugging.
package/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
+
4
+ import { createVoicyClawChannel } from "./src/channel.js";
5
+ import { createVoicyClawRuntime, setVoicyClawRuntime } from "./src/runtime.js";
6
+
7
+ const plugin = {
8
+ id: "voicyclaw",
9
+ name: "VoicyClaw",
10
+ description: "Outbound VoicyClaw channel connector for OpenClaw",
11
+ configSchema: emptyPluginConfigSchema(),
12
+ register(api: OpenClawPluginApi) {
13
+ const runtime = createVoicyClawRuntime();
14
+
15
+ setVoicyClawRuntime(runtime);
16
+
17
+ api.registerChannel({
18
+ plugin: createVoicyClawChannel(runtime, api.runtime.channel),
19
+ });
20
+ api.registerGatewayMethod("voicyclaw.status", ({ respond }) => {
21
+ respond(true, {
22
+ accounts: runtime.listSnapshots(),
23
+ });
24
+ });
25
+ },
26
+ };
27
+
28
+ export default plugin;
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "voicyclaw",
3
+ "channels": ["voicyclaw"],
4
+ "configSchema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {}
8
+ }
9
+ }
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@voicyclaw/voicyclaw",
3
+ "version": "0.0.2",
4
+ "private": false,
5
+ "description": "OpenClaw VoicyClaw channel plugin",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Lyshen/VoicyClaw.git",
11
+ "directory": "extensions/voicyclaw"
12
+ },
13
+ "homepage": "https://github.com/Lyshen/VoicyClaw/tree/main/extensions/voicyclaw",
14
+ "bugs": {
15
+ "url": "https://github.com/Lyshen/VoicyClaw/issues"
16
+ },
17
+ "keywords": [
18
+ "openclaw",
19
+ "plugin",
20
+ "voicyclaw",
21
+ "voice"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "files": [
27
+ "README.md",
28
+ "index.ts",
29
+ "openclaw.plugin.json",
30
+ "src/channel.ts",
31
+ "src/config.ts",
32
+ "src/dispatch.ts",
33
+ "src/gateway.ts",
34
+ "src/protocol.ts",
35
+ "src/runtime.ts",
36
+ "src/socket-client.ts",
37
+ "tsconfig.json"
38
+ ],
39
+ "dependencies": {
40
+ "ws": "^8.19.0"
41
+ },
42
+ "devDependencies": {
43
+ "@biomejs/biome": "^2.4.8",
44
+ "@types/node": "^25.5.0",
45
+ "@types/ws": "^8.18.1",
46
+ "openclaw": "2026.3.2",
47
+ "typescript": "^5.9.3",
48
+ "vitest": "^3.2.4"
49
+ },
50
+ "peerDependencies": {
51
+ "openclaw": ">=2026.3.1"
52
+ },
53
+ "openclaw": {
54
+ "extensions": [
55
+ "./index.ts"
56
+ ],
57
+ "channel": {
58
+ "id": "voicyclaw",
59
+ "label": "VoicyClaw",
60
+ "selectionLabel": "VoicyClaw (Outbound Connector)",
61
+ "detailLabel": "VoicyClaw",
62
+ "docsPath": "/channels/voicyclaw",
63
+ "docsLabel": "voicyclaw",
64
+ "blurb": "Connect OpenClaw outward to a VoicyClaw service over WebSocket.",
65
+ "aliases": [
66
+ "voiceclaw"
67
+ ],
68
+ "order": 60
69
+ }
70
+ },
71
+ "scripts": {
72
+ "lint": "biome check .",
73
+ "test": "vitest run",
74
+ "typecheck": "tsc --noEmit -p tsconfig.json"
75
+ }
76
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,125 @@
1
+ import type { ChannelPlugin, PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ import {
4
+ DEFAULT_VOICYCLAW_ACCOUNT_ID,
5
+ listVoicyClawAccountIds,
6
+ type ResolvedVoicyClawAccount,
7
+ resolveVoicyClawAccount,
8
+ voicyClawChannelConfigSchema,
9
+ } from "./config.js";
10
+ import { createVoicyClawGatewayAdapter } from "./gateway.js";
11
+ import type { VoicyClawRuntime } from "./runtime.js";
12
+
13
+ export function createVoicyClawChannel(
14
+ runtimeState: VoicyClawRuntime,
15
+ channelRuntime: PluginRuntime["channel"],
16
+ ): ChannelPlugin<ResolvedVoicyClawAccount> {
17
+ return {
18
+ id: "voicyclaw",
19
+ meta: {
20
+ id: "voicyclaw",
21
+ label: "VoicyClaw",
22
+ selectionLabel: "VoicyClaw (Outbound Connector)",
23
+ docsPath: "/channels/voicyclaw",
24
+ blurb:
25
+ "Outbound VoicyClaw connector that lets OpenClaw attach to a hosted voice workspace.",
26
+ aliases: ["voiceclaw"],
27
+ },
28
+ capabilities: {
29
+ chatTypes: ["direct"],
30
+ },
31
+ reload: {
32
+ configPrefixes: ["channels.voicyclaw"],
33
+ },
34
+ configSchema: voicyClawChannelConfigSchema,
35
+ config: {
36
+ listAccountIds: (cfg) => listVoicyClawAccountIds(cfg),
37
+ resolveAccount: (cfg, accountId) =>
38
+ resolveVoicyClawAccount(cfg, accountId),
39
+ defaultAccountId: () => DEFAULT_VOICYCLAW_ACCOUNT_ID,
40
+ isEnabled: (account) => account.enabled,
41
+ isConfigured: (account) => account.configured,
42
+ describeAccount: (account) => ({
43
+ accountId: account.accountId,
44
+ name: account.displayName,
45
+ enabled: account.enabled,
46
+ configured: account.configured,
47
+ baseUrl: account.url,
48
+ audience: account.channelId,
49
+ tokenSource: account.token ? "config" : undefined,
50
+ }),
51
+ },
52
+ status: {
53
+ buildAccountSnapshot: ({ account, runtime }) => {
54
+ const tracked = runtimeState.getSnapshot(account.accountId);
55
+
56
+ return {
57
+ accountId: account.accountId,
58
+ name: account.displayName,
59
+ enabled: account.enabled,
60
+ configured: account.configured,
61
+ running: tracked?.running ?? runtime?.running ?? false,
62
+ connected: tracked?.connected ?? runtime?.connected ?? false,
63
+ reconnectAttempts:
64
+ tracked?.reconnectAttempts ?? runtime?.reconnectAttempts ?? 0,
65
+ lastConnectedAt:
66
+ tracked?.lastConnectedAt ?? runtime?.lastConnectedAt ?? null,
67
+ lastDisconnect:
68
+ tracked?.lastDisconnect ?? runtime?.lastDisconnect ?? null,
69
+ lastError: tracked?.lastError ?? runtime?.lastError ?? null,
70
+ lastStartAt: tracked?.lastStartAt ?? runtime?.lastStartAt ?? null,
71
+ lastStopAt: tracked?.lastStopAt ?? runtime?.lastStopAt ?? null,
72
+ lastMessageAt:
73
+ tracked?.lastMessageAt ?? runtime?.lastMessageAt ?? null,
74
+ lastInboundAt:
75
+ tracked?.lastInboundAt ?? runtime?.lastInboundAt ?? null,
76
+ lastOutboundAt:
77
+ tracked?.lastOutboundAt ?? runtime?.lastOutboundAt ?? null,
78
+ baseUrl: account.url,
79
+ audience: account.channelId,
80
+ tokenSource: account.token ? "config" : undefined,
81
+ };
82
+ },
83
+ resolveAccountState: ({ configured, enabled }) => {
84
+ if (!configured) {
85
+ return "not configured";
86
+ }
87
+
88
+ return enabled ? "enabled" : "disabled";
89
+ },
90
+ collectStatusIssues: (accounts) => {
91
+ return accounts.flatMap((account) => {
92
+ const issues = [];
93
+
94
+ if (!account.configured) {
95
+ issues.push({
96
+ channel: "voicyclaw",
97
+ accountId: account.accountId,
98
+ kind: "config" as const,
99
+ message: "VoicyClaw token is missing.",
100
+ fix: "Set channels.voicyclaw.token or channels.voicyclaw.accounts.<id>.token.",
101
+ });
102
+ }
103
+
104
+ if (
105
+ account.enabled &&
106
+ account.configured &&
107
+ !account.connected &&
108
+ account.lastError
109
+ ) {
110
+ issues.push({
111
+ channel: "voicyclaw",
112
+ accountId: account.accountId,
113
+ kind: "runtime" as const,
114
+ message: `VoicyClaw connector is disconnected: ${account.lastError}`,
115
+ fix: "Check the VoicyClaw base URL, token, and room id, then restart the gateway.",
116
+ });
117
+ }
118
+
119
+ return issues;
120
+ });
121
+ },
122
+ },
123
+ gateway: createVoicyClawGatewayAdapter(runtimeState, channelRuntime),
124
+ };
125
+ }
package/src/config.ts ADDED
@@ -0,0 +1,297 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+
3
+ export const DEFAULT_VOICYCLAW_ACCOUNT_ID = "default";
4
+ export const DEFAULT_VOICYCLAW_BASE_URL = "http://127.0.0.1:3001";
5
+ export const DEFAULT_VOICYCLAW_CHANNEL_ID = "default";
6
+ export const DEFAULT_VOICYCLAW_BOT_ID = "openclaw-voicyclaw";
7
+ export const DEFAULT_VOICYCLAW_DISPLAY_NAME = "VoicyClaw Connector";
8
+ export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
9
+ export const DEFAULT_RECONNECT_BACKOFF_MS = 5_000;
10
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000;
11
+
12
+ export type VoicyClawAccountConfig = {
13
+ enabled?: boolean;
14
+ url?: string;
15
+ token?: string;
16
+ workspaceId?: string;
17
+ channelId?: string;
18
+ botId?: string;
19
+ displayName?: string;
20
+ connectTimeoutMs?: number;
21
+ reconnectBackoffMs?: number;
22
+ heartbeatIntervalMs?: number;
23
+ devEchoReplies?: boolean;
24
+ };
25
+
26
+ export type ResolvedVoicyClawAccount = {
27
+ accountId: string;
28
+ enabled: boolean;
29
+ configured: boolean;
30
+ url: string;
31
+ token?: string;
32
+ workspaceId?: string;
33
+ channelId: string;
34
+ botId: string;
35
+ displayName: string;
36
+ connectTimeoutMs: number;
37
+ reconnectBackoffMs: number;
38
+ heartbeatIntervalMs: number;
39
+ devEchoReplies: boolean;
40
+ config: VoicyClawAccountConfig;
41
+ };
42
+
43
+ const voicyClawAccountProperties = {
44
+ enabled: { type: "boolean" },
45
+ url: { type: "string" },
46
+ token: { type: "string" },
47
+ workspaceId: { type: "string" },
48
+ channelId: { type: "string" },
49
+ botId: { type: "string" },
50
+ displayName: { type: "string" },
51
+ connectTimeoutMs: { type: "integer", minimum: 1000 },
52
+ reconnectBackoffMs: { type: "integer", minimum: 500 },
53
+ heartbeatIntervalMs: { type: "integer", minimum: 1000 },
54
+ devEchoReplies: { type: "boolean" },
55
+ } as const;
56
+
57
+ const voicyClawAccountSchema = {
58
+ type: "object",
59
+ additionalProperties: false,
60
+ properties: voicyClawAccountProperties,
61
+ } as const;
62
+
63
+ export const voicyClawChannelConfigSchema = {
64
+ schema: {
65
+ type: "object",
66
+ additionalProperties: false,
67
+ properties: {
68
+ ...voicyClawAccountProperties,
69
+ accounts: {
70
+ type: "object",
71
+ additionalProperties: voicyClawAccountSchema,
72
+ },
73
+ },
74
+ },
75
+ uiHints: {
76
+ enabled: {
77
+ label: "Enabled",
78
+ },
79
+ url: {
80
+ label: "VoicyClaw Base URL",
81
+ placeholder: "https://voice.example.com",
82
+ },
83
+ token: {
84
+ label: "VoicyClaw Token",
85
+ sensitive: true,
86
+ },
87
+ workspaceId: {
88
+ label: "Workspace ID",
89
+ advanced: true,
90
+ },
91
+ channelId: {
92
+ label: "Room / Channel ID",
93
+ },
94
+ botId: {
95
+ label: "Bot ID",
96
+ },
97
+ displayName: {
98
+ label: "Display Name",
99
+ },
100
+ connectTimeoutMs: {
101
+ label: "Connect Timeout (ms)",
102
+ advanced: true,
103
+ },
104
+ reconnectBackoffMs: {
105
+ label: "Reconnect Backoff (ms)",
106
+ advanced: true,
107
+ },
108
+ heartbeatIntervalMs: {
109
+ label: "Heartbeat Interval (ms)",
110
+ advanced: true,
111
+ },
112
+ devEchoReplies: {
113
+ label: "Dev Echo Replies",
114
+ help: "Only for transport smoke tests before real agent dispatch is wired.",
115
+ advanced: true,
116
+ },
117
+ accounts: {
118
+ label: "Accounts",
119
+ advanced: true,
120
+ },
121
+ },
122
+ } as const;
123
+
124
+ export function listVoicyClawAccountIds(cfg: OpenClawConfig) {
125
+ const section = getVoicyClawSection(cfg);
126
+ const accounts = asRecord(section.accounts);
127
+ const accountIds = Object.keys(accounts ?? {}).filter((entry) =>
128
+ entry.trim(),
129
+ );
130
+
131
+ if (accountIds.length > 0) {
132
+ return accountIds.sort();
133
+ }
134
+
135
+ return [DEFAULT_VOICYCLAW_ACCOUNT_ID];
136
+ }
137
+
138
+ export function resolveVoicyClawAccount(
139
+ cfg: OpenClawConfig,
140
+ accountId?: string | null,
141
+ ): ResolvedVoicyClawAccount {
142
+ const normalizedAccountId = normalizeAccountId(accountId);
143
+ const section = getVoicyClawSection(cfg);
144
+ const baseConfig = omitAccounts(section);
145
+ const accountConfig = getVoicyClawAccountOverrides(
146
+ section,
147
+ normalizedAccountId,
148
+ );
149
+ const mergedConfig = {
150
+ ...baseConfig,
151
+ ...accountConfig,
152
+ };
153
+
154
+ const enabled = readBoolean(mergedConfig.enabled, true);
155
+ const url = readString(mergedConfig.url) ?? DEFAULT_VOICYCLAW_BASE_URL;
156
+ const token = readString(mergedConfig.token);
157
+ const workspaceId = readString(mergedConfig.workspaceId);
158
+ const channelId =
159
+ readString(mergedConfig.channelId) ?? DEFAULT_VOICYCLAW_CHANNEL_ID;
160
+ const botId =
161
+ readString(mergedConfig.botId) ?? buildDefaultBotId(normalizedAccountId);
162
+ const displayName =
163
+ readString(mergedConfig.displayName) ??
164
+ buildDefaultDisplayName(normalizedAccountId);
165
+ const connectTimeoutMs = readPositiveInteger(
166
+ mergedConfig.connectTimeoutMs,
167
+ DEFAULT_CONNECT_TIMEOUT_MS,
168
+ );
169
+ const reconnectBackoffMs = readPositiveInteger(
170
+ mergedConfig.reconnectBackoffMs,
171
+ DEFAULT_RECONNECT_BACKOFF_MS,
172
+ );
173
+ const heartbeatIntervalMs = readPositiveInteger(
174
+ mergedConfig.heartbeatIntervalMs,
175
+ DEFAULT_HEARTBEAT_INTERVAL_MS,
176
+ );
177
+ const devEchoReplies = readBoolean(mergedConfig.devEchoReplies, false);
178
+
179
+ return {
180
+ accountId: normalizedAccountId,
181
+ enabled,
182
+ configured: Boolean(token),
183
+ url,
184
+ token,
185
+ workspaceId,
186
+ channelId,
187
+ botId,
188
+ displayName,
189
+ connectTimeoutMs,
190
+ reconnectBackoffMs,
191
+ heartbeatIntervalMs,
192
+ devEchoReplies,
193
+ config: {
194
+ enabled,
195
+ url,
196
+ token,
197
+ workspaceId,
198
+ channelId,
199
+ botId,
200
+ displayName,
201
+ connectTimeoutMs,
202
+ reconnectBackoffMs,
203
+ heartbeatIntervalMs,
204
+ devEchoReplies,
205
+ },
206
+ };
207
+ }
208
+
209
+ export function buildVoicyClawSocketUrl(input: string | undefined) {
210
+ const raw = (input?.trim() || DEFAULT_VOICYCLAW_BASE_URL).trim();
211
+ const withScheme = /^(wss?|https?):\/\//i.test(raw) ? raw : `http://${raw}`;
212
+ const url = new URL(withScheme);
213
+
214
+ if (url.protocol === "http:") {
215
+ url.protocol = "ws:";
216
+ } else if (url.protocol === "https:") {
217
+ url.protocol = "wss:";
218
+ }
219
+
220
+ if (!url.pathname || url.pathname === "/") {
221
+ url.pathname = "/bot/connect";
222
+ } else if (!url.pathname.endsWith("/bot/connect")) {
223
+ url.pathname = `${url.pathname.replace(/\/$/, "")}/bot/connect`;
224
+ }
225
+
226
+ url.hash = "";
227
+ return url.toString();
228
+ }
229
+
230
+ function getVoicyClawSection(cfg: OpenClawConfig) {
231
+ return asRecord(cfg.channels?.voicyclaw);
232
+ }
233
+
234
+ function getVoicyClawAccountOverrides(
235
+ section: Record<string, unknown>,
236
+ accountId: string,
237
+ ) {
238
+ const accounts = asRecord(section.accounts);
239
+ if (!accounts) {
240
+ return {};
241
+ }
242
+
243
+ return asRecord(accounts[accountId]);
244
+ }
245
+
246
+ function omitAccounts(section: Record<string, unknown>) {
247
+ const { accounts: _accounts, ...rest } = section;
248
+ return rest;
249
+ }
250
+
251
+ function buildDefaultBotId(accountId: string) {
252
+ if (accountId === DEFAULT_VOICYCLAW_ACCOUNT_ID) {
253
+ return DEFAULT_VOICYCLAW_BOT_ID;
254
+ }
255
+
256
+ return `${DEFAULT_VOICYCLAW_BOT_ID}-${accountId}`;
257
+ }
258
+
259
+ function buildDefaultDisplayName(accountId: string) {
260
+ if (accountId === DEFAULT_VOICYCLAW_ACCOUNT_ID) {
261
+ return DEFAULT_VOICYCLAW_DISPLAY_NAME;
262
+ }
263
+
264
+ return `${DEFAULT_VOICYCLAW_DISPLAY_NAME} ${accountId}`;
265
+ }
266
+
267
+ function normalizeAccountId(accountId?: string | null) {
268
+ const normalized = accountId?.trim();
269
+ return normalized || DEFAULT_VOICYCLAW_ACCOUNT_ID;
270
+ }
271
+
272
+ function asRecord(value: unknown): Record<string, unknown> {
273
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
274
+ return {};
275
+ }
276
+
277
+ return value as Record<string, unknown>;
278
+ }
279
+
280
+ function readString(value: unknown) {
281
+ if (typeof value !== "string") {
282
+ return undefined;
283
+ }
284
+
285
+ const normalized = value.trim();
286
+ return normalized || undefined;
287
+ }
288
+
289
+ function readBoolean(value: unknown, fallback: boolean) {
290
+ return typeof value === "boolean" ? value : fallback;
291
+ }
292
+
293
+ function readPositiveInteger(value: unknown, fallback: number) {
294
+ return typeof value === "number" && Number.isInteger(value) && value > 0
295
+ ? value
296
+ : fallback;
297
+ }