@alfe.ai/openclaw-voice 0.1.14 → 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/dist/plugin2.js CHANGED
@@ -1,12 +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 { readFile, writeFile } from "node:fs/promises";
6
- 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";
7
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
+ }
8
27
  /** Prepend a canonical 44-byte PCM WAV header to raw PCM samples. */
9
28
  function pcmToWav(pcm, framing) {
29
+ validatePcmFraming(framing, pcm.length);
10
30
  const { sampleRate, channels, bitDepth } = framing;
11
31
  const blockAlign = channels * (bitDepth / 8);
12
32
  const byteRate = sampleRate * blockAlign;
@@ -27,35 +47,59 @@ function pcmToWav(pcm, framing) {
27
47
  return Buffer.concat([header, pcm]);
28
48
  }
29
49
  /**
30
- * Parse a WAV buffer into its PCM payload + framing. Returns `null` when the
31
- * buffer is not a RIFF/WAVE file (caller should then treat the bytes as raw
32
- * PCM). Walks the chunk list so it tolerates `fmt `/`data` ordering, extra
33
- * 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.
34
54
  */
35
55
  function parseWav(buf) {
36
- if (buf.length < 44) return null;
37
- if (buf.toString("ascii", 0, 4) !== "RIFF") return null;
38
- 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.");
39
61
  let sampleRate = 0;
40
62
  let channels = 0;
41
63
  let bitDepth = 0;
42
64
  let pcm = null;
65
+ let blockAlign = 0;
66
+ let byteRate = 0;
67
+ let sawFmt = false;
68
+ let sawData = false;
43
69
  let offset = 12;
44
- while (offset + 8 <= buf.length) {
70
+ while (offset < declaredEnd) {
71
+ if (offset + 8 > declaredEnd) throw new Error("Malformed WAV: truncated chunk header.");
45
72
  const chunkId = buf.toString("ascii", offset, offset + 4);
46
73
  const chunkSize = buf.readUInt32LE(offset + 4);
47
74
  const bodyStart = offset + 8;
48
- 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.");
49
82
  channels = buf.readUInt16LE(bodyStart + 2);
50
83
  sampleRate = buf.readUInt32LE(bodyStart + 4);
84
+ byteRate = buf.readUInt32LE(bodyStart + 8);
85
+ blockAlign = buf.readUInt16LE(bodyStart + 12);
51
86
  bitDepth = buf.readUInt16LE(bodyStart + 14);
87
+ sawFmt = true;
52
88
  } else if (chunkId === "data") {
53
- const end = Math.min(bodyStart + chunkSize, buf.length);
54
- 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;
55
92
  }
56
- offset = bodyStart + chunkSize + chunkSize % 2;
93
+ offset = nextOffset;
57
94
  }
58
- 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.");
59
103
  return {
60
104
  pcm,
61
105
  sampleRate,
@@ -64,6 +108,83 @@ function parseWav(buf) {
64
108
  };
65
109
  }
66
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
67
188
  //#region src/plugin.ts
68
189
  /**
69
190
  * @alfe/voice-plugin — OpenClaw native plugin
@@ -78,10 +199,14 @@ function parseWav(buf) {
78
199
  * This plugin provides:
79
200
  * - voice_tts tool — text → audio file via POST /voice/tts (agent-authed)
80
201
  * - voice_stt tool — audio file → transcript via POST /voice/stt (agent-authed)
81
- * - voice.speak RPC — legacy gateway RPC (reads plugin config; vestigial)
82
- * - voice_hangup tool placeholder (requires channel service)
83
- * - voice_transfer tool placeholder (requires Twilio service)
84
- * - voice_dtmf tool — placeholder (requires Twilio service)
202
+ *
203
+ * The plugin is intentionally tool-only. It has no daemon connection,
204
+ * gateway RPC, message hook, or plugin-config credential path.
205
+ *
206
+ * voice_hangup / voice_transfer / voice_dtmf were removed deliberately — they
207
+ * threw unconditionally ("requires a channel service"), which poisons the
208
+ * model's tool selection with always-failing options. Re-add per-channel when
209
+ * the twilio-adapter/Discord dispatch actually lands (voice-engineer roadmap).
85
210
  *
86
211
  * The voice_tts/voice_stt tools source URL + agent key from ~/.alfe/config.toml
87
212
  * via `@alfe.ai/config` (resolveConfig) and call through `AgentApiClient` — the
@@ -89,90 +214,10 @@ function parseWav(buf) {
89
214
  * or hit localhost.
90
215
  */
91
216
  const pkg = createRequire(import.meta.url)("../package.json");
92
- const VOICE_CAPABILITIES = [
93
- "voice.call",
94
- "voice.answer",
95
- "voice.dtmf",
96
- "voice.hangup"
97
- ];
98
- let voiceServiceUrl = "";
99
- let voiceServiceApiKey = "";
100
- async function voiceApi(method, path, body) {
101
- const url = `${voiceServiceUrl}${path}`;
102
- const headers = { "Content-Type": "application/json" };
103
- if (voiceServiceApiKey) headers["x-api-key"] = voiceServiceApiKey;
104
- const res = await fetch(url, {
105
- method,
106
- headers,
107
- body: body ? JSON.stringify(body) : void 0
108
- });
109
- const json = await res.json();
110
- if (!res.ok) {
111
- const errorMsg = typeof json.error === "string" ? json.error : `Voice service returned ${String(res.status)}`;
112
- throw new Error(errorMsg);
113
- }
114
- return json;
115
- }
116
- let daemonIpcClient = null;
117
- async function connectToDaemon(socketPath, log) {
118
- try {
119
- const IPCClientCtor = (await import("@alfe.ai/openclaw")).IPCClient;
120
- const client = new IPCClientCtor(socketPath, log);
121
- client.on("connected", async () => {
122
- log.info("Connected to Alfe daemon — registering voice capabilities...");
123
- const response = await client.request("capability.register", {
124
- plugin: "@alfe.ai/openclaw-voice",
125
- capabilities: [...VOICE_CAPABILITIES]
126
- });
127
- if (response.ok) log.info("Voice capabilities registered with daemon");
128
- else log.warn(`Failed to register voice capabilities: ${response.error?.message ?? "unknown"}`);
129
- });
130
- client.on("disconnected", (reason) => {
131
- log.warn(`Disconnected from Alfe daemon: ${String(reason)}`);
132
- });
133
- client.on("error", (err) => {
134
- log.debug(`Daemon IPC error: ${err.message}`);
135
- });
136
- client.start();
137
- return client;
138
- } catch {
139
- log.info("Alfe daemon not available — voice plugin running standalone");
140
- return null;
141
- }
142
- }
143
- function ok(data) {
144
- return {
145
- content: [{
146
- type: "text",
147
- text: JSON.stringify(data)
148
- }],
149
- details: data
150
- };
151
- }
152
- function errResult(message) {
153
- return {
154
- content: [{
155
- type: "text",
156
- text: JSON.stringify({ error: message })
157
- }],
158
- details: { error: message }
159
- };
160
- }
161
- function defineTool(def) {
162
- return {
163
- name: def.name,
164
- description: def.description,
165
- label: def.name,
166
- parameters: def.parameters,
167
- execute: async (_toolCallId, params) => {
168
- try {
169
- return ok(await def.handler(params));
170
- } catch (e) {
171
- return errResult(e.message);
172
- }
173
- }
174
- };
175
- }
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;
176
221
  let voiceClient = null;
177
222
  function getVoiceClient() {
178
223
  if (voiceClient) return voiceClient;
@@ -183,106 +228,99 @@ function getVoiceClient() {
183
228
  });
184
229
  return voiceClient;
185
230
  }
186
- /** Resolve a tool-supplied path against the agent's working directory. */
187
- function resolveWorkspacePath(p) {
188
- return isAbsolute(p) ? p : resolve(process.cwd(), p);
189
- }
190
- const voiceTools = [
191
- defineTool({
192
- name: "voice_tts",
193
- 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.",
194
- parameters: Type.Object({
195
- text: Type.String({ description: "Text to synthesize (1–5000 characters)." }),
196
- 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." })),
197
- 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." })),
198
- voiceId: Type.Optional(Type.String({ description: "ElevenLabs voice ID. Platform default when omitted." })),
199
- 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." }))
231
+ const voiceTools = [defineTool({
232
+ name: "voice_tts",
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.",
234
+ parameters: Type.Object({
235
+ text: Type.String({
236
+ minLength: 1,
237
+ maxLength: 5e3,
238
+ description: "Text to synthesize (1–5000 characters)."
200
239
  }),
201
- handler: async (params) => {
202
- const text = params.text;
203
- const format = params.format ?? "wav";
204
- const result = await getVoiceClient().tts({
205
- text,
206
- voiceId: params.voiceId,
207
- model: params.model
208
- });
209
- const bytes = format === "wav" ? pcmToWav(result.audio, {
210
- sampleRate: result.sampleRate,
211
- channels: result.channels,
212
- bitDepth: result.bitDepth
213
- }) : result.audio;
214
- const outputPath = params.outputPath ?? `alfe-tts-${String(Date.now())}.${format}`;
215
- const absolutePath = resolveWorkspacePath(outputPath);
216
- await writeFile(absolutePath, bytes);
217
- return {
218
- path: outputPath,
219
- absolutePath,
220
- format,
221
- sampleRate: result.sampleRate,
222
- channels: result.channels,
223
- bitDepth: result.bitDepth,
224
- bytes: bytes.length,
225
- characters: text.length
226
- };
227
- }
228
- }),
229
- defineTool({
230
- name: "voice_stt",
231
- 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.",
232
- parameters: Type.Object({
233
- path: Type.String({ description: "Path to the audio file (absolute, or relative to the working directory). WAV or raw linear16 mono PCM." }),
234
- 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." }))
235
- }),
236
- handler: async (params) => {
237
- const raw = await readFile(resolveWorkspacePath(params.path));
238
- const wav = parseWav(raw);
239
- let audio;
240
- let sampleRate;
241
- if (wav) {
242
- 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.`);
243
- audio = wav.pcm;
244
- sampleRate = wav.sampleRate;
245
- } else {
246
- audio = raw;
247
- sampleRate = params.sampleRate ?? 24e3;
248
- }
249
- return getVoiceClient().stt({
250
- audio,
251
- sampleRate
252
- });
253
- }
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
+ })),
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." })),
246
+ voiceId: Type.Optional(Type.String({
247
+ pattern: VOICE_ID_PATTERN,
248
+ description: "ElevenLabs voice ID. Platform default when omitted."
249
+ })),
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." }))
254
251
  }),
255
- defineTool({
256
- name: "voice_hangup",
257
- description: "Hang up the current voice call or leave the voice channel. Use when the conversation is done or the user asks you to leave.",
258
- parameters: Type.Object({ sessionId: Type.Optional(Type.String({ description: "Voice session ID." })) }),
259
- handler: () => {
260
- throw new Error("voice_hangup requires a channel service (Discord/Twilio). The voice service no longer manages channels directly.");
261
- }
262
- }),
263
- defineTool({
264
- name: "voice_transfer",
265
- description: "Transfer the current phone call to another number. Only works for Twilio calls.",
266
- parameters: Type.Object({
267
- targetNumber: Type.String({ description: "Phone number to transfer to (E.164 format)" }),
268
- sessionId: Type.Optional(Type.String({ description: "Voice session ID." }))
252
+ handler: async (params) => {
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.");
257
+ const format = params.format ?? "wav";
258
+ const outputPath = params.outputPath ?? `alfe-tts-${String(Date.now())}.${format}`;
259
+ await resolveWorkspaceOutputPath(outputPath);
260
+ const result = await getVoiceClient().tts({
261
+ text,
262
+ voiceId,
263
+ model: params.model
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);
271
+ const bytes = format === "wav" ? pcmToWav(result.audio, {
272
+ sampleRate: result.sampleRate,
273
+ channels: result.channels,
274
+ bitDepth: result.bitDepth
275
+ }) : result.audio;
276
+ return {
277
+ path: outputPath,
278
+ absolutePath: await writeWorkspaceFileExclusive(outputPath, bytes),
279
+ format,
280
+ sampleRate: result.sampleRate,
281
+ channels: result.channels,
282
+ bitDepth: result.bitDepth,
283
+ bytes: bytes.length,
284
+ characters: text.length
285
+ };
286
+ }
287
+ }), defineTool({
288
+ name: "voice_stt",
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.",
290
+ parameters: Type.Object({
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)."
269
295
  }),
270
- handler: () => {
271
- throw new Error("voice_transfer requires the Twilio service. The voice service no longer manages phone calls directly.");
272
- }
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
+ }))
273
302
  }),
274
- defineTool({
275
- name: "voice_dtmf",
276
- description: "Send DTMF tones (dial pad digits) on the current phone call. Only works for Twilio calls.",
277
- parameters: Type.Object({
278
- digits: Type.String({ description: "DTMF digits to send (0-9, *, #, w for pause)" }),
279
- sessionId: Type.Optional(Type.String({ description: "Voice session ID." }))
280
- }),
281
- handler: () => {
282
- throw new Error("voice_dtmf requires the Twilio service. The voice service no longer manages phone calls directly.");
303
+ handler: async (params) => {
304
+ const { data: raw } = await readWorkspaceFile(params.path, MAX_AUDIO_FILE_BYTES);
305
+ const wav = parseWav(raw);
306
+ let audio;
307
+ let sampleRate;
308
+ if (wav) {
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.`);
310
+ audio = wav.pcm;
311
+ sampleRate = wav.sampleRate;
312
+ } else {
313
+ audio = raw;
314
+ sampleRate = params.sampleRate ?? 24e3;
283
315
  }
284
- })
285
- ];
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.");
318
+ return getVoiceClient().stt({
319
+ audio,
320
+ sampleRate
321
+ });
322
+ }
323
+ })];
286
324
  const plugin = {
287
325
  id: "@alfe.ai/openclaw-voice",
288
326
  name: "Alfe Voice Plugin",
@@ -293,76 +331,12 @@ const plugin = {
293
331
  const log = api.logger;
294
332
  for (const tool of voiceTools) api.registerTool(tool);
295
333
  log.info(`Registered ${String(voiceTools.length)} voice tools: ${voiceTools.map((t) => t.name).join(", ")}`);
296
- const fullConfig = api.config ?? {};
297
- const pluginConfig = fullConfig.plugins?.entries?.["@alfe.ai/openclaw-voice"]?.config ?? fullConfig.plugins?.entries?.["voice-gateway"]?.config ?? {};
298
- const startVoiceService = () => {
299
- if (globalThis.__voiceGatewayActivated === true) {
300
- log.debug("Alfe Voice plugin already activated — skipping duplicate");
301
- return;
302
- }
303
- globalThis.__voiceGatewayActivated = true;
304
- log.info("Alfe Voice plugin activating...");
305
- voiceServiceUrl = pluginConfig.voiceServiceUrl ?? `http://localhost:${String(pluginConfig.voiceServicePort ?? "3100")}`;
306
- voiceServiceApiKey = pluginConfig.voiceServiceApiKey ?? "";
307
- log.info(`Voice service: ${voiceServiceUrl}`);
308
- let alfeConfig = null;
309
- try {
310
- alfeConfig = resolveConfig();
311
- } catch {}
312
- connectToDaemon(pluginConfig.daemonSocket ?? alfeConfig?.socketPath ?? DEFAULT_SOCKET_PATH, log).then((client) => {
313
- daemonIpcClient = client;
314
- }).catch((err) => {
315
- log.debug(`Daemon connect failed: ${err.message}`);
316
- });
317
- };
318
- const stopVoiceService = () => {
319
- globalThis.__voiceGatewayActivated = false;
320
- if (daemonIpcClient) {
321
- try {
322
- daemonIpcClient.stop();
323
- log.info("Disconnected from Alfe daemon");
324
- } catch (err) {
325
- log.debug(`Error disconnecting from daemon: ${err.message}`);
326
- }
327
- daemonIpcClient = null;
328
- }
329
- log.info("Alfe Voice plugin deactivated");
330
- };
331
- api.registerGatewayMethod("voice.speak", async (...args) => {
332
- const { text } = args[0];
333
- log.info(`voice.speak RPC → text=${text?.slice(0, 50) ?? "(none)"}...`);
334
- if (!text) throw new Error("voice.speak requires text");
335
- return await voiceApi("POST", "/voice/tts", { text });
336
- });
337
- log.info("Registered gateway RPC method: voice.speak");
338
- api.on("message_received", (...eventArgs) => {
339
- const event = eventArgs[0];
340
- if (eventArgs[1].channelId.includes("voice")) log.debug(`Voice-related message from ${event.from}: ${event.content.slice(0, 100)}`);
341
- });
342
- api.registerService({
343
- id: "alfe-voice-daemon",
344
- start: () => {
345
- startVoiceService();
346
- },
347
- stop: () => {
348
- stopVoiceService();
349
- }
350
- });
351
334
  log.info("Alfe Voice plugin activated");
352
335
  },
353
336
  deactivate(api) {
354
- globalThis.__voiceGatewayActivated = false;
355
337
  const log = api.logger;
356
338
  log.info("Alfe Voice plugin deactivating...");
357
- if (daemonIpcClient) {
358
- try {
359
- daemonIpcClient.stop();
360
- log.info("Disconnected from Alfe daemon");
361
- } catch (err) {
362
- log.debug(`Error disconnecting from daemon: ${err.message}`);
363
- }
364
- daemonIpcClient = null;
365
- }
339
+ voiceClient = null;
366
340
  log.info("Alfe Voice plugin deactivated");
367
341
  }
368
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
- "tools": ["voice_tts", "voice_stt", "voice_dtmf", "voice_hangup", "voice_transfer"]
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.14",
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,8 +28,9 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "@sinclair/typebox": "^0.34.48",
31
- "@alfe.ai/agent-api-client": "0.13.0",
32
- "@alfe.ai/config": "0.3.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"
33
34
  },
34
35
  "peerDependencies": {
35
36
  "openclaw": ">=2026.3.0"