@alfe.ai/openclaw-voice 0.0.17 → 0.1.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
@@ -47,3 +47,8 @@ pnpm --filter @alfe.ai/openclaw-voice test
47
47
 
48
48
  - **voice-service** — core voice logic (TTS, audio pipeline, Discord/Twilio adapters)
49
49
  - **@alfe.ai/openclaw** — OpenClaw runtime plugin API
50
+
51
+ ## Links
52
+
53
+ - 🌐 Website: <https://alfe.ai>
54
+ - 📚 Docs: <https://docs.alfe.ai>
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- const require_plugin = require("./plugin.cjs");
2
- module.exports = require_plugin;
1
+ const require_plugin = require("./plugin2.cjs");
2
+ module.exports = require_plugin.plugin;
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import plugin from "./plugin.js";
1
+ import { t as plugin } from "./plugin2.js";
2
2
  export { plugin as default };
package/dist/plugin.cjs CHANGED
@@ -1,220 +1,2 @@
1
- let _sinclair_typebox = require("@sinclair/typebox");
2
- let _alfe_ai_config = require("@alfe.ai/config");
3
- //#region src/plugin.ts
4
- /**
5
- * @alfe/voice-plugin — OpenClaw native plugin
6
- *
7
- * Thin client that registers voice tools with OpenClaw and forwards
8
- * TTS/STT operations to the voice service's public API.
9
- *
10
- * In the new architecture, voice service is a channel-agnostic pipeline
11
- * brain. Channel-specific operations (hangup, transfer, DTMF) are handled
12
- * by the respective channel services (Discord, Twilio, etc.), not voice.
13
- *
14
- * This plugin provides:
15
- * - voice.speak RPC — converts text to audio via POST /voice/tts
16
- * - voice_hangup tool — placeholder (requires channel service)
17
- * - voice_transfer tool — placeholder (requires Twilio service)
18
- * - voice_dtmf tool �� placeholder (requires Twilio service)
19
- */
20
- const pkg = (0, require("node:module").createRequire)(require("url").pathToFileURL(__filename).href)("../package.json");
21
- const VOICE_CAPABILITIES = [
22
- "voice.call",
23
- "voice.answer",
24
- "voice.dtmf",
25
- "voice.hangup"
26
- ];
27
- let voiceServiceUrl = "";
28
- let voiceServiceApiKey = "";
29
- async function voiceApi(method, path, body) {
30
- const url = `${voiceServiceUrl}${path}`;
31
- const headers = { "Content-Type": "application/json" };
32
- if (voiceServiceApiKey) headers["x-api-key"] = voiceServiceApiKey;
33
- const res = await fetch(url, {
34
- method,
35
- headers,
36
- body: body ? JSON.stringify(body) : void 0
37
- });
38
- const json = await res.json();
39
- if (!res.ok) {
40
- const errorMsg = typeof json.error === "string" ? json.error : `Voice service returned ${String(res.status)}`;
41
- throw new Error(errorMsg);
42
- }
43
- return json;
44
- }
45
- let daemonIpcClient = null;
46
- async function connectToDaemon(socketPath, log) {
47
- try {
48
- const IPCClientCtor = (await import("@alfe.ai/openclaw")).IPCClient;
49
- const client = new IPCClientCtor(socketPath, log);
50
- client.on("connected", async () => {
51
- log.info("Connected to Alfe daemon — registering voice capabilities...");
52
- const response = await client.request("capability.register", {
53
- plugin: "@alfe.ai/openclaw-voice",
54
- capabilities: [...VOICE_CAPABILITIES]
55
- });
56
- if (response.ok) log.info("Voice capabilities registered with daemon");
57
- else log.warn(`Failed to register voice capabilities: ${response.error?.message ?? "unknown"}`);
58
- });
59
- client.on("disconnected", (reason) => {
60
- log.warn(`Disconnected from Alfe daemon: ${String(reason)}`);
61
- });
62
- client.on("error", (err) => {
63
- log.debug(`Daemon IPC error: ${err.message}`);
64
- });
65
- client.start();
66
- return client;
67
- } catch {
68
- log.info("Alfe daemon not available — voice plugin running standalone");
69
- return null;
70
- }
71
- }
72
- function ok(data) {
73
- return {
74
- content: [{
75
- type: "text",
76
- text: JSON.stringify(data)
77
- }],
78
- details: data
79
- };
80
- }
81
- function errResult(message) {
82
- return {
83
- content: [{
84
- type: "text",
85
- text: JSON.stringify({ error: message })
86
- }],
87
- details: { error: message }
88
- };
89
- }
90
- function defineTool(def) {
91
- return {
92
- name: def.name,
93
- description: def.description,
94
- label: def.name,
95
- parameters: def.parameters,
96
- execute: async (_toolCallId, params) => {
97
- try {
98
- return ok(await def.handler(params));
99
- } catch (e) {
100
- return errResult(e.message);
101
- }
102
- }
103
- };
104
- }
105
- const voiceTools = [
106
- defineTool({
107
- name: "voice_hangup",
108
- 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.",
109
- parameters: _sinclair_typebox.Type.Object({ sessionId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Voice session ID." })) }),
110
- handler: () => {
111
- throw new Error("voice_hangup requires a channel service (Discord/Twilio). The voice service no longer manages channels directly.");
112
- }
113
- }),
114
- defineTool({
115
- name: "voice_transfer",
116
- description: "Transfer the current phone call to another number. Only works for Twilio calls.",
117
- parameters: _sinclair_typebox.Type.Object({
118
- targetNumber: _sinclair_typebox.Type.String({ description: "Phone number to transfer to (E.164 format)" }),
119
- sessionId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Voice session ID." }))
120
- }),
121
- handler: () => {
122
- throw new Error("voice_transfer requires the Twilio service. The voice service no longer manages phone calls directly.");
123
- }
124
- }),
125
- defineTool({
126
- name: "voice_dtmf",
127
- description: "Send DTMF tones (dial pad digits) on the current phone call. Only works for Twilio calls.",
128
- parameters: _sinclair_typebox.Type.Object({
129
- digits: _sinclair_typebox.Type.String({ description: "DTMF digits to send (0-9, *, #, w for pause)" }),
130
- sessionId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Voice session ID." }))
131
- }),
132
- handler: () => {
133
- throw new Error("voice_dtmf requires the Twilio service. The voice service no longer manages phone calls directly.");
134
- }
135
- })
136
- ];
137
- const plugin = {
138
- id: "@alfe.ai/openclaw-voice",
139
- name: "Alfe Voice Plugin",
140
- description: "Voice integration — TTS/STT via the voice service, channel-specific operations via channel services",
141
- version: pkg.version,
142
- activate(api) {
143
- const log = api.logger;
144
- for (const tool of voiceTools) api.registerTool(tool);
145
- log.info(`Registered ${String(voiceTools.length)} voice tools: ${voiceTools.map((t) => t.name).join(", ")}`);
146
- const fullConfig = api.config ?? {};
147
- const pluginConfig = fullConfig.plugins?.entries?.["@alfe.ai/openclaw-voice"]?.config ?? fullConfig.plugins?.entries?.["voice-gateway"]?.config ?? {};
148
- const startVoiceService = () => {
149
- if (globalThis.__voiceGatewayActivated === true) {
150
- log.debug("Alfe Voice plugin already activated — skipping duplicate");
151
- return;
152
- }
153
- globalThis.__voiceGatewayActivated = true;
154
- log.info("Alfe Voice plugin activating...");
155
- voiceServiceUrl = pluginConfig.voiceServiceUrl ?? `http://localhost:${String(pluginConfig.voiceServicePort ?? "3100")}`;
156
- voiceServiceApiKey = pluginConfig.voiceServiceApiKey ?? "";
157
- log.info(`Voice service: ${voiceServiceUrl}`);
158
- let alfeConfig = null;
159
- try {
160
- alfeConfig = (0, _alfe_ai_config.resolveConfig)();
161
- } catch {}
162
- connectToDaemon(pluginConfig.daemonSocket ?? alfeConfig?.socketPath ?? _alfe_ai_config.DEFAULT_SOCKET_PATH, log).then((client) => {
163
- daemonIpcClient = client;
164
- }).catch((err) => {
165
- log.debug(`Daemon connect failed: ${err.message}`);
166
- });
167
- };
168
- const stopVoiceService = () => {
169
- globalThis.__voiceGatewayActivated = false;
170
- if (daemonIpcClient) {
171
- try {
172
- daemonIpcClient.stop();
173
- log.info("Disconnected from Alfe daemon");
174
- } catch (err) {
175
- log.debug(`Error disconnecting from daemon: ${err.message}`);
176
- }
177
- daemonIpcClient = null;
178
- }
179
- log.info("Alfe Voice plugin deactivated");
180
- };
181
- api.registerGatewayMethod("voice.speak", async (...args) => {
182
- const { text } = args[0];
183
- log.info(`voice.speak RPC → text=${text?.slice(0, 50) ?? "(none)"}...`);
184
- if (!text) throw new Error("voice.speak requires text");
185
- return await voiceApi("POST", "/voice/tts", { text });
186
- });
187
- log.info("Registered gateway RPC method: voice.speak");
188
- api.on("message_received", (...eventArgs) => {
189
- const event = eventArgs[0];
190
- if (eventArgs[1].channelId.includes("voice")) log.debug(`Voice-related message from ${event.from}: ${event.content.slice(0, 100)}`);
191
- });
192
- api.registerService({
193
- id: "alfe-voice-daemon",
194
- start: () => {
195
- startVoiceService();
196
- },
197
- stop: () => {
198
- stopVoiceService();
199
- }
200
- });
201
- log.info("Alfe Voice plugin activated");
202
- },
203
- deactivate(api) {
204
- globalThis.__voiceGatewayActivated = false;
205
- const log = api.logger;
206
- log.info("Alfe Voice plugin deactivating...");
207
- if (daemonIpcClient) {
208
- try {
209
- daemonIpcClient.stop();
210
- log.info("Disconnected from Alfe daemon");
211
- } catch (err) {
212
- log.debug(`Error disconnecting from daemon: ${err.message}`);
213
- }
214
- daemonIpcClient = null;
215
- }
216
- log.info("Alfe Voice plugin deactivated");
217
- }
218
- };
219
- //#endregion
220
- module.exports = plugin;
1
+ const require_plugin = require("./plugin2.cjs");
2
+ module.exports = require_plugin.plugin;
package/dist/plugin.js CHANGED
@@ -1,221 +1,2 @@
1
- import { createRequire } from "node:module";
2
- import { Type } from "@sinclair/typebox";
3
- import { DEFAULT_SOCKET_PATH, resolveConfig } from "@alfe.ai/config";
4
- //#region src/plugin.ts
5
- /**
6
- * @alfe/voice-plugin — OpenClaw native plugin
7
- *
8
- * Thin client that registers voice tools with OpenClaw and forwards
9
- * TTS/STT operations to the voice service's public API.
10
- *
11
- * In the new architecture, voice service is a channel-agnostic pipeline
12
- * brain. Channel-specific operations (hangup, transfer, DTMF) are handled
13
- * by the respective channel services (Discord, Twilio, etc.), not voice.
14
- *
15
- * This plugin provides:
16
- * - voice.speak RPC — converts text to audio via POST /voice/tts
17
- * - voice_hangup tool — placeholder (requires channel service)
18
- * - voice_transfer tool — placeholder (requires Twilio service)
19
- * - voice_dtmf tool �� placeholder (requires Twilio service)
20
- */
21
- const pkg = createRequire(import.meta.url)("../package.json");
22
- const VOICE_CAPABILITIES = [
23
- "voice.call",
24
- "voice.answer",
25
- "voice.dtmf",
26
- "voice.hangup"
27
- ];
28
- let voiceServiceUrl = "";
29
- let voiceServiceApiKey = "";
30
- async function voiceApi(method, path, body) {
31
- const url = `${voiceServiceUrl}${path}`;
32
- const headers = { "Content-Type": "application/json" };
33
- if (voiceServiceApiKey) headers["x-api-key"] = voiceServiceApiKey;
34
- const res = await fetch(url, {
35
- method,
36
- headers,
37
- body: body ? JSON.stringify(body) : void 0
38
- });
39
- const json = await res.json();
40
- if (!res.ok) {
41
- const errorMsg = typeof json.error === "string" ? json.error : `Voice service returned ${String(res.status)}`;
42
- throw new Error(errorMsg);
43
- }
44
- return json;
45
- }
46
- let daemonIpcClient = null;
47
- async function connectToDaemon(socketPath, log) {
48
- try {
49
- const IPCClientCtor = (await import("@alfe.ai/openclaw")).IPCClient;
50
- const client = new IPCClientCtor(socketPath, log);
51
- client.on("connected", async () => {
52
- log.info("Connected to Alfe daemon — registering voice capabilities...");
53
- const response = await client.request("capability.register", {
54
- plugin: "@alfe.ai/openclaw-voice",
55
- capabilities: [...VOICE_CAPABILITIES]
56
- });
57
- if (response.ok) log.info("Voice capabilities registered with daemon");
58
- else log.warn(`Failed to register voice capabilities: ${response.error?.message ?? "unknown"}`);
59
- });
60
- client.on("disconnected", (reason) => {
61
- log.warn(`Disconnected from Alfe daemon: ${String(reason)}`);
62
- });
63
- client.on("error", (err) => {
64
- log.debug(`Daemon IPC error: ${err.message}`);
65
- });
66
- client.start();
67
- return client;
68
- } catch {
69
- log.info("Alfe daemon not available — voice plugin running standalone");
70
- return null;
71
- }
72
- }
73
- function ok(data) {
74
- return {
75
- content: [{
76
- type: "text",
77
- text: JSON.stringify(data)
78
- }],
79
- details: data
80
- };
81
- }
82
- function errResult(message) {
83
- return {
84
- content: [{
85
- type: "text",
86
- text: JSON.stringify({ error: message })
87
- }],
88
- details: { error: message }
89
- };
90
- }
91
- function defineTool(def) {
92
- return {
93
- name: def.name,
94
- description: def.description,
95
- label: def.name,
96
- parameters: def.parameters,
97
- execute: async (_toolCallId, params) => {
98
- try {
99
- return ok(await def.handler(params));
100
- } catch (e) {
101
- return errResult(e.message);
102
- }
103
- }
104
- };
105
- }
106
- const voiceTools = [
107
- defineTool({
108
- name: "voice_hangup",
109
- 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.",
110
- parameters: Type.Object({ sessionId: Type.Optional(Type.String({ description: "Voice session ID." })) }),
111
- handler: () => {
112
- throw new Error("voice_hangup requires a channel service (Discord/Twilio). The voice service no longer manages channels directly.");
113
- }
114
- }),
115
- defineTool({
116
- name: "voice_transfer",
117
- description: "Transfer the current phone call to another number. Only works for Twilio calls.",
118
- parameters: Type.Object({
119
- targetNumber: Type.String({ description: "Phone number to transfer to (E.164 format)" }),
120
- sessionId: Type.Optional(Type.String({ description: "Voice session ID." }))
121
- }),
122
- handler: () => {
123
- throw new Error("voice_transfer requires the Twilio service. The voice service no longer manages phone calls directly.");
124
- }
125
- }),
126
- defineTool({
127
- name: "voice_dtmf",
128
- description: "Send DTMF tones (dial pad digits) on the current phone call. Only works for Twilio calls.",
129
- parameters: Type.Object({
130
- digits: Type.String({ description: "DTMF digits to send (0-9, *, #, w for pause)" }),
131
- sessionId: Type.Optional(Type.String({ description: "Voice session ID." }))
132
- }),
133
- handler: () => {
134
- throw new Error("voice_dtmf requires the Twilio service. The voice service no longer manages phone calls directly.");
135
- }
136
- })
137
- ];
138
- const plugin = {
139
- id: "@alfe.ai/openclaw-voice",
140
- name: "Alfe Voice Plugin",
141
- description: "Voice integration — TTS/STT via the voice service, channel-specific operations via channel services",
142
- version: pkg.version,
143
- activate(api) {
144
- const log = api.logger;
145
- for (const tool of voiceTools) api.registerTool(tool);
146
- log.info(`Registered ${String(voiceTools.length)} voice tools: ${voiceTools.map((t) => t.name).join(", ")}`);
147
- const fullConfig = api.config ?? {};
148
- const pluginConfig = fullConfig.plugins?.entries?.["@alfe.ai/openclaw-voice"]?.config ?? fullConfig.plugins?.entries?.["voice-gateway"]?.config ?? {};
149
- const startVoiceService = () => {
150
- if (globalThis.__voiceGatewayActivated === true) {
151
- log.debug("Alfe Voice plugin already activated — skipping duplicate");
152
- return;
153
- }
154
- globalThis.__voiceGatewayActivated = true;
155
- log.info("Alfe Voice plugin activating...");
156
- voiceServiceUrl = pluginConfig.voiceServiceUrl ?? `http://localhost:${String(pluginConfig.voiceServicePort ?? "3100")}`;
157
- voiceServiceApiKey = pluginConfig.voiceServiceApiKey ?? "";
158
- log.info(`Voice service: ${voiceServiceUrl}`);
159
- let alfeConfig = null;
160
- try {
161
- alfeConfig = resolveConfig();
162
- } catch {}
163
- connectToDaemon(pluginConfig.daemonSocket ?? alfeConfig?.socketPath ?? DEFAULT_SOCKET_PATH, log).then((client) => {
164
- daemonIpcClient = client;
165
- }).catch((err) => {
166
- log.debug(`Daemon connect failed: ${err.message}`);
167
- });
168
- };
169
- const stopVoiceService = () => {
170
- globalThis.__voiceGatewayActivated = false;
171
- if (daemonIpcClient) {
172
- try {
173
- daemonIpcClient.stop();
174
- log.info("Disconnected from Alfe daemon");
175
- } catch (err) {
176
- log.debug(`Error disconnecting from daemon: ${err.message}`);
177
- }
178
- daemonIpcClient = null;
179
- }
180
- log.info("Alfe Voice plugin deactivated");
181
- };
182
- api.registerGatewayMethod("voice.speak", async (...args) => {
183
- const { text } = args[0];
184
- log.info(`voice.speak RPC → text=${text?.slice(0, 50) ?? "(none)"}...`);
185
- if (!text) throw new Error("voice.speak requires text");
186
- return await voiceApi("POST", "/voice/tts", { text });
187
- });
188
- log.info("Registered gateway RPC method: voice.speak");
189
- api.on("message_received", (...eventArgs) => {
190
- const event = eventArgs[0];
191
- if (eventArgs[1].channelId.includes("voice")) log.debug(`Voice-related message from ${event.from}: ${event.content.slice(0, 100)}`);
192
- });
193
- api.registerService({
194
- id: "alfe-voice-daemon",
195
- start: () => {
196
- startVoiceService();
197
- },
198
- stop: () => {
199
- stopVoiceService();
200
- }
201
- });
202
- log.info("Alfe Voice plugin activated");
203
- },
204
- deactivate(api) {
205
- globalThis.__voiceGatewayActivated = false;
206
- const log = api.logger;
207
- log.info("Alfe Voice plugin deactivating...");
208
- if (daemonIpcClient) {
209
- try {
210
- daemonIpcClient.stop();
211
- log.info("Disconnected from Alfe daemon");
212
- } catch (err) {
213
- log.debug(`Error disconnecting from daemon: ${err.message}`);
214
- }
215
- daemonIpcClient = null;
216
- }
217
- log.info("Alfe Voice plugin deactivated");
218
- }
219
- };
220
- //#endregion
1
+ import { t as plugin } from "./plugin2.js";
221
2
  export { plugin as default };
@@ -0,0 +1,374 @@
1
+ let _sinclair_typebox = require("@sinclair/typebox");
2
+ let _alfe_ai_config = require("@alfe.ai/config");
3
+ let _alfe_ai_agent_api_client = require("@alfe.ai/agent-api-client");
4
+ let node_fs_promises = require("node:fs/promises");
5
+ let node_path = require("node:path");
6
+ let node_module = require("node:module");
7
+ //#region src/audio.ts
8
+ /** Prepend a canonical 44-byte PCM WAV header to raw PCM samples. */
9
+ function pcmToWav(pcm, framing) {
10
+ const { sampleRate, channels, bitDepth } = framing;
11
+ const blockAlign = channels * (bitDepth / 8);
12
+ const byteRate = sampleRate * blockAlign;
13
+ const header = Buffer.alloc(44);
14
+ header.write("RIFF", 0, "ascii");
15
+ header.writeUInt32LE(36 + pcm.length, 4);
16
+ header.write("WAVE", 8, "ascii");
17
+ header.write("fmt ", 12, "ascii");
18
+ header.writeUInt32LE(16, 16);
19
+ header.writeUInt16LE(1, 20);
20
+ header.writeUInt16LE(channels, 22);
21
+ header.writeUInt32LE(sampleRate, 24);
22
+ header.writeUInt32LE(byteRate, 28);
23
+ header.writeUInt16LE(blockAlign, 32);
24
+ header.writeUInt16LE(bitDepth, 34);
25
+ header.write("data", 36, "ascii");
26
+ header.writeUInt32LE(pcm.length, 40);
27
+ return Buffer.concat([header, pcm]);
28
+ }
29
+ /**
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.
34
+ */
35
+ 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;
39
+ let sampleRate = 0;
40
+ let channels = 0;
41
+ let bitDepth = 0;
42
+ let pcm = null;
43
+ let offset = 12;
44
+ while (offset + 8 <= buf.length) {
45
+ const chunkId = buf.toString("ascii", offset, offset + 4);
46
+ const chunkSize = buf.readUInt32LE(offset + 4);
47
+ const bodyStart = offset + 8;
48
+ if (chunkId === "fmt " && bodyStart + 16 <= buf.length) {
49
+ channels = buf.readUInt16LE(bodyStart + 2);
50
+ sampleRate = buf.readUInt32LE(bodyStart + 4);
51
+ bitDepth = buf.readUInt16LE(bodyStart + 14);
52
+ } else if (chunkId === "data") {
53
+ const end = Math.min(bodyStart + chunkSize, buf.length);
54
+ pcm = buf.subarray(bodyStart, end);
55
+ }
56
+ offset = bodyStart + chunkSize + chunkSize % 2;
57
+ }
58
+ if (!pcm || sampleRate === 0 || channels === 0 || bitDepth === 0) return null;
59
+ return {
60
+ pcm,
61
+ sampleRate,
62
+ channels,
63
+ bitDepth
64
+ };
65
+ }
66
+ //#endregion
67
+ //#region src/plugin.ts
68
+ /**
69
+ * @alfe/voice-plugin — OpenClaw native plugin
70
+ *
71
+ * Thin client that registers voice tools with OpenClaw and forwards
72
+ * TTS/STT operations to the voice service's public API.
73
+ *
74
+ * In the new architecture, voice service is a channel-agnostic pipeline
75
+ * brain. Channel-specific operations (hangup, transfer, DTMF) are handled
76
+ * by the respective channel services (Discord, Twilio, etc.), not voice.
77
+ *
78
+ * This plugin provides:
79
+ * - voice_tts tool — text → audio file via POST /voice/tts (agent-authed)
80
+ * - 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)
85
+ *
86
+ * The voice_tts/voice_stt tools source URL + agent key from ~/.alfe/config.toml
87
+ * via `@alfe.ai/config` (resolveConfig) and call through `AgentApiClient` — the
88
+ * sanctioned client for these plugins. They do NOT read OpenClaw plugin config
89
+ * or hit localhost.
90
+ */
91
+ const pkg = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("../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
+ }
176
+ let voiceClient = null;
177
+ function getVoiceClient() {
178
+ if (voiceClient) return voiceClient;
179
+ const config = (0, _alfe_ai_config.resolveConfig)();
180
+ voiceClient = new _alfe_ai_agent_api_client.AgentApiClient({
181
+ apiUrl: config.voiceServiceUrl,
182
+ apiKey: config.apiKey
183
+ });
184
+ return voiceClient;
185
+ }
186
+ /** Resolve a tool-supplied path against the agent's working directory. */
187
+ function resolveWorkspacePath(p) {
188
+ return (0, node_path.isAbsolute)(p) ? p : (0, node_path.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: _sinclair_typebox.Type.Object({
195
+ text: _sinclair_typebox.Type.String({ description: "Text to synthesize (1–5000 characters)." }),
196
+ 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." })),
197
+ 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." })),
198
+ voiceId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "ElevenLabs voice ID. Platform default when omitted." })),
199
+ 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." }))
200
+ }),
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 (0, node_fs_promises.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: _sinclair_typebox.Type.Object({
233
+ path: _sinclair_typebox.Type.String({ description: "Path to the audio file (absolute, or relative to the working directory). WAV or raw linear16 mono PCM." }),
234
+ 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." }))
235
+ }),
236
+ handler: async (params) => {
237
+ const raw = await (0, node_fs_promises.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
+ }
254
+ }),
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: _sinclair_typebox.Type.Object({ sessionId: _sinclair_typebox.Type.Optional(_sinclair_typebox.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: _sinclair_typebox.Type.Object({
267
+ targetNumber: _sinclair_typebox.Type.String({ description: "Phone number to transfer to (E.164 format)" }),
268
+ sessionId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Voice session ID." }))
269
+ }),
270
+ handler: () => {
271
+ throw new Error("voice_transfer requires the Twilio service. The voice service no longer manages phone calls directly.");
272
+ }
273
+ }),
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: _sinclair_typebox.Type.Object({
278
+ digits: _sinclair_typebox.Type.String({ description: "DTMF digits to send (0-9, *, #, w for pause)" }),
279
+ sessionId: _sinclair_typebox.Type.Optional(_sinclair_typebox.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.");
283
+ }
284
+ })
285
+ ];
286
+ const plugin = {
287
+ id: "@alfe.ai/openclaw-voice",
288
+ name: "Alfe Voice Plugin",
289
+ description: "Voice integration — TTS/STT via the voice service, channel-specific operations via channel services",
290
+ version: pkg.version,
291
+ activate(api) {
292
+ const log = api.logger;
293
+ for (const tool of voiceTools) api.registerTool(tool);
294
+ log.info(`Registered ${String(voiceTools.length)} voice tools: ${voiceTools.map((t) => t.name).join(", ")}`);
295
+ const fullConfig = api.config ?? {};
296
+ const pluginConfig = fullConfig.plugins?.entries?.["@alfe.ai/openclaw-voice"]?.config ?? fullConfig.plugins?.entries?.["voice-gateway"]?.config ?? {};
297
+ const startVoiceService = () => {
298
+ if (globalThis.__voiceGatewayActivated === true) {
299
+ log.debug("Alfe Voice plugin already activated — skipping duplicate");
300
+ return;
301
+ }
302
+ globalThis.__voiceGatewayActivated = true;
303
+ log.info("Alfe Voice plugin activating...");
304
+ voiceServiceUrl = pluginConfig.voiceServiceUrl ?? `http://localhost:${String(pluginConfig.voiceServicePort ?? "3100")}`;
305
+ voiceServiceApiKey = pluginConfig.voiceServiceApiKey ?? "";
306
+ log.info(`Voice service: ${voiceServiceUrl}`);
307
+ let alfeConfig = null;
308
+ try {
309
+ alfeConfig = (0, _alfe_ai_config.resolveConfig)();
310
+ } catch {}
311
+ connectToDaemon(pluginConfig.daemonSocket ?? alfeConfig?.socketPath ?? _alfe_ai_config.DEFAULT_SOCKET_PATH, log).then((client) => {
312
+ daemonIpcClient = client;
313
+ }).catch((err) => {
314
+ log.debug(`Daemon connect failed: ${err.message}`);
315
+ });
316
+ };
317
+ const stopVoiceService = () => {
318
+ globalThis.__voiceGatewayActivated = false;
319
+ if (daemonIpcClient) {
320
+ try {
321
+ daemonIpcClient.stop();
322
+ log.info("Disconnected from Alfe daemon");
323
+ } catch (err) {
324
+ log.debug(`Error disconnecting from daemon: ${err.message}`);
325
+ }
326
+ daemonIpcClient = null;
327
+ }
328
+ log.info("Alfe Voice plugin deactivated");
329
+ };
330
+ api.registerGatewayMethod("voice.speak", async (...args) => {
331
+ const { text } = args[0];
332
+ log.info(`voice.speak RPC → text=${text?.slice(0, 50) ?? "(none)"}...`);
333
+ if (!text) throw new Error("voice.speak requires text");
334
+ return await voiceApi("POST", "/voice/tts", { text });
335
+ });
336
+ log.info("Registered gateway RPC method: voice.speak");
337
+ api.on("message_received", (...eventArgs) => {
338
+ const event = eventArgs[0];
339
+ if (eventArgs[1].channelId.includes("voice")) log.debug(`Voice-related message from ${event.from}: ${event.content.slice(0, 100)}`);
340
+ });
341
+ api.registerService({
342
+ id: "alfe-voice-daemon",
343
+ start: () => {
344
+ startVoiceService();
345
+ },
346
+ stop: () => {
347
+ stopVoiceService();
348
+ }
349
+ });
350
+ log.info("Alfe Voice plugin activated");
351
+ },
352
+ deactivate(api) {
353
+ globalThis.__voiceGatewayActivated = false;
354
+ const log = api.logger;
355
+ log.info("Alfe Voice plugin deactivating...");
356
+ if (daemonIpcClient) {
357
+ try {
358
+ daemonIpcClient.stop();
359
+ log.info("Disconnected from Alfe daemon");
360
+ } catch (err) {
361
+ log.debug(`Error disconnecting from daemon: ${err.message}`);
362
+ }
363
+ daemonIpcClient = null;
364
+ }
365
+ log.info("Alfe Voice plugin deactivated");
366
+ }
367
+ };
368
+ //#endregion
369
+ Object.defineProperty(exports, "plugin", {
370
+ enumerable: true,
371
+ get: function() {
372
+ return plugin;
373
+ }
374
+ });
@@ -0,0 +1,369 @@
1
+ import { createRequire } from "node:module";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { DEFAULT_SOCKET_PATH, resolveConfig } from "@alfe.ai/config";
4
+ import { AgentApiClient } from "@alfe.ai/agent-api-client";
5
+ import { readFile, writeFile } from "node:fs/promises";
6
+ import { isAbsolute, resolve } from "node:path";
7
+ //#region src/audio.ts
8
+ /** Prepend a canonical 44-byte PCM WAV header to raw PCM samples. */
9
+ function pcmToWav(pcm, framing) {
10
+ const { sampleRate, channels, bitDepth } = framing;
11
+ const blockAlign = channels * (bitDepth / 8);
12
+ const byteRate = sampleRate * blockAlign;
13
+ const header = Buffer.alloc(44);
14
+ header.write("RIFF", 0, "ascii");
15
+ header.writeUInt32LE(36 + pcm.length, 4);
16
+ header.write("WAVE", 8, "ascii");
17
+ header.write("fmt ", 12, "ascii");
18
+ header.writeUInt32LE(16, 16);
19
+ header.writeUInt16LE(1, 20);
20
+ header.writeUInt16LE(channels, 22);
21
+ header.writeUInt32LE(sampleRate, 24);
22
+ header.writeUInt32LE(byteRate, 28);
23
+ header.writeUInt16LE(blockAlign, 32);
24
+ header.writeUInt16LE(bitDepth, 34);
25
+ header.write("data", 36, "ascii");
26
+ header.writeUInt32LE(pcm.length, 40);
27
+ return Buffer.concat([header, pcm]);
28
+ }
29
+ /**
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.
34
+ */
35
+ 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;
39
+ let sampleRate = 0;
40
+ let channels = 0;
41
+ let bitDepth = 0;
42
+ let pcm = null;
43
+ let offset = 12;
44
+ while (offset + 8 <= buf.length) {
45
+ const chunkId = buf.toString("ascii", offset, offset + 4);
46
+ const chunkSize = buf.readUInt32LE(offset + 4);
47
+ const bodyStart = offset + 8;
48
+ if (chunkId === "fmt " && bodyStart + 16 <= buf.length) {
49
+ channels = buf.readUInt16LE(bodyStart + 2);
50
+ sampleRate = buf.readUInt32LE(bodyStart + 4);
51
+ bitDepth = buf.readUInt16LE(bodyStart + 14);
52
+ } else if (chunkId === "data") {
53
+ const end = Math.min(bodyStart + chunkSize, buf.length);
54
+ pcm = buf.subarray(bodyStart, end);
55
+ }
56
+ offset = bodyStart + chunkSize + chunkSize % 2;
57
+ }
58
+ if (!pcm || sampleRate === 0 || channels === 0 || bitDepth === 0) return null;
59
+ return {
60
+ pcm,
61
+ sampleRate,
62
+ channels,
63
+ bitDepth
64
+ };
65
+ }
66
+ //#endregion
67
+ //#region src/plugin.ts
68
+ /**
69
+ * @alfe/voice-plugin — OpenClaw native plugin
70
+ *
71
+ * Thin client that registers voice tools with OpenClaw and forwards
72
+ * TTS/STT operations to the voice service's public API.
73
+ *
74
+ * In the new architecture, voice service is a channel-agnostic pipeline
75
+ * brain. Channel-specific operations (hangup, transfer, DTMF) are handled
76
+ * by the respective channel services (Discord, Twilio, etc.), not voice.
77
+ *
78
+ * This plugin provides:
79
+ * - voice_tts tool — text → audio file via POST /voice/tts (agent-authed)
80
+ * - 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)
85
+ *
86
+ * The voice_tts/voice_stt tools source URL + agent key from ~/.alfe/config.toml
87
+ * via `@alfe.ai/config` (resolveConfig) and call through `AgentApiClient` — the
88
+ * sanctioned client for these plugins. They do NOT read OpenClaw plugin config
89
+ * or hit localhost.
90
+ */
91
+ 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
+ }
176
+ let voiceClient = null;
177
+ function getVoiceClient() {
178
+ if (voiceClient) return voiceClient;
179
+ const config = resolveConfig();
180
+ voiceClient = new AgentApiClient({
181
+ apiUrl: config.voiceServiceUrl,
182
+ apiKey: config.apiKey
183
+ });
184
+ return voiceClient;
185
+ }
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." }))
200
+ }),
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
+ }
254
+ }),
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." }))
269
+ }),
270
+ handler: () => {
271
+ throw new Error("voice_transfer requires the Twilio service. The voice service no longer manages phone calls directly.");
272
+ }
273
+ }),
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.");
283
+ }
284
+ })
285
+ ];
286
+ const plugin = {
287
+ id: "@alfe.ai/openclaw-voice",
288
+ name: "Alfe Voice Plugin",
289
+ description: "Voice integration — TTS/STT via the voice service, channel-specific operations via channel services",
290
+ version: pkg.version,
291
+ activate(api) {
292
+ const log = api.logger;
293
+ for (const tool of voiceTools) api.registerTool(tool);
294
+ log.info(`Registered ${String(voiceTools.length)} voice tools: ${voiceTools.map((t) => t.name).join(", ")}`);
295
+ const fullConfig = api.config ?? {};
296
+ const pluginConfig = fullConfig.plugins?.entries?.["@alfe.ai/openclaw-voice"]?.config ?? fullConfig.plugins?.entries?.["voice-gateway"]?.config ?? {};
297
+ const startVoiceService = () => {
298
+ if (globalThis.__voiceGatewayActivated === true) {
299
+ log.debug("Alfe Voice plugin already activated — skipping duplicate");
300
+ return;
301
+ }
302
+ globalThis.__voiceGatewayActivated = true;
303
+ log.info("Alfe Voice plugin activating...");
304
+ voiceServiceUrl = pluginConfig.voiceServiceUrl ?? `http://localhost:${String(pluginConfig.voiceServicePort ?? "3100")}`;
305
+ voiceServiceApiKey = pluginConfig.voiceServiceApiKey ?? "";
306
+ log.info(`Voice service: ${voiceServiceUrl}`);
307
+ let alfeConfig = null;
308
+ try {
309
+ alfeConfig = resolveConfig();
310
+ } catch {}
311
+ connectToDaemon(pluginConfig.daemonSocket ?? alfeConfig?.socketPath ?? DEFAULT_SOCKET_PATH, log).then((client) => {
312
+ daemonIpcClient = client;
313
+ }).catch((err) => {
314
+ log.debug(`Daemon connect failed: ${err.message}`);
315
+ });
316
+ };
317
+ const stopVoiceService = () => {
318
+ globalThis.__voiceGatewayActivated = false;
319
+ if (daemonIpcClient) {
320
+ try {
321
+ daemonIpcClient.stop();
322
+ log.info("Disconnected from Alfe daemon");
323
+ } catch (err) {
324
+ log.debug(`Error disconnecting from daemon: ${err.message}`);
325
+ }
326
+ daemonIpcClient = null;
327
+ }
328
+ log.info("Alfe Voice plugin deactivated");
329
+ };
330
+ api.registerGatewayMethod("voice.speak", async (...args) => {
331
+ const { text } = args[0];
332
+ log.info(`voice.speak RPC → text=${text?.slice(0, 50) ?? "(none)"}...`);
333
+ if (!text) throw new Error("voice.speak requires text");
334
+ return await voiceApi("POST", "/voice/tts", { text });
335
+ });
336
+ log.info("Registered gateway RPC method: voice.speak");
337
+ api.on("message_received", (...eventArgs) => {
338
+ const event = eventArgs[0];
339
+ if (eventArgs[1].channelId.includes("voice")) log.debug(`Voice-related message from ${event.from}: ${event.content.slice(0, 100)}`);
340
+ });
341
+ api.registerService({
342
+ id: "alfe-voice-daemon",
343
+ start: () => {
344
+ startVoiceService();
345
+ },
346
+ stop: () => {
347
+ stopVoiceService();
348
+ }
349
+ });
350
+ log.info("Alfe Voice plugin activated");
351
+ },
352
+ deactivate(api) {
353
+ globalThis.__voiceGatewayActivated = false;
354
+ const log = api.logger;
355
+ log.info("Alfe Voice plugin deactivating...");
356
+ if (daemonIpcClient) {
357
+ try {
358
+ daemonIpcClient.stop();
359
+ log.info("Disconnected from Alfe daemon");
360
+ } catch (err) {
361
+ log.debug(`Error disconnecting from daemon: ${err.message}`);
362
+ }
363
+ daemonIpcClient = null;
364
+ }
365
+ log.info("Alfe Voice plugin deactivated");
366
+ }
367
+ };
368
+ //#endregion
369
+ export { plugin as t };
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "id": "@alfe.ai/openclaw-voice",
3
3
  "name": "Alfe Voice Plugin",
4
- "description": "Pure relay plugin — connects to Fly voice server via WebSocket. All audio processing (STT, TTS, ack) runs on Fly.",
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
6
  "activation": { "onStartup": true },
7
7
  "contracts": {
8
- "tools": ["voice_dtmf", "voice_hangup", "voice_transfer"]
8
+ "tools": ["voice_tts", "voice_stt", "voice_dtmf", "voice_hangup", "voice_transfer"]
9
9
  },
10
10
  "configSchema": {
11
11
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-voice",
3
- "version": "0.0.17",
3
+ "version": "0.1.0",
4
4
  "description": "OpenClaw voice plugin for Alfe — Discord audio, Twilio, Recall.ai",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -28,7 +28,8 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "@sinclair/typebox": "^0.34.48",
31
- "@alfe.ai/config": "0.1.0"
31
+ "@alfe.ai/agent-api-client": "0.4.0",
32
+ "@alfe.ai/config": "0.2.0"
32
33
  },
33
34
  "peerDependencies": {
34
35
  "openclaw": ">=2026.3.0"
@@ -38,6 +39,16 @@
38
39
  "optional": true
39
40
  }
40
41
  },
42
+ "homepage": "https://alfe.ai",
43
+ "author": "Alfe (https://alfe.ai)",
44
+ "keywords": [
45
+ "alfe",
46
+ "ai-agents",
47
+ "agent",
48
+ "llm",
49
+ "openclaw",
50
+ "plugin"
51
+ ],
41
52
  "scripts": {
42
53
  "build": "tsdown",
43
54
  "dev": "tsdown --watch",