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