@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 +5 -0
- package/dist/index.cjs +2 -2
- package/dist/index.js +1 -1
- package/dist/plugin.cjs +2 -220
- package/dist/plugin.js +1 -220
- package/dist/plugin2.cjs +374 -0
- package/dist/plugin2.js +369 -0
- package/openclaw.plugin.json +2 -2
- package/package.json +13 -2
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("./
|
|
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 "./
|
|
1
|
+
import { t as plugin } from "./plugin2.js";
|
|
2
2
|
export { plugin as default };
|
package/dist/plugin.cjs
CHANGED
|
@@ -1,220 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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 {
|
|
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 };
|
package/dist/plugin2.cjs
ADDED
|
@@ -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
|
+
});
|
package/dist/plugin2.js
ADDED
|
@@ -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 };
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "@alfe.ai/openclaw-voice",
|
|
3
3
|
"name": "Alfe Voice Plugin",
|
|
4
|
-
"description": "
|
|
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
|
|
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/
|
|
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",
|