@alfe.ai/openclaw-voice 0.1.15 → 0.2.0

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/README.md CHANGED
@@ -1,54 +1,37 @@
1
- # packages/openclaw-voice (`@alfe.ai/openclaw-voice`)
1
+ # `@alfe.ai/openclaw-voice`
2
2
 
3
- OpenClaw voice plugin for Alfe Discord audio, Twilio phone calls, and meeting bots.
3
+ OpenClaw tools for Alfe's agent-authenticated one-shot voice endpoints.
4
4
 
5
- ## What It Does
5
+ The plugin registers two tools:
6
6
 
7
- OpenClaw plugin that integrates voice capabilities into the agent runtime. All heavy voice logic lives in `voice-service` this package is the OpenClaw integration layer.
7
+ - `voice_tts` converts 1–5000 characters to a new workspace-relative WAV or
8
+ raw PCM file.
9
+ - `voice_stt` transcribes a workspace-relative WAV or raw mono 16-bit PCM file
10
+ of at most 10 MiB.
8
11
 
9
- On activation, the plugin:
12
+ Both tools resolve the agent API key and voice-service URL from
13
+ `@alfe.ai/config`, call through `AgentApiClient`, and leave billing to the
14
+ server. The plugin has no provider credential fields, gateway RPC, daemon
15
+ connection, or channel-specific call controls.
10
16
 
11
- - **Registers voice tools** `voice_hangup`, `voice_transfer` (Twilio), `voice_dtmf` (Twilio)
12
- - **Registers HTTP routes** `/twilio/inbound` and `/twilio/status` webhook handlers
13
- - **Registers gateway RPC** `voice.speak` for TTS and direct audio output
14
- - **Hooks into OpenClaw events** — auto-joins/leaves Discord voice on session start/end, auto-injects session IDs into tool calls
15
- - **Connects to Alfe daemon IPC** — registers voice capabilities; gracefully degrades if unavailable
17
+ File arguments are treated as model-controlled input: real paths must stay
18
+ inside the current workspace, input files must be regular files within the
19
+ size cap, and TTS creates output exclusively without replacing existing files.
16
20
 
17
- Follows the standard OpenClaw plugin pattern: exports `id`, `name`, `activate`, `deactivate`.
18
-
19
- ## Key Files
20
-
21
- ```
22
- src/
23
- ├── plugin.ts # Plugin entry point (activate/deactivate lifecycle, tools, routes, hooks)
24
- └── index.ts # Public re-exports
25
- ```
26
-
27
- ## Session Management
28
-
29
- The plugin maintains bidirectional maps between OpenClaw session keys and voice session IDs. On `before_tool_call`, it auto-injects `sessionId` into voice tool parameters so the agent doesn't need to track sessions explicitly.
30
-
31
- Discord sessions are detected via the pattern `voice-discord:{guildId}:{channelId}`.
21
+ Channel voice sessions, calls, barge-in, and streaming audio live in the voice
22
+ service and channel adapters; they are not part of this tool-only package.
32
23
 
33
24
  ## Development
34
25
 
35
- ```bash
36
- pnpm install
37
- pnpm --filter @alfe.ai/openclaw-voice build
38
- ```
39
-
40
- ## Testing
41
-
42
26
  ```bash
43
27
  pnpm --filter @alfe.ai/openclaw-voice test
28
+ pnpm --filter @alfe.ai/openclaw-voice lint
29
+ pnpm --filter @alfe.ai/openclaw-voice build
44
30
  ```
45
31
 
46
- ## Dependencies
47
-
48
- - **voice-service** — core voice logic (TTS, audio pipeline, Discord/Twilio adapters)
49
- - **@alfe.ai/openclaw** — OpenClaw runtime plugin API
32
+ See [`DEVELOPING.md`](DEVELOPING.md) for the file-I/O and runtime contracts.
50
33
 
51
34
  ## Links
52
35
 
53
- - 🌐 Website: <https://alfe.ai>
54
- - 📚 Docs: <https://docs.alfe.ai>
36
+ - [Alfe](https://alfe.ai)
37
+ - [Documentation](https://docs.alfe.ai)
package/dist/plugin.d.cts CHANGED
@@ -3,16 +3,6 @@
3
3
  //# sourceMappingURL=types.d.ts.map
4
4
  //#endregion
5
5
  //#region src/tools.d.ts
6
- /**
7
- * Tool-definition helpers — the `defineTool` / `ok` / `errResult` trio that
8
- * was copy-pasted across 8 plugins (identity, google, teams, mobile,
9
- * whatsapp, voice, chat a2a-tools, base openclaw).
10
- *
11
- * Error handling is standardized on the openclaw-google variant — the only
12
- * copy that survived non-`Error` throws (`e instanceof Error ? e.message :
13
- * "Unknown error"`). The other copies did `(e as Error).message`, which
14
- * crashes the tool executor when a handler throws a string/object.
15
- */
16
6
  /** Shape returned to OpenClaw from a tool `execute`. */
17
7
  interface ToolResult {
18
8
  content: {
@@ -20,15 +10,8 @@ interface ToolResult {
20
10
  text: string;
21
11
  }[];
22
12
  details: unknown;
13
+ isError?: boolean;
23
14
  }
24
- /**
25
- * An OpenClaw tool definition.
26
- *
27
- * `parameters` is generic because the fleet is split between TypeBox
28
- * `TSchema` schemas (identity/google/teams/mobile/whatsapp) and plain
29
- * JSON-Schema objects (chat a2a-tools). Instantiate with whichever schema
30
- * type the plugin uses — the kit itself has no schema dependency.
31
- */
32
15
  interface ToolDef<TParameters = unknown> {
33
16
  name: string;
34
17
  description: string;
@@ -36,7 +19,7 @@ interface ToolDef<TParameters = unknown> {
36
19
  parameters: TParameters;
37
20
  execute: (toolCallId: string, params: Record<string, unknown>) => Promise<ToolResult>;
38
21
  }
39
- /** Wrap a successful handler result in the OpenClaw tool-result envelope. */
22
+ /** Deliberately model-safe validation/usage failure. Other exceptions stay private. */
40
23
  //#endregion
41
24
  //#region src/plugin.d.ts
42
25
 
@@ -46,43 +29,9 @@ interface Logger {
46
29
  error(msg: string, ...args: unknown[]): void;
47
30
  debug(msg: string, ...args: unknown[]): void;
48
31
  }
49
- interface VoicePluginConfig {
50
- voiceServiceUrl?: string;
51
- voiceServicePort?: string | number;
52
- voiceServiceApiKey?: string;
53
- daemonSocket?: string;
54
- [key: string]: unknown;
55
- }
56
- interface OpenClawConfig {
57
- plugins?: {
58
- entries?: Record<string, {
59
- config?: VoicePluginConfig;
60
- [key: string]: unknown;
61
- }>;
62
- [key: string]: unknown;
63
- };
64
- [key: string]: unknown;
65
- }
66
- interface PluginServiceContext {
67
- config: Record<string, unknown>;
68
- workspaceDir?: string;
69
- stateDir: string;
70
- logger: Logger;
71
- }
72
32
  interface OpenClawPluginApi {
73
33
  logger: Logger;
74
- registrationMode?: 'full' | 'setup-only' | 'setup-runtime' | 'cli-metadata';
75
- config?: OpenClawConfig;
76
34
  registerTool(tool: ToolDef): void;
77
- registerGatewayMethod(name: string, handler: (...args: unknown[]) => Promise<unknown>): void;
78
- registerService(service: {
79
- id: string;
80
- start: (ctx: PluginServiceContext) => void | Promise<void>;
81
- stop?: (ctx: PluginServiceContext) => void | Promise<void>;
82
- }): void;
83
- on(event: string, handler: (...args: unknown[]) => void | Promise<void>, options?: {
84
- priority?: number;
85
- }): void;
86
35
  }
87
36
  declare const plugin: {
88
37
  id: string;
package/dist/plugin.d.ts CHANGED
@@ -3,16 +3,6 @@
3
3
  //# sourceMappingURL=types.d.ts.map
4
4
  //#endregion
5
5
  //#region src/tools.d.ts
6
- /**
7
- * Tool-definition helpers — the `defineTool` / `ok` / `errResult` trio that
8
- * was copy-pasted across 8 plugins (identity, google, teams, mobile,
9
- * whatsapp, voice, chat a2a-tools, base openclaw).
10
- *
11
- * Error handling is standardized on the openclaw-google variant — the only
12
- * copy that survived non-`Error` throws (`e instanceof Error ? e.message :
13
- * "Unknown error"`). The other copies did `(e as Error).message`, which
14
- * crashes the tool executor when a handler throws a string/object.
15
- */
16
6
  /** Shape returned to OpenClaw from a tool `execute`. */
17
7
  interface ToolResult {
18
8
  content: {
@@ -20,15 +10,8 @@ interface ToolResult {
20
10
  text: string;
21
11
  }[];
22
12
  details: unknown;
13
+ isError?: boolean;
23
14
  }
24
- /**
25
- * An OpenClaw tool definition.
26
- *
27
- * `parameters` is generic because the fleet is split between TypeBox
28
- * `TSchema` schemas (identity/google/teams/mobile/whatsapp) and plain
29
- * JSON-Schema objects (chat a2a-tools). Instantiate with whichever schema
30
- * type the plugin uses — the kit itself has no schema dependency.
31
- */
32
15
  interface ToolDef<TParameters = unknown> {
33
16
  name: string;
34
17
  description: string;
@@ -36,7 +19,7 @@ interface ToolDef<TParameters = unknown> {
36
19
  parameters: TParameters;
37
20
  execute: (toolCallId: string, params: Record<string, unknown>) => Promise<ToolResult>;
38
21
  }
39
- /** Wrap a successful handler result in the OpenClaw tool-result envelope. */
22
+ /** Deliberately model-safe validation/usage failure. Other exceptions stay private. */
40
23
  //#endregion
41
24
  //#region src/plugin.d.ts
42
25
 
@@ -46,43 +29,9 @@ interface Logger {
46
29
  error(msg: string, ...args: unknown[]): void;
47
30
  debug(msg: string, ...args: unknown[]): void;
48
31
  }
49
- interface VoicePluginConfig {
50
- voiceServiceUrl?: string;
51
- voiceServicePort?: string | number;
52
- voiceServiceApiKey?: string;
53
- daemonSocket?: string;
54
- [key: string]: unknown;
55
- }
56
- interface OpenClawConfig {
57
- plugins?: {
58
- entries?: Record<string, {
59
- config?: VoicePluginConfig;
60
- [key: string]: unknown;
61
- }>;
62
- [key: string]: unknown;
63
- };
64
- [key: string]: unknown;
65
- }
66
- interface PluginServiceContext {
67
- config: Record<string, unknown>;
68
- workspaceDir?: string;
69
- stateDir: string;
70
- logger: Logger;
71
- }
72
32
  interface OpenClawPluginApi {
73
33
  logger: Logger;
74
- registrationMode?: 'full' | 'setup-only' | 'setup-runtime' | 'cli-metadata';
75
- config?: OpenClawConfig;
76
34
  registerTool(tool: ToolDef): void;
77
- registerGatewayMethod(name: string, handler: (...args: unknown[]) => Promise<unknown>): void;
78
- registerService(service: {
79
- id: string;
80
- start: (ctx: PluginServiceContext) => void | Promise<void>;
81
- stop?: (ctx: PluginServiceContext) => void | Promise<void>;
82
- }): void;
83
- on(event: string, handler: (...args: unknown[]) => void | Promise<void>, options?: {
84
- priority?: number;
85
- }): void;
86
35
  }
87
36
  declare const plugin: {
88
37
  id: string;
package/dist/plugin2.cjs CHANGED
@@ -2,12 +2,31 @@ let _sinclair_typebox = require("@sinclair/typebox");
2
2
  let _alfe_ai_config = require("@alfe.ai/config");
3
3
  let _alfe_ai_agent_api_client = require("@alfe.ai/agent-api-client");
4
4
  let _alfe_ai_openclaw_plugin_kit = require("@alfe.ai/openclaw-plugin-kit");
5
+ let node_module = require("node:module");
6
+ let node_fs = require("node:fs");
5
7
  let node_fs_promises = require("node:fs/promises");
6
8
  let node_path = require("node:path");
7
- let node_module = require("node:module");
8
9
  //#region src/audio.ts
10
+ const MAX_WAV_PCM_BYTES = 4294967259;
11
+ const SUPPORTED_BIT_DEPTHS = new Set([
12
+ 8,
13
+ 16,
14
+ 24,
15
+ 32
16
+ ]);
17
+ function validatePcmFraming(framing, pcmLength) {
18
+ const { sampleRate, channels, bitDepth } = framing;
19
+ if (!Number.isInteger(sampleRate) || sampleRate < 8e3 || sampleRate > 384e3) throw new Error("WAV sample rate must be an integer between 8000 and 384000 Hz.");
20
+ if (!Number.isInteger(channels) || channels < 1 || channels > 32) throw new Error("WAV channel count must be an integer between 1 and 32.");
21
+ if (!SUPPORTED_BIT_DEPTHS.has(bitDepth)) throw new Error("WAV bit depth must be one of 8, 16, 24, or 32.");
22
+ const blockAlign = channels * (bitDepth / 8);
23
+ if (pcmLength % blockAlign !== 0) throw new Error("PCM byte length must contain a whole number of sample frames.");
24
+ if (pcmLength > MAX_WAV_PCM_BYTES) throw new Error("PCM payload is too large for a RIFF/WAV container.");
25
+ if (sampleRate * blockAlign > 4294967295) throw new Error("WAV byte rate exceeds the RIFF field limit.");
26
+ }
9
27
  /** Prepend a canonical 44-byte PCM WAV header to raw PCM samples. */
10
28
  function pcmToWav(pcm, framing) {
29
+ validatePcmFraming(framing, pcm.length);
11
30
  const { sampleRate, channels, bitDepth } = framing;
12
31
  const blockAlign = channels * (bitDepth / 8);
13
32
  const byteRate = sampleRate * blockAlign;
@@ -28,35 +47,59 @@ function pcmToWav(pcm, framing) {
28
47
  return Buffer.concat([header, pcm]);
29
48
  }
30
49
  /**
31
- * Parse a WAV buffer into its PCM payload + framing. Returns `null` when the
32
- * buffer is not a RIFF/WAVE file (caller should then treat the bytes as raw
33
- * PCM). Walks the chunk list so it tolerates `fmt `/`data` ordering, extra
34
- * chunks (e.g. `LIST`/`fact`), and word-alignment padding.
50
+ * Parse a WAV buffer into its PCM payload + framing. Returns `null` only when
51
+ * the buffer is not RIFF data (caller may then treat it as raw PCM). A RIFF
52
+ * buffer that claims to be WAV but is truncated, unsupported, or internally
53
+ * inconsistent throws instead of silently uploading container bytes as PCM.
35
54
  */
36
55
  function parseWav(buf) {
37
- if (buf.length < 44) return null;
38
- if (buf.toString("ascii", 0, 4) !== "RIFF") return null;
39
- if (buf.toString("ascii", 8, 12) !== "WAVE") return null;
56
+ if (buf.length < 4 || buf.toString("ascii", 0, 4) !== "RIFF") return null;
57
+ if (buf.length < 12) throw new Error("Malformed WAV: truncated RIFF header.");
58
+ if (buf.toString("ascii", 8, 12) !== "WAVE") throw new Error("Unsupported RIFF container: expected WAVE.");
59
+ const declaredEnd = buf.readUInt32LE(4) + 8;
60
+ if (declaredEnd !== buf.length) throw new Error("Malformed WAV: RIFF size does not match the file length.");
40
61
  let sampleRate = 0;
41
62
  let channels = 0;
42
63
  let bitDepth = 0;
43
64
  let pcm = null;
65
+ let blockAlign = 0;
66
+ let byteRate = 0;
67
+ let sawFmt = false;
68
+ let sawData = false;
44
69
  let offset = 12;
45
- while (offset + 8 <= buf.length) {
70
+ while (offset < declaredEnd) {
71
+ if (offset + 8 > declaredEnd) throw new Error("Malformed WAV: truncated chunk header.");
46
72
  const chunkId = buf.toString("ascii", offset, offset + 4);
47
73
  const chunkSize = buf.readUInt32LE(offset + 4);
48
74
  const bodyStart = offset + 8;
49
- if (chunkId === "fmt " && bodyStart + 16 <= buf.length) {
75
+ const bodyEnd = bodyStart + chunkSize;
76
+ const nextOffset = bodyEnd + chunkSize % 2;
77
+ if (bodyEnd > declaredEnd || nextOffset > declaredEnd) throw new Error(`Malformed WAV: truncated ${chunkId} chunk.`);
78
+ if (chunkId === "fmt ") {
79
+ if (sawFmt) throw new Error("Malformed WAV: duplicate fmt chunk.");
80
+ if (chunkSize < 16) throw new Error("Malformed WAV: fmt chunk is too short.");
81
+ if (buf.readUInt16LE(bodyStart) !== 1) throw new Error("Unsupported WAV encoding: only integer PCM is accepted.");
50
82
  channels = buf.readUInt16LE(bodyStart + 2);
51
83
  sampleRate = buf.readUInt32LE(bodyStart + 4);
84
+ byteRate = buf.readUInt32LE(bodyStart + 8);
85
+ blockAlign = buf.readUInt16LE(bodyStart + 12);
52
86
  bitDepth = buf.readUInt16LE(bodyStart + 14);
87
+ sawFmt = true;
53
88
  } else if (chunkId === "data") {
54
- const end = Math.min(bodyStart + chunkSize, buf.length);
55
- pcm = buf.subarray(bodyStart, end);
89
+ if (sawData) throw new Error("Malformed WAV: duplicate data chunk.");
90
+ pcm = buf.subarray(bodyStart, bodyEnd);
91
+ sawData = true;
56
92
  }
57
- offset = bodyStart + chunkSize + chunkSize % 2;
93
+ offset = nextOffset;
58
94
  }
59
- if (!pcm || sampleRate === 0 || channels === 0 || bitDepth === 0) return null;
95
+ if (!sawFmt || !sawData || pcm === null) throw new Error("Malformed WAV: both fmt and data chunks are required.");
96
+ validatePcmFraming({
97
+ sampleRate,
98
+ channels,
99
+ bitDepth
100
+ }, pcm.length);
101
+ const expectedBlockAlign = channels * (bitDepth / 8);
102
+ if (blockAlign !== expectedBlockAlign || byteRate !== sampleRate * expectedBlockAlign) throw new Error("Malformed WAV: byte rate or block alignment is inconsistent with its framing.");
60
103
  return {
61
104
  pcm,
62
105
  sampleRate,
@@ -65,6 +108,83 @@ function parseWav(buf) {
65
108
  };
66
109
  }
67
110
  //#endregion
111
+ //#region src/workspace-files.ts
112
+ const MAX_TOOL_PATH_LENGTH = 1024;
113
+ function isWithin(root, candidate) {
114
+ const rel = (0, node_path.relative)(root, candidate);
115
+ return rel === "" || !(0, node_path.isAbsolute)(rel) && rel !== ".." && !rel.startsWith(`..${node_path.sep}`);
116
+ }
117
+ function assertRelativeToolPath(input, label) {
118
+ if (input.length === 0) throw new Error(`${label} must not be empty.`);
119
+ if (input.length > MAX_TOOL_PATH_LENGTH) throw new Error(`${label} is too long (maximum ${String(MAX_TOOL_PATH_LENGTH)} characters).`);
120
+ if (input.includes("\0")) throw new Error(`${label} contains a null byte.`);
121
+ if ((0, node_path.isAbsolute)(input) || /^[A-Za-z]:[\\/]/u.test(input) || input.startsWith("\\\\")) throw new Error(`${label} must be relative to the workspace.`);
122
+ if (input.split(/[\\/]/u).includes("..")) throw new Error(`${label} must not contain parent-directory traversal.`);
123
+ }
124
+ async function workspaceRoot() {
125
+ return (0, node_fs_promises.realpath)(process.cwd());
126
+ }
127
+ async function readWorkspaceFile(input, maxBytes) {
128
+ assertRelativeToolPath(input, "Path");
129
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error("Maximum file size must be a positive safe integer.");
130
+ const root = await workspaceRoot();
131
+ const lexicalPath = (0, node_path.resolve)(root, input);
132
+ if (!isWithin(root, lexicalPath)) throw new Error("Path escapes the workspace.");
133
+ const absolutePath = await (0, node_fs_promises.realpath)(lexicalPath);
134
+ if (!isWithin(root, absolutePath)) throw new Error("Path resolves outside the workspace.");
135
+ const handle = await (0, node_fs_promises.open)(absolutePath, node_fs.constants.O_RDONLY | node_fs.constants.O_NOFOLLOW);
136
+ try {
137
+ const before = await handle.stat();
138
+ if (!before.isFile()) throw new Error("Path must refer to a regular file.");
139
+ if (before.size > maxBytes) throw new Error(`File is too large (maximum ${String(maxBytes)} bytes).`);
140
+ const data = await handle.readFile();
141
+ const after = await handle.stat();
142
+ if (data.length > maxBytes || after.size > maxBytes) throw new Error(`File is too large (maximum ${String(maxBytes)} bytes).`);
143
+ return {
144
+ absolutePath,
145
+ data
146
+ };
147
+ } finally {
148
+ await handle.close();
149
+ }
150
+ }
151
+ async function resolveWorkspaceOutputPath(input) {
152
+ assertRelativeToolPath(input, "Output path");
153
+ const root = await workspaceRoot();
154
+ const lexicalPath = (0, node_path.resolve)(root, input);
155
+ if (!isWithin(root, lexicalPath)) throw new Error("Output path escapes the workspace.");
156
+ const parent = await (0, node_fs_promises.realpath)((0, node_path.dirname)(lexicalPath));
157
+ if (!isWithin(root, parent)) throw new Error("Output path resolves outside the workspace.");
158
+ const absolutePath = (0, node_path.join)(parent, (0, node_path.basename)(lexicalPath));
159
+ try {
160
+ await (0, node_fs_promises.lstat)(absolutePath);
161
+ throw new Error(`Refusing to overwrite existing workspace file: ${input}`);
162
+ } catch (error) {
163
+ if (error.code !== "ENOENT") throw error;
164
+ }
165
+ return absolutePath;
166
+ }
167
+ async function writeWorkspaceFileExclusive(input, data) {
168
+ const absolutePath = await resolveWorkspaceOutputPath(input);
169
+ let handle;
170
+ try {
171
+ handle = await (0, node_fs_promises.open)(absolutePath, "wx", 384);
172
+ } catch (error) {
173
+ if (error.code === "EEXIST") throw new Error(`Refusing to overwrite existing workspace file: ${input}`);
174
+ throw error;
175
+ }
176
+ let completed = false;
177
+ try {
178
+ await handle.writeFile(data);
179
+ await handle.sync();
180
+ completed = true;
181
+ return absolutePath;
182
+ } finally {
183
+ await handle.close();
184
+ if (!completed) await (0, node_fs_promises.unlink)(absolutePath).catch(() => void 0);
185
+ }
186
+ }
187
+ //#endregion
68
188
  //#region src/plugin.ts
69
189
  /**
70
190
  * @alfe/voice-plugin — OpenClaw native plugin
@@ -79,7 +199,9 @@ function parseWav(buf) {
79
199
  * This plugin provides:
80
200
  * - voice_tts tool — text → audio file via POST /voice/tts (agent-authed)
81
201
  * - voice_stt tool — audio file → transcript via POST /voice/stt (agent-authed)
82
- * - voice.speak RPC — legacy gateway RPC (reads plugin config; vestigial)
202
+ *
203
+ * The plugin is intentionally tool-only. It has no daemon connection,
204
+ * gateway RPC, message hook, or plugin-config credential path.
83
205
  *
84
206
  * voice_hangup / voice_transfer / voice_dtmf were removed deliberately — they
85
207
  * threw unconditionally ("requires a channel service"), which poisons the
@@ -92,32 +214,10 @@ function parseWav(buf) {
92
214
  * or hit localhost.
93
215
  */
94
216
  const pkg = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("../package.json");
95
- const VOICE_CAPABILITIES = [
96
- "voice.call",
97
- "voice.answer",
98
- "voice.dtmf",
99
- "voice.hangup"
100
- ];
101
- const VOICE_ACTIVATION_KEY = (0, _alfe_ai_openclaw_plugin_kit.getActivationKey)("voice");
102
- let voiceServiceUrl = "";
103
- let voiceServiceApiKey = "";
104
- async function voiceApi(method, path, body) {
105
- const url = `${voiceServiceUrl}${path}`;
106
- const headers = { "Content-Type": "application/json" };
107
- if (voiceServiceApiKey) headers["x-api-key"] = voiceServiceApiKey;
108
- const res = await fetch(url, {
109
- method,
110
- headers,
111
- body: body ? JSON.stringify(body) : void 0
112
- });
113
- const json = await res.json();
114
- if (!res.ok) {
115
- const errorMsg = typeof json.error === "string" ? json.error : `Voice service returned ${String(res.status)}`;
116
- throw new Error(errorMsg);
117
- }
118
- return json;
119
- }
120
- let daemonIpcClient = null;
217
+ const MAX_AUDIO_FILE_BYTES = 10 * 1024 * 1024;
218
+ const MAX_TTS_RESPONSE_BYTES = 32 * 1024 * 1024;
219
+ const VOICE_ID_PATTERN = "^[A-Za-z0-9_-]{1,200}$";
220
+ const VOICE_ID_REGEX = /^[A-Za-z0-9_-]{1,200}$/u;
121
221
  let voiceClient = null;
122
222
  function getVoiceClient() {
123
223
  if (voiceClient) return voiceClient;
@@ -128,39 +228,54 @@ function getVoiceClient() {
128
228
  });
129
229
  return voiceClient;
130
230
  }
131
- /** Resolve a tool-supplied path against the agent's working directory. */
132
- function resolveWorkspacePath(p) {
133
- return (0, node_path.isAbsolute)(p) ? p : (0, node_path.resolve)(process.cwd(), p);
134
- }
135
231
  const voiceTools = [(0, _alfe_ai_openclaw_plugin_kit.defineTool)({
136
232
  name: "voice_tts",
137
233
  description: "Convert text to speech using the Alfe voice service (ElevenLabs). Writes a playable audio file to the workspace and returns its path. Billed per character to your tenant credit pool.",
138
234
  parameters: _sinclair_typebox.Type.Object({
139
- text: _sinclair_typebox.Type.String({ description: "Text to synthesize (1–5000 characters)." }),
140
- outputPath: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Where to write the audio file (absolute, or relative to the working directory). Defaults to alfe-tts-<timestamp>.wav in the working directory." })),
235
+ text: _sinclair_typebox.Type.String({
236
+ minLength: 1,
237
+ maxLength: 5e3,
238
+ description: "Text to synthesize (1–5000 characters)."
239
+ }),
240
+ outputPath: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({
241
+ minLength: 1,
242
+ maxLength: 1024,
243
+ description: "New workspace-relative audio file to create. Existing files are never overwritten. Defaults to alfe-tts-<timestamp>.wav."
244
+ })),
141
245
  format: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.Union([_sinclair_typebox.Type.Literal("wav"), _sinclair_typebox.Type.Literal("pcm")], { description: "Output container. 'wav' (default) is a playable file; 'pcm' is headerless 24kHz/mono/16-bit raw PCM." })),
142
- voiceId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "ElevenLabs voice ID. Platform default when omitted." })),
246
+ voiceId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({
247
+ pattern: VOICE_ID_PATTERN,
248
+ description: "ElevenLabs voice ID. Platform default when omitted."
249
+ })),
143
250
  model: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.Union([_sinclair_typebox.Type.Literal("eleven_turbo_v2_5"), _sinclair_typebox.Type.Literal("eleven_multilingual_v2")], { description: "TTS model. eleven_turbo_v2_5 (lower latency) when omitted." }))
144
251
  }),
145
252
  handler: async (params) => {
146
- const text = params.text;
253
+ const text = params.text.trim();
254
+ if (text.length < 1 || text.length > 5e3) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("voice_tts text must contain 1–5000 characters.");
255
+ const voiceId = params.voiceId;
256
+ if (voiceId !== void 0 && !VOICE_ID_REGEX.test(voiceId)) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("voice_tts voiceId contains unsupported characters.");
147
257
  const format = params.format ?? "wav";
258
+ const outputPath = params.outputPath ?? `alfe-tts-${String(Date.now())}.${format}`;
259
+ await resolveWorkspaceOutputPath(outputPath);
148
260
  const result = await getVoiceClient().tts({
149
261
  text,
150
- voiceId: params.voiceId,
262
+ voiceId,
151
263
  model: params.model
152
264
  });
265
+ if (result.audio.length === 0 || result.audio.length > MAX_TTS_RESPONSE_BYTES) throw new Error(`voice_tts returned an invalid audio payload size (${String(result.audio.length)} bytes).`);
266
+ validatePcmFraming({
267
+ sampleRate: result.sampleRate,
268
+ channels: result.channels,
269
+ bitDepth: result.bitDepth
270
+ }, result.audio.length);
153
271
  const bytes = format === "wav" ? pcmToWav(result.audio, {
154
272
  sampleRate: result.sampleRate,
155
273
  channels: result.channels,
156
274
  bitDepth: result.bitDepth
157
275
  }) : result.audio;
158
- const outputPath = params.outputPath ?? `alfe-tts-${String(Date.now())}.${format}`;
159
- const absolutePath = resolveWorkspacePath(outputPath);
160
- await (0, node_fs_promises.writeFile)(absolutePath, bytes);
161
276
  return {
162
277
  path: outputPath,
163
- absolutePath,
278
+ absolutePath: await writeWorkspaceFileExclusive(outputPath, bytes),
164
279
  format,
165
280
  sampleRate: result.sampleRate,
166
281
  channels: result.channels,
@@ -173,22 +288,33 @@ const voiceTools = [(0, _alfe_ai_openclaw_plugin_kit.defineTool)({
173
288
  name: "voice_stt",
174
289
  description: "Transcribe an audio file to text using the Alfe voice service (Deepgram). Accepts a WAV file or headerless mono 16-bit PCM. Billed per audio-second to your tenant credit pool.",
175
290
  parameters: _sinclair_typebox.Type.Object({
176
- path: _sinclair_typebox.Type.String({ description: "Path to the audio file (absolute, or relative to the working directory). WAV or raw linear16 mono PCM." }),
177
- sampleRate: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.Number({ description: "Sample rate in Hz (8000–48000). Only used for headerless PCM input; ignored for WAV (its header wins). Defaults to 24000." }))
291
+ path: _sinclair_typebox.Type.String({
292
+ minLength: 1,
293
+ maxLength: 1024,
294
+ description: "Workspace-relative path to a WAV or raw linear16 mono PCM file (maximum 10 MiB)."
295
+ }),
296
+ sampleRate: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.Number({
297
+ minimum: 8e3,
298
+ maximum: 48e3,
299
+ multipleOf: 1,
300
+ description: "Sample rate in Hz (8000–48000). Only used for headerless PCM input; ignored for WAV (its header wins). Defaults to 24000."
301
+ }))
178
302
  }),
179
303
  handler: async (params) => {
180
- const raw = await (0, node_fs_promises.readFile)(resolveWorkspacePath(params.path));
304
+ const { data: raw } = await readWorkspaceFile(params.path, MAX_AUDIO_FILE_BYTES);
181
305
  const wav = parseWav(raw);
182
306
  let audio;
183
307
  let sampleRate;
184
308
  if (wav) {
185
- if (wav.bitDepth !== 16 || wav.channels !== 1) throw new Error(`voice_stt needs 16-bit mono audio; got ${String(wav.bitDepth)}-bit / ${String(wav.channels)}-channel WAV. Re-export as 16-bit mono.`);
309
+ if (wav.bitDepth !== 16 || wav.channels !== 1) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)(`voice_stt needs 16-bit mono audio; got ${String(wav.bitDepth)}-bit / ${String(wav.channels)}-channel WAV. Re-export as 16-bit mono.`);
186
310
  audio = wav.pcm;
187
311
  sampleRate = wav.sampleRate;
188
312
  } else {
189
313
  audio = raw;
190
314
  sampleRate = params.sampleRate ?? 24e3;
191
315
  }
316
+ if (!Number.isInteger(sampleRate) || sampleRate < 8e3 || sampleRate > 48e3) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("voice_stt sampleRate must be an integer between 8000 and 48000 Hz.");
317
+ if (audio.length === 0 || audio.length % 2 !== 0) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("voice_stt needs non-empty 16-bit PCM with an even byte length.");
192
318
  return getVoiceClient().stt({
193
319
  audio,
194
320
  sampleRate
@@ -205,77 +331,12 @@ const plugin = {
205
331
  const log = api.logger;
206
332
  for (const tool of voiceTools) api.registerTool(tool);
207
333
  log.info(`Registered ${String(voiceTools.length)} voice tools: ${voiceTools.map((t) => t.name).join(", ")}`);
208
- const fullConfig = api.config ?? {};
209
- const pluginConfig = fullConfig.plugins?.entries?.["@alfe.ai/openclaw-voice"]?.config ?? fullConfig.plugins?.entries?.["voice-gateway"]?.config ?? {};
210
- const startVoiceService = () => {
211
- (0, _alfe_ai_openclaw_plugin_kit.guardedStart)(VOICE_ACTIVATION_KEY, log, () => {
212
- log.info("Alfe Voice plugin activating...");
213
- voiceServiceUrl = pluginConfig.voiceServiceUrl ?? `http://localhost:${String(pluginConfig.voiceServicePort ?? "3100")}`;
214
- voiceServiceApiKey = pluginConfig.voiceServiceApiKey ?? "";
215
- log.info(`Voice service: ${voiceServiceUrl}`);
216
- let alfeConfig = null;
217
- try {
218
- alfeConfig = (0, _alfe_ai_config.resolveConfig)();
219
- } catch {}
220
- (0, _alfe_ai_openclaw_plugin_kit.connectToDaemon)(pluginConfig.daemonSocket ?? alfeConfig?.socketPath ?? _alfe_ai_config.DEFAULT_SOCKET_PATH, log, {
221
- pluginId: "@alfe.ai/openclaw-voice",
222
- capabilities: VOICE_CAPABILITIES,
223
- standaloneNote: "Alfe daemon not available — voice plugin running standalone"
224
- }).then((client) => {
225
- daemonIpcClient = client;
226
- }).catch((err) => {
227
- log.debug(`Daemon connect failed: ${err.message}`);
228
- });
229
- });
230
- };
231
- const stopVoiceService = () => {
232
- if (daemonIpcClient) {
233
- try {
234
- daemonIpcClient.stop();
235
- log.info("Disconnected from Alfe daemon");
236
- } catch (err) {
237
- log.debug(`Error disconnecting from daemon: ${err.message}`);
238
- }
239
- daemonIpcClient = null;
240
- }
241
- (0, _alfe_ai_openclaw_plugin_kit.resetActivation)(VOICE_ACTIVATION_KEY);
242
- log.info("Alfe Voice plugin deactivated");
243
- };
244
- api.registerGatewayMethod("voice.speak", async (...args) => {
245
- const { text } = args[0];
246
- log.info(`voice.speak RPC → text=${text?.slice(0, 50) ?? "(none)"}...`);
247
- if (!text) throw new Error("voice.speak requires text");
248
- return await voiceApi("POST", "/voice/tts", { text });
249
- });
250
- log.info("Registered gateway RPC method: voice.speak");
251
- api.on("message_received", (...eventArgs) => {
252
- const event = eventArgs[0];
253
- if (eventArgs[1].channelId.includes("voice")) log.debug(`Voice-related message from ${event.from}: ${event.content.slice(0, 100)}`);
254
- });
255
- api.registerService({
256
- id: "alfe-voice-daemon",
257
- start: () => {
258
- startVoiceService();
259
- },
260
- stop: () => {
261
- stopVoiceService();
262
- }
263
- });
264
334
  log.info("Alfe Voice plugin activated");
265
335
  },
266
336
  deactivate(api) {
267
337
  const log = api.logger;
268
338
  log.info("Alfe Voice plugin deactivating...");
269
- if (daemonIpcClient) {
270
- try {
271
- daemonIpcClient.stop();
272
- log.info("Disconnected from Alfe daemon");
273
- } catch (err) {
274
- log.debug(`Error disconnecting from daemon: ${err.message}`);
275
- }
276
- daemonIpcClient = null;
277
- }
278
- (0, _alfe_ai_openclaw_plugin_kit.resetActivation)(VOICE_ACTIVATION_KEY);
339
+ voiceClient = null;
279
340
  log.info("Alfe Voice plugin deactivated");
280
341
  }
281
342
  };
package/dist/plugin2.js CHANGED
@@ -1,13 +1,32 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { Type } from "@sinclair/typebox";
3
- import { DEFAULT_SOCKET_PATH, resolveConfig } from "@alfe.ai/config";
3
+ import { resolveConfig } from "@alfe.ai/config";
4
4
  import { AgentApiClient, installToolErrorCapture } from "@alfe.ai/agent-api-client";
5
- import { connectToDaemon, defineTool, getActivationKey, guardedStart, resetActivation } from "@alfe.ai/openclaw-plugin-kit";
6
- import { readFile, writeFile } from "node:fs/promises";
7
- import { isAbsolute, resolve } from "node:path";
5
+ import { defineTool, publicToolError } from "@alfe.ai/openclaw-plugin-kit";
6
+ import { constants } from "node:fs";
7
+ import { lstat, open, realpath, unlink } from "node:fs/promises";
8
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
9
  //#region src/audio.ts
10
+ const MAX_WAV_PCM_BYTES = 4294967259;
11
+ const SUPPORTED_BIT_DEPTHS = new Set([
12
+ 8,
13
+ 16,
14
+ 24,
15
+ 32
16
+ ]);
17
+ function validatePcmFraming(framing, pcmLength) {
18
+ const { sampleRate, channels, bitDepth } = framing;
19
+ if (!Number.isInteger(sampleRate) || sampleRate < 8e3 || sampleRate > 384e3) throw new Error("WAV sample rate must be an integer between 8000 and 384000 Hz.");
20
+ if (!Number.isInteger(channels) || channels < 1 || channels > 32) throw new Error("WAV channel count must be an integer between 1 and 32.");
21
+ if (!SUPPORTED_BIT_DEPTHS.has(bitDepth)) throw new Error("WAV bit depth must be one of 8, 16, 24, or 32.");
22
+ const blockAlign = channels * (bitDepth / 8);
23
+ if (pcmLength % blockAlign !== 0) throw new Error("PCM byte length must contain a whole number of sample frames.");
24
+ if (pcmLength > MAX_WAV_PCM_BYTES) throw new Error("PCM payload is too large for a RIFF/WAV container.");
25
+ if (sampleRate * blockAlign > 4294967295) throw new Error("WAV byte rate exceeds the RIFF field limit.");
26
+ }
9
27
  /** Prepend a canonical 44-byte PCM WAV header to raw PCM samples. */
10
28
  function pcmToWav(pcm, framing) {
29
+ validatePcmFraming(framing, pcm.length);
11
30
  const { sampleRate, channels, bitDepth } = framing;
12
31
  const blockAlign = channels * (bitDepth / 8);
13
32
  const byteRate = sampleRate * blockAlign;
@@ -28,35 +47,59 @@ function pcmToWav(pcm, framing) {
28
47
  return Buffer.concat([header, pcm]);
29
48
  }
30
49
  /**
31
- * Parse a WAV buffer into its PCM payload + framing. Returns `null` when the
32
- * buffer is not a RIFF/WAVE file (caller should then treat the bytes as raw
33
- * PCM). Walks the chunk list so it tolerates `fmt `/`data` ordering, extra
34
- * chunks (e.g. `LIST`/`fact`), and word-alignment padding.
50
+ * Parse a WAV buffer into its PCM payload + framing. Returns `null` only when
51
+ * the buffer is not RIFF data (caller may then treat it as raw PCM). A RIFF
52
+ * buffer that claims to be WAV but is truncated, unsupported, or internally
53
+ * inconsistent throws instead of silently uploading container bytes as PCM.
35
54
  */
36
55
  function parseWav(buf) {
37
- if (buf.length < 44) return null;
38
- if (buf.toString("ascii", 0, 4) !== "RIFF") return null;
39
- if (buf.toString("ascii", 8, 12) !== "WAVE") return null;
56
+ if (buf.length < 4 || buf.toString("ascii", 0, 4) !== "RIFF") return null;
57
+ if (buf.length < 12) throw new Error("Malformed WAV: truncated RIFF header.");
58
+ if (buf.toString("ascii", 8, 12) !== "WAVE") throw new Error("Unsupported RIFF container: expected WAVE.");
59
+ const declaredEnd = buf.readUInt32LE(4) + 8;
60
+ if (declaredEnd !== buf.length) throw new Error("Malformed WAV: RIFF size does not match the file length.");
40
61
  let sampleRate = 0;
41
62
  let channels = 0;
42
63
  let bitDepth = 0;
43
64
  let pcm = null;
65
+ let blockAlign = 0;
66
+ let byteRate = 0;
67
+ let sawFmt = false;
68
+ let sawData = false;
44
69
  let offset = 12;
45
- while (offset + 8 <= buf.length) {
70
+ while (offset < declaredEnd) {
71
+ if (offset + 8 > declaredEnd) throw new Error("Malformed WAV: truncated chunk header.");
46
72
  const chunkId = buf.toString("ascii", offset, offset + 4);
47
73
  const chunkSize = buf.readUInt32LE(offset + 4);
48
74
  const bodyStart = offset + 8;
49
- if (chunkId === "fmt " && bodyStart + 16 <= buf.length) {
75
+ const bodyEnd = bodyStart + chunkSize;
76
+ const nextOffset = bodyEnd + chunkSize % 2;
77
+ if (bodyEnd > declaredEnd || nextOffset > declaredEnd) throw new Error(`Malformed WAV: truncated ${chunkId} chunk.`);
78
+ if (chunkId === "fmt ") {
79
+ if (sawFmt) throw new Error("Malformed WAV: duplicate fmt chunk.");
80
+ if (chunkSize < 16) throw new Error("Malformed WAV: fmt chunk is too short.");
81
+ if (buf.readUInt16LE(bodyStart) !== 1) throw new Error("Unsupported WAV encoding: only integer PCM is accepted.");
50
82
  channels = buf.readUInt16LE(bodyStart + 2);
51
83
  sampleRate = buf.readUInt32LE(bodyStart + 4);
84
+ byteRate = buf.readUInt32LE(bodyStart + 8);
85
+ blockAlign = buf.readUInt16LE(bodyStart + 12);
52
86
  bitDepth = buf.readUInt16LE(bodyStart + 14);
87
+ sawFmt = true;
53
88
  } else if (chunkId === "data") {
54
- const end = Math.min(bodyStart + chunkSize, buf.length);
55
- pcm = buf.subarray(bodyStart, end);
89
+ if (sawData) throw new Error("Malformed WAV: duplicate data chunk.");
90
+ pcm = buf.subarray(bodyStart, bodyEnd);
91
+ sawData = true;
56
92
  }
57
- offset = bodyStart + chunkSize + chunkSize % 2;
93
+ offset = nextOffset;
58
94
  }
59
- if (!pcm || sampleRate === 0 || channels === 0 || bitDepth === 0) return null;
95
+ if (!sawFmt || !sawData || pcm === null) throw new Error("Malformed WAV: both fmt and data chunks are required.");
96
+ validatePcmFraming({
97
+ sampleRate,
98
+ channels,
99
+ bitDepth
100
+ }, pcm.length);
101
+ const expectedBlockAlign = channels * (bitDepth / 8);
102
+ if (blockAlign !== expectedBlockAlign || byteRate !== sampleRate * expectedBlockAlign) throw new Error("Malformed WAV: byte rate or block alignment is inconsistent with its framing.");
60
103
  return {
61
104
  pcm,
62
105
  sampleRate,
@@ -65,6 +108,83 @@ function parseWav(buf) {
65
108
  };
66
109
  }
67
110
  //#endregion
111
+ //#region src/workspace-files.ts
112
+ const MAX_TOOL_PATH_LENGTH = 1024;
113
+ function isWithin(root, candidate) {
114
+ const rel = relative(root, candidate);
115
+ return rel === "" || !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`);
116
+ }
117
+ function assertRelativeToolPath(input, label) {
118
+ if (input.length === 0) throw new Error(`${label} must not be empty.`);
119
+ if (input.length > MAX_TOOL_PATH_LENGTH) throw new Error(`${label} is too long (maximum ${String(MAX_TOOL_PATH_LENGTH)} characters).`);
120
+ if (input.includes("\0")) throw new Error(`${label} contains a null byte.`);
121
+ if (isAbsolute(input) || /^[A-Za-z]:[\\/]/u.test(input) || input.startsWith("\\\\")) throw new Error(`${label} must be relative to the workspace.`);
122
+ if (input.split(/[\\/]/u).includes("..")) throw new Error(`${label} must not contain parent-directory traversal.`);
123
+ }
124
+ async function workspaceRoot() {
125
+ return realpath(process.cwd());
126
+ }
127
+ async function readWorkspaceFile(input, maxBytes) {
128
+ assertRelativeToolPath(input, "Path");
129
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error("Maximum file size must be a positive safe integer.");
130
+ const root = await workspaceRoot();
131
+ const lexicalPath = resolve(root, input);
132
+ if (!isWithin(root, lexicalPath)) throw new Error("Path escapes the workspace.");
133
+ const absolutePath = await realpath(lexicalPath);
134
+ if (!isWithin(root, absolutePath)) throw new Error("Path resolves outside the workspace.");
135
+ const handle = await open(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW);
136
+ try {
137
+ const before = await handle.stat();
138
+ if (!before.isFile()) throw new Error("Path must refer to a regular file.");
139
+ if (before.size > maxBytes) throw new Error(`File is too large (maximum ${String(maxBytes)} bytes).`);
140
+ const data = await handle.readFile();
141
+ const after = await handle.stat();
142
+ if (data.length > maxBytes || after.size > maxBytes) throw new Error(`File is too large (maximum ${String(maxBytes)} bytes).`);
143
+ return {
144
+ absolutePath,
145
+ data
146
+ };
147
+ } finally {
148
+ await handle.close();
149
+ }
150
+ }
151
+ async function resolveWorkspaceOutputPath(input) {
152
+ assertRelativeToolPath(input, "Output path");
153
+ const root = await workspaceRoot();
154
+ const lexicalPath = resolve(root, input);
155
+ if (!isWithin(root, lexicalPath)) throw new Error("Output path escapes the workspace.");
156
+ const parent = await realpath(dirname(lexicalPath));
157
+ if (!isWithin(root, parent)) throw new Error("Output path resolves outside the workspace.");
158
+ const absolutePath = join(parent, basename(lexicalPath));
159
+ try {
160
+ await lstat(absolutePath);
161
+ throw new Error(`Refusing to overwrite existing workspace file: ${input}`);
162
+ } catch (error) {
163
+ if (error.code !== "ENOENT") throw error;
164
+ }
165
+ return absolutePath;
166
+ }
167
+ async function writeWorkspaceFileExclusive(input, data) {
168
+ const absolutePath = await resolveWorkspaceOutputPath(input);
169
+ let handle;
170
+ try {
171
+ handle = await open(absolutePath, "wx", 384);
172
+ } catch (error) {
173
+ if (error.code === "EEXIST") throw new Error(`Refusing to overwrite existing workspace file: ${input}`);
174
+ throw error;
175
+ }
176
+ let completed = false;
177
+ try {
178
+ await handle.writeFile(data);
179
+ await handle.sync();
180
+ completed = true;
181
+ return absolutePath;
182
+ } finally {
183
+ await handle.close();
184
+ if (!completed) await unlink(absolutePath).catch(() => void 0);
185
+ }
186
+ }
187
+ //#endregion
68
188
  //#region src/plugin.ts
69
189
  /**
70
190
  * @alfe/voice-plugin — OpenClaw native plugin
@@ -79,7 +199,9 @@ function parseWav(buf) {
79
199
  * This plugin provides:
80
200
  * - voice_tts tool — text → audio file via POST /voice/tts (agent-authed)
81
201
  * - voice_stt tool — audio file → transcript via POST /voice/stt (agent-authed)
82
- * - voice.speak RPC — legacy gateway RPC (reads plugin config; vestigial)
202
+ *
203
+ * The plugin is intentionally tool-only. It has no daemon connection,
204
+ * gateway RPC, message hook, or plugin-config credential path.
83
205
  *
84
206
  * voice_hangup / voice_transfer / voice_dtmf were removed deliberately — they
85
207
  * threw unconditionally ("requires a channel service"), which poisons the
@@ -92,32 +214,10 @@ function parseWav(buf) {
92
214
  * or hit localhost.
93
215
  */
94
216
  const pkg = createRequire(import.meta.url)("../package.json");
95
- const VOICE_CAPABILITIES = [
96
- "voice.call",
97
- "voice.answer",
98
- "voice.dtmf",
99
- "voice.hangup"
100
- ];
101
- const VOICE_ACTIVATION_KEY = getActivationKey("voice");
102
- let voiceServiceUrl = "";
103
- let voiceServiceApiKey = "";
104
- async function voiceApi(method, path, body) {
105
- const url = `${voiceServiceUrl}${path}`;
106
- const headers = { "Content-Type": "application/json" };
107
- if (voiceServiceApiKey) headers["x-api-key"] = voiceServiceApiKey;
108
- const res = await fetch(url, {
109
- method,
110
- headers,
111
- body: body ? JSON.stringify(body) : void 0
112
- });
113
- const json = await res.json();
114
- if (!res.ok) {
115
- const errorMsg = typeof json.error === "string" ? json.error : `Voice service returned ${String(res.status)}`;
116
- throw new Error(errorMsg);
117
- }
118
- return json;
119
- }
120
- let daemonIpcClient = null;
217
+ const MAX_AUDIO_FILE_BYTES = 10 * 1024 * 1024;
218
+ const MAX_TTS_RESPONSE_BYTES = 32 * 1024 * 1024;
219
+ const VOICE_ID_PATTERN = "^[A-Za-z0-9_-]{1,200}$";
220
+ const VOICE_ID_REGEX = /^[A-Za-z0-9_-]{1,200}$/u;
121
221
  let voiceClient = null;
122
222
  function getVoiceClient() {
123
223
  if (voiceClient) return voiceClient;
@@ -128,39 +228,54 @@ function getVoiceClient() {
128
228
  });
129
229
  return voiceClient;
130
230
  }
131
- /** Resolve a tool-supplied path against the agent's working directory. */
132
- function resolveWorkspacePath(p) {
133
- return isAbsolute(p) ? p : resolve(process.cwd(), p);
134
- }
135
231
  const voiceTools = [defineTool({
136
232
  name: "voice_tts",
137
233
  description: "Convert text to speech using the Alfe voice service (ElevenLabs). Writes a playable audio file to the workspace and returns its path. Billed per character to your tenant credit pool.",
138
234
  parameters: Type.Object({
139
- text: Type.String({ description: "Text to synthesize (1–5000 characters)." }),
140
- outputPath: Type.Optional(Type.String({ description: "Where to write the audio file (absolute, or relative to the working directory). Defaults to alfe-tts-<timestamp>.wav in the working directory." })),
235
+ text: Type.String({
236
+ minLength: 1,
237
+ maxLength: 5e3,
238
+ description: "Text to synthesize (1–5000 characters)."
239
+ }),
240
+ outputPath: Type.Optional(Type.String({
241
+ minLength: 1,
242
+ maxLength: 1024,
243
+ description: "New workspace-relative audio file to create. Existing files are never overwritten. Defaults to alfe-tts-<timestamp>.wav."
244
+ })),
141
245
  format: Type.Optional(Type.Union([Type.Literal("wav"), Type.Literal("pcm")], { description: "Output container. 'wav' (default) is a playable file; 'pcm' is headerless 24kHz/mono/16-bit raw PCM." })),
142
- voiceId: Type.Optional(Type.String({ description: "ElevenLabs voice ID. Platform default when omitted." })),
246
+ voiceId: Type.Optional(Type.String({
247
+ pattern: VOICE_ID_PATTERN,
248
+ description: "ElevenLabs voice ID. Platform default when omitted."
249
+ })),
143
250
  model: Type.Optional(Type.Union([Type.Literal("eleven_turbo_v2_5"), Type.Literal("eleven_multilingual_v2")], { description: "TTS model. eleven_turbo_v2_5 (lower latency) when omitted." }))
144
251
  }),
145
252
  handler: async (params) => {
146
- const text = params.text;
253
+ const text = params.text.trim();
254
+ if (text.length < 1 || text.length > 5e3) throw publicToolError("voice_tts text must contain 1–5000 characters.");
255
+ const voiceId = params.voiceId;
256
+ if (voiceId !== void 0 && !VOICE_ID_REGEX.test(voiceId)) throw publicToolError("voice_tts voiceId contains unsupported characters.");
147
257
  const format = params.format ?? "wav";
258
+ const outputPath = params.outputPath ?? `alfe-tts-${String(Date.now())}.${format}`;
259
+ await resolveWorkspaceOutputPath(outputPath);
148
260
  const result = await getVoiceClient().tts({
149
261
  text,
150
- voiceId: params.voiceId,
262
+ voiceId,
151
263
  model: params.model
152
264
  });
265
+ if (result.audio.length === 0 || result.audio.length > MAX_TTS_RESPONSE_BYTES) throw new Error(`voice_tts returned an invalid audio payload size (${String(result.audio.length)} bytes).`);
266
+ validatePcmFraming({
267
+ sampleRate: result.sampleRate,
268
+ channels: result.channels,
269
+ bitDepth: result.bitDepth
270
+ }, result.audio.length);
153
271
  const bytes = format === "wav" ? pcmToWav(result.audio, {
154
272
  sampleRate: result.sampleRate,
155
273
  channels: result.channels,
156
274
  bitDepth: result.bitDepth
157
275
  }) : result.audio;
158
- const outputPath = params.outputPath ?? `alfe-tts-${String(Date.now())}.${format}`;
159
- const absolutePath = resolveWorkspacePath(outputPath);
160
- await writeFile(absolutePath, bytes);
161
276
  return {
162
277
  path: outputPath,
163
- absolutePath,
278
+ absolutePath: await writeWorkspaceFileExclusive(outputPath, bytes),
164
279
  format,
165
280
  sampleRate: result.sampleRate,
166
281
  channels: result.channels,
@@ -173,22 +288,33 @@ const voiceTools = [defineTool({
173
288
  name: "voice_stt",
174
289
  description: "Transcribe an audio file to text using the Alfe voice service (Deepgram). Accepts a WAV file or headerless mono 16-bit PCM. Billed per audio-second to your tenant credit pool.",
175
290
  parameters: Type.Object({
176
- path: Type.String({ description: "Path to the audio file (absolute, or relative to the working directory). WAV or raw linear16 mono PCM." }),
177
- sampleRate: Type.Optional(Type.Number({ description: "Sample rate in Hz (8000–48000). Only used for headerless PCM input; ignored for WAV (its header wins). Defaults to 24000." }))
291
+ path: Type.String({
292
+ minLength: 1,
293
+ maxLength: 1024,
294
+ description: "Workspace-relative path to a WAV or raw linear16 mono PCM file (maximum 10 MiB)."
295
+ }),
296
+ sampleRate: Type.Optional(Type.Number({
297
+ minimum: 8e3,
298
+ maximum: 48e3,
299
+ multipleOf: 1,
300
+ description: "Sample rate in Hz (8000–48000). Only used for headerless PCM input; ignored for WAV (its header wins). Defaults to 24000."
301
+ }))
178
302
  }),
179
303
  handler: async (params) => {
180
- const raw = await readFile(resolveWorkspacePath(params.path));
304
+ const { data: raw } = await readWorkspaceFile(params.path, MAX_AUDIO_FILE_BYTES);
181
305
  const wav = parseWav(raw);
182
306
  let audio;
183
307
  let sampleRate;
184
308
  if (wav) {
185
- if (wav.bitDepth !== 16 || wav.channels !== 1) throw new Error(`voice_stt needs 16-bit mono audio; got ${String(wav.bitDepth)}-bit / ${String(wav.channels)}-channel WAV. Re-export as 16-bit mono.`);
309
+ if (wav.bitDepth !== 16 || wav.channels !== 1) throw publicToolError(`voice_stt needs 16-bit mono audio; got ${String(wav.bitDepth)}-bit / ${String(wav.channels)}-channel WAV. Re-export as 16-bit mono.`);
186
310
  audio = wav.pcm;
187
311
  sampleRate = wav.sampleRate;
188
312
  } else {
189
313
  audio = raw;
190
314
  sampleRate = params.sampleRate ?? 24e3;
191
315
  }
316
+ if (!Number.isInteger(sampleRate) || sampleRate < 8e3 || sampleRate > 48e3) throw publicToolError("voice_stt sampleRate must be an integer between 8000 and 48000 Hz.");
317
+ if (audio.length === 0 || audio.length % 2 !== 0) throw publicToolError("voice_stt needs non-empty 16-bit PCM with an even byte length.");
192
318
  return getVoiceClient().stt({
193
319
  audio,
194
320
  sampleRate
@@ -205,77 +331,12 @@ const plugin = {
205
331
  const log = api.logger;
206
332
  for (const tool of voiceTools) api.registerTool(tool);
207
333
  log.info(`Registered ${String(voiceTools.length)} voice tools: ${voiceTools.map((t) => t.name).join(", ")}`);
208
- const fullConfig = api.config ?? {};
209
- const pluginConfig = fullConfig.plugins?.entries?.["@alfe.ai/openclaw-voice"]?.config ?? fullConfig.plugins?.entries?.["voice-gateway"]?.config ?? {};
210
- const startVoiceService = () => {
211
- guardedStart(VOICE_ACTIVATION_KEY, log, () => {
212
- log.info("Alfe Voice plugin activating...");
213
- voiceServiceUrl = pluginConfig.voiceServiceUrl ?? `http://localhost:${String(pluginConfig.voiceServicePort ?? "3100")}`;
214
- voiceServiceApiKey = pluginConfig.voiceServiceApiKey ?? "";
215
- log.info(`Voice service: ${voiceServiceUrl}`);
216
- let alfeConfig = null;
217
- try {
218
- alfeConfig = resolveConfig();
219
- } catch {}
220
- connectToDaemon(pluginConfig.daemonSocket ?? alfeConfig?.socketPath ?? DEFAULT_SOCKET_PATH, log, {
221
- pluginId: "@alfe.ai/openclaw-voice",
222
- capabilities: VOICE_CAPABILITIES,
223
- standaloneNote: "Alfe daemon not available — voice plugin running standalone"
224
- }).then((client) => {
225
- daemonIpcClient = client;
226
- }).catch((err) => {
227
- log.debug(`Daemon connect failed: ${err.message}`);
228
- });
229
- });
230
- };
231
- const stopVoiceService = () => {
232
- if (daemonIpcClient) {
233
- try {
234
- daemonIpcClient.stop();
235
- log.info("Disconnected from Alfe daemon");
236
- } catch (err) {
237
- log.debug(`Error disconnecting from daemon: ${err.message}`);
238
- }
239
- daemonIpcClient = null;
240
- }
241
- resetActivation(VOICE_ACTIVATION_KEY);
242
- log.info("Alfe Voice plugin deactivated");
243
- };
244
- api.registerGatewayMethod("voice.speak", async (...args) => {
245
- const { text } = args[0];
246
- log.info(`voice.speak RPC → text=${text?.slice(0, 50) ?? "(none)"}...`);
247
- if (!text) throw new Error("voice.speak requires text");
248
- return await voiceApi("POST", "/voice/tts", { text });
249
- });
250
- log.info("Registered gateway RPC method: voice.speak");
251
- api.on("message_received", (...eventArgs) => {
252
- const event = eventArgs[0];
253
- if (eventArgs[1].channelId.includes("voice")) log.debug(`Voice-related message from ${event.from}: ${event.content.slice(0, 100)}`);
254
- });
255
- api.registerService({
256
- id: "alfe-voice-daemon",
257
- start: () => {
258
- startVoiceService();
259
- },
260
- stop: () => {
261
- stopVoiceService();
262
- }
263
- });
264
334
  log.info("Alfe Voice plugin activated");
265
335
  },
266
336
  deactivate(api) {
267
337
  const log = api.logger;
268
338
  log.info("Alfe Voice plugin deactivating...");
269
- if (daemonIpcClient) {
270
- try {
271
- daemonIpcClient.stop();
272
- log.info("Disconnected from Alfe daemon");
273
- } catch (err) {
274
- log.debug(`Error disconnecting from daemon: ${err.message}`);
275
- }
276
- daemonIpcClient = null;
277
- }
278
- resetActivation(VOICE_ACTIVATION_KEY);
339
+ voiceClient = null;
279
340
  log.info("Alfe Voice plugin deactivated");
280
341
  }
281
342
  };
@@ -3,22 +3,13 @@
3
3
  "name": "Alfe Voice Plugin",
4
4
  "description": "Alfe voice plugin — agent-callable one-shot TTS/STT via the voice service (billed to the tenant credit pool). Channel-specific ops (hangup/transfer/DTMF) are handled by channel services.",
5
5
  "entry": "./dist/plugin.js",
6
- "activation": { "onStartup": true },
6
+ "activation": { "onStartup": false },
7
7
  "contracts": {
8
8
  "tools": ["voice_tts", "voice_stt"]
9
9
  },
10
10
  "configSchema": {
11
11
  "type": "object",
12
12
  "additionalProperties": false,
13
- "properties": {
14
- "agentToken": {
15
- "type": "string",
16
- "description": "Single token for relay authentication and agent identification. Defaults to api_key from ~/.alfe/config.toml if not set."
17
- },
18
- "relayUrl": {
19
- "type": "string",
20
- "description": "WebSocket URL of the Fly voice server relay endpoint. Defaults to wss://voice.dev.alfe.ai/gateway"
21
- }
22
- }
13
+ "properties": {}
23
14
  }
24
15
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-voice",
3
- "version": "0.1.15",
4
- "description": "OpenClaw voice plugin for Alfe Discord audio, Twilio, Recall.ai",
3
+ "version": "0.2.0",
4
+ "description": "OpenClaw tools for Alfe one-shot text-to-speech and speech-to-text",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -28,9 +28,9 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "@sinclair/typebox": "^0.34.48",
31
- "@alfe.ai/agent-api-client": "0.14.0",
32
- "@alfe.ai/config": "0.4.0",
33
- "@alfe.ai/openclaw-plugin-kit": "0.1.0"
31
+ "@alfe.ai/agent-api-client": "0.15.0",
32
+ "@alfe.ai/config": "0.4.1",
33
+ "@alfe.ai/openclaw-plugin-kit": "0.2.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "openclaw": ">=2026.3.0"