@alfe.ai/mcp-tools 0.2.4 → 0.2.6
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 +14 -1
- package/dist/index.cjs +242 -71
- package/dist/index.d.cts +1 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +243 -72
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { TextDecoder } from "node:util";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { lstat, open, realpath, unlink } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
6
|
//#region src/types.ts
|
|
4
7
|
function ok(data) {
|
|
5
8
|
return { content: [{
|
|
@@ -19,16 +22,34 @@ function err(message) {
|
|
|
19
22
|
//#endregion
|
|
20
23
|
//#region src/integrations.ts
|
|
21
24
|
const OAUTH_PROVIDERS = [
|
|
22
|
-
"
|
|
25
|
+
"atlassian",
|
|
26
|
+
"ctrader",
|
|
27
|
+
"discord",
|
|
28
|
+
"github",
|
|
23
29
|
"google",
|
|
24
|
-
"notion",
|
|
25
30
|
"microsoft",
|
|
26
|
-
"atlassian",
|
|
27
31
|
"myob",
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
32
|
+
"notion",
|
|
33
|
+
"salesforce",
|
|
34
|
+
"shopify",
|
|
35
|
+
"slack",
|
|
36
|
+
"x",
|
|
37
|
+
"xero"
|
|
31
38
|
];
|
|
39
|
+
function oauthStartUrl(ctx, provider, scopes, shop) {
|
|
40
|
+
if (!ctx.agentId || ctx.agentId.length > 256 || !ctx.tenantId || ctx.tenantId.length > 256) throw new Error("OAuth context is missing a valid agent or tenant identity.");
|
|
41
|
+
const url = new URL(ctx.apiUrl);
|
|
42
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error("OAuth API URL must be an HTTP(S) URL without embedded credentials.");
|
|
43
|
+
url.hash = "";
|
|
44
|
+
url.search = "";
|
|
45
|
+
url.pathname = `${url.pathname.replace(/\/+$/u, "")}/connect/oauth/start`;
|
|
46
|
+
url.searchParams.set("provider", provider);
|
|
47
|
+
url.searchParams.set("tenantId", ctx.tenantId);
|
|
48
|
+
url.searchParams.set("scope", `agent:${ctx.agentId}`);
|
|
49
|
+
if (scopes) url.searchParams.set("scopes", scopes);
|
|
50
|
+
if (shop) url.searchParams.set("shop", shop);
|
|
51
|
+
return url.toString();
|
|
52
|
+
}
|
|
32
53
|
/**
|
|
33
54
|
* Thin-slice subset of the `services/mcp` `integrations` domain — only
|
|
34
55
|
* the two subtools whose underlying calls already hit public
|
|
@@ -40,7 +61,7 @@ const OAUTH_PROVIDERS = [
|
|
|
40
61
|
* - `integrations_browse_registry` — public `GET /integrations/registry`
|
|
41
62
|
* via `AgentApiClient.getRegistry()`.
|
|
42
63
|
* - `integrations_start_oauth` — generates the public
|
|
43
|
-
* `GET
|
|
64
|
+
* universal `GET /connect/oauth/start` URL for the user to open in a browser.
|
|
44
65
|
* Pure URL builder; no server-side call. Completion is confirmed out of
|
|
45
66
|
* band (the dashboard, or the hosted MCP server's
|
|
46
67
|
* `integrations_check_oauth_status`) — the local server intentionally has
|
|
@@ -61,24 +82,124 @@ const registerIntegrationsTools = (server, ctx) => {
|
|
|
61
82
|
description: "Start an OAuth connection flow for an integration. Returns a URL the user must open in their browser to authorize the connection. After the user authorizes, the connection appears in the Alfe dashboard — this local server does not expose a status-check tool, so confirm completion there.",
|
|
62
83
|
inputSchema: {
|
|
63
84
|
provider: z.enum(OAUTH_PROVIDERS).describe("The OAuth provider to connect"),
|
|
64
|
-
scopes: z.string().optional().describe("Comma-separated scope groups (e.g. 'gmail,drive' for Google)")
|
|
85
|
+
scopes: z.string().trim().min(1).max(1024).optional().describe("Comma-separated scope groups (e.g. 'gmail,drive' for Google)"),
|
|
86
|
+
shop: z.string().trim().min(1).max(512).optional().describe("Shopify store handle or myshopify.com domain (required for Shopify only)")
|
|
87
|
+
}
|
|
88
|
+
}, ({ provider, scopes, shop }) => {
|
|
89
|
+
try {
|
|
90
|
+
if (provider === "shopify" && !shop) return Promise.resolve(err("Shopify OAuth requires the shop handle or myshopify.com domain."));
|
|
91
|
+
if (provider !== "shopify" && shop) return Promise.resolve(err("The shop argument is only valid for Shopify OAuth."));
|
|
92
|
+
const url = oauthStartUrl(ctx, provider, scopes, shop);
|
|
93
|
+
return Promise.resolve(ok({
|
|
94
|
+
url,
|
|
95
|
+
provider,
|
|
96
|
+
message: `Open this URL to connect ${provider}: ${url}`
|
|
97
|
+
}));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return Promise.resolve(err(error instanceof Error ? error.message : "Failed to generate OAuth URL"));
|
|
65
100
|
}
|
|
66
|
-
}, ({ provider, scopes }) => {
|
|
67
|
-
const params = new URLSearchParams({
|
|
68
|
-
agentId: ctx.agentId,
|
|
69
|
-
tenantId: ctx.tenantId
|
|
70
|
-
});
|
|
71
|
-
if (scopes) params.set("scopes", scopes);
|
|
72
|
-
const url = `${ctx.apiUrl}/${provider}/oauth/start?${params.toString()}`;
|
|
73
|
-
return Promise.resolve(ok({
|
|
74
|
-
url,
|
|
75
|
-
provider,
|
|
76
|
-
message: `Open this URL to connect ${provider}: ${url}`
|
|
77
|
-
}));
|
|
78
101
|
});
|
|
79
102
|
};
|
|
80
103
|
//#endregion
|
|
104
|
+
//#region src/workspace-files.ts
|
|
105
|
+
const MAX_TOOL_PATH_LENGTH = 1024;
|
|
106
|
+
function isWithin(root, candidate) {
|
|
107
|
+
const rel = relative(root, candidate);
|
|
108
|
+
return rel === "" || !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`);
|
|
109
|
+
}
|
|
110
|
+
function assertRelativeToolPath(input, label) {
|
|
111
|
+
if (input.length === 0) throw new Error(`${label} must not be empty.`);
|
|
112
|
+
if (input.length > MAX_TOOL_PATH_LENGTH) throw new Error(`${label} is too long (maximum ${String(MAX_TOOL_PATH_LENGTH)} characters).`);
|
|
113
|
+
if (input.includes("\0")) throw new Error(`${label} contains a null byte.`);
|
|
114
|
+
if (isAbsolute(input) || /^[A-Za-z]:[\\/]/u.test(input) || input.startsWith("\\\\")) throw new Error(`${label} must be relative to the workspace.`);
|
|
115
|
+
if (input.split(/[\\/]/u).includes("..")) throw new Error(`${label} must not contain parent-directory traversal.`);
|
|
116
|
+
}
|
|
117
|
+
async function workspaceRoot() {
|
|
118
|
+
return realpath(process.cwd());
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Read a regular file that resolves inside the current workspace.
|
|
122
|
+
*
|
|
123
|
+
* The lexical check rejects obvious traversal, while `realpath` closes the
|
|
124
|
+
* symlink escape that a simple `resolve(...).startsWith(...)` check leaves
|
|
125
|
+
* open. The descriptor is opened with `O_NOFOLLOW`, then size-checked both
|
|
126
|
+
* before and after the read so a growing file cannot bypass the byte cap.
|
|
127
|
+
*/
|
|
128
|
+
async function readWorkspaceFile(input, maxBytes) {
|
|
129
|
+
assertRelativeToolPath(input, "Path");
|
|
130
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error("Maximum file size must be a positive safe integer.");
|
|
131
|
+
const root = await workspaceRoot();
|
|
132
|
+
const lexicalPath = resolve(root, input);
|
|
133
|
+
if (!isWithin(root, lexicalPath)) throw new Error("Path escapes the workspace.");
|
|
134
|
+
const absolutePath = await realpath(lexicalPath);
|
|
135
|
+
if (!isWithin(root, absolutePath)) throw new Error("Path resolves outside the workspace.");
|
|
136
|
+
const handle = await open(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
137
|
+
try {
|
|
138
|
+
const before = await handle.stat();
|
|
139
|
+
if (!before.isFile()) throw new Error("Path must refer to a regular file.");
|
|
140
|
+
if (before.size > maxBytes) throw new Error(`File is too large (maximum ${String(maxBytes)} bytes).`);
|
|
141
|
+
const data = await handle.readFile();
|
|
142
|
+
const after = await handle.stat();
|
|
143
|
+
if (data.length > maxBytes || after.size > maxBytes) throw new Error(`File is too large (maximum ${String(maxBytes)} bytes).`);
|
|
144
|
+
return {
|
|
145
|
+
absolutePath,
|
|
146
|
+
data
|
|
147
|
+
};
|
|
148
|
+
} finally {
|
|
149
|
+
await handle.close();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Write a new file beneath the current workspace without following a parent
|
|
154
|
+
* symlink outside it and without replacing an existing file.
|
|
155
|
+
*/
|
|
156
|
+
async function resolveWorkspaceOutputPath(input) {
|
|
157
|
+
assertRelativeToolPath(input, "Output path");
|
|
158
|
+
const root = await workspaceRoot();
|
|
159
|
+
const lexicalPath = resolve(root, input);
|
|
160
|
+
if (!isWithin(root, lexicalPath)) throw new Error("Output path escapes the workspace.");
|
|
161
|
+
const parent = await realpath(dirname(lexicalPath));
|
|
162
|
+
if (!isWithin(root, parent)) throw new Error("Output path resolves outside the workspace.");
|
|
163
|
+
const absolutePath = join(parent, basename(lexicalPath));
|
|
164
|
+
try {
|
|
165
|
+
await lstat(absolutePath);
|
|
166
|
+
throw new Error(`Refusing to overwrite existing workspace file: ${input}`);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error.code !== "ENOENT") throw error;
|
|
169
|
+
}
|
|
170
|
+
return absolutePath;
|
|
171
|
+
}
|
|
172
|
+
async function writeWorkspaceFileExclusive(input, data) {
|
|
173
|
+
const absolutePath = await resolveWorkspaceOutputPath(input);
|
|
174
|
+
let handle;
|
|
175
|
+
try {
|
|
176
|
+
handle = await open(absolutePath, "wx", 384);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error.code === "EEXIST") throw new Error(`Refusing to overwrite existing workspace file: ${input}`);
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
let completed = false;
|
|
182
|
+
try {
|
|
183
|
+
await handle.writeFile(data);
|
|
184
|
+
await handle.sync();
|
|
185
|
+
completed = true;
|
|
186
|
+
return absolutePath;
|
|
187
|
+
} finally {
|
|
188
|
+
await handle.close();
|
|
189
|
+
if (!completed) await unlink(absolutePath).catch(() => void 0);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
//#endregion
|
|
81
193
|
//#region src/memory.ts
|
|
194
|
+
const MEMORY_TAGS = [
|
|
195
|
+
"fact",
|
|
196
|
+
"decision",
|
|
197
|
+
"preference",
|
|
198
|
+
"event",
|
|
199
|
+
"discovery"
|
|
200
|
+
];
|
|
201
|
+
const MAX_MEMORY_TEXT_CHARACTERS = 1e5;
|
|
202
|
+
const MAX_MEMORY_FILE_BYTES = MAX_MEMORY_TEXT_CHARACTERS * 4;
|
|
82
203
|
/**
|
|
83
204
|
* Memory tools — a runtime-agnostic mirror of the OpenClaw memory-cloud
|
|
84
205
|
* plugin (`@alfe.ai/openclaw-memory-cloud`). All 7 tools (memory_recall,
|
|
@@ -103,10 +224,10 @@ const registerMemoryTools = (server, ctx) => {
|
|
|
103
224
|
server.registerTool("memory_recall", {
|
|
104
225
|
description: "Search conversation memory and knowledge graph. Returns structured facts and relevant conversation excerpts.",
|
|
105
226
|
inputSchema: {
|
|
106
|
-
query: z.string().describe("What to search for"),
|
|
107
|
-
limit: z.coerce.number().optional().describe("Maximum results (default 10)"),
|
|
108
|
-
topic: z.string().optional().describe("Filter by topic"),
|
|
109
|
-
tag: z.
|
|
227
|
+
query: z.string().trim().min(1).max(4e3).describe("What to search for"),
|
|
228
|
+
limit: z.coerce.number().int().min(1).max(50).optional().describe("Maximum results (default 10)"),
|
|
229
|
+
topic: z.string().trim().min(1).max(256).optional().describe("Filter by topic"),
|
|
230
|
+
tag: z.enum(MEMORY_TAGS).optional().describe("Filter by tag (fact/decision/preference/event/discovery)")
|
|
110
231
|
}
|
|
111
232
|
}, async (args) => {
|
|
112
233
|
try {
|
|
@@ -122,10 +243,10 @@ const registerMemoryTools = (server, ctx) => {
|
|
|
122
243
|
server.registerTool("memory_store", {
|
|
123
244
|
description: "Explicitly save a piece of information to long-term memory.",
|
|
124
245
|
inputSchema: {
|
|
125
|
-
text: z.string().describe("The information to remember"),
|
|
126
|
-
topic: z.string().optional().describe("Topic category (e.g., person name, project)"),
|
|
127
|
-
tag: z.
|
|
128
|
-
importance: z.coerce.number().optional().describe("Importance 0-1 (default 0.7)")
|
|
246
|
+
text: z.string().trim().min(1).max(5e3).describe("The information to remember"),
|
|
247
|
+
topic: z.string().trim().min(1).max(128).optional().describe("Topic category (e.g., person name, project)"),
|
|
248
|
+
tag: z.enum(MEMORY_TAGS).optional().describe("Memory type: fact, decision, preference, event, discovery"),
|
|
249
|
+
importance: z.coerce.number().min(0).max(1).optional().describe("Importance 0-1 (default 0.7)")
|
|
129
250
|
}
|
|
130
251
|
}, async (args) => {
|
|
131
252
|
try {
|
|
@@ -145,27 +266,24 @@ const registerMemoryTools = (server, ctx) => {
|
|
|
145
266
|
server.registerTool("memory_learn", {
|
|
146
267
|
description: "Save arbitrary content (a paragraph, a doc, a fact list) into long-term memory. The system auto-classifies and extracts entities. Use this when the user asks you to learn, remember, or study something — or when you read a document worth retaining. Pass the source text directly; do not paraphrase first.",
|
|
147
268
|
inputSchema: {
|
|
148
|
-
content: z.string().optional().describe("Inline text to ingest. Provide either content OR path, not both."),
|
|
149
|
-
path: z.string().optional().describe("Workspace-relative file path to read and ingest. Provide either content OR path, not both."),
|
|
150
|
-
source: z.string().optional().describe("Optional label describing where the content came from (e.g. doc title, url).")
|
|
269
|
+
content: z.string().min(1).max(MAX_MEMORY_TEXT_CHARACTERS).optional().describe("Inline text to ingest. Provide either content OR path, not both."),
|
|
270
|
+
path: z.string().min(1).max(1024).optional().describe("Workspace-relative file path to read and ingest. Provide either content OR path, not both."),
|
|
271
|
+
source: z.string().min(1).max(512).optional().describe("Optional label describing where the content came from (e.g. doc title, url).")
|
|
151
272
|
}
|
|
152
273
|
}, async (args) => {
|
|
153
274
|
const content = typeof args.content === "string" ? args.content : void 0;
|
|
154
275
|
const filePath = typeof args.path === "string" ? args.path : void 0;
|
|
155
276
|
const source = typeof args.source === "string" ? args.source : void 0;
|
|
156
|
-
if (
|
|
157
|
-
if (content && filePath) return err("Error: provide either `content` or `path`, not both.");
|
|
277
|
+
if (content === void 0 && filePath === void 0) return err("Error: provide either `content` or `path`.");
|
|
278
|
+
if (content !== void 0 && filePath !== void 0) return err("Error: provide either `content` or `path`, not both.");
|
|
279
|
+
if (source !== void 0 && (source.length < 1 || source.length > 512)) return err("Error: source must contain 1–512 characters.");
|
|
158
280
|
let text;
|
|
159
281
|
let resolvedSource = source;
|
|
160
282
|
let sourceType = "inline";
|
|
161
283
|
if (filePath !== void 0) {
|
|
162
|
-
const nodePath = await import("node:path");
|
|
163
|
-
const fs = await import("node:fs/promises");
|
|
164
|
-
const cwd = process.cwd();
|
|
165
|
-
const resolved = nodePath.resolve(cwd, filePath);
|
|
166
|
-
if (!resolved.startsWith(cwd + nodePath.sep) && resolved !== cwd) return err(`Error: path "${filePath}" escapes the workspace.`);
|
|
167
284
|
try {
|
|
168
|
-
|
|
285
|
+
const { data } = await readWorkspaceFile(filePath, MAX_MEMORY_FILE_BYTES);
|
|
286
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
169
287
|
} catch (readErr) {
|
|
170
288
|
return err(`Error reading "${filePath}": ${readErr instanceof Error ? readErr.message : String(readErr)}`);
|
|
171
289
|
}
|
|
@@ -173,6 +291,7 @@ const registerMemoryTools = (server, ctx) => {
|
|
|
173
291
|
sourceType = "file";
|
|
174
292
|
} else if (content !== void 0) text = content;
|
|
175
293
|
else return err("Error: provide either `content` or `path`.");
|
|
294
|
+
if (text.trim().length === 0 || text.length > MAX_MEMORY_TEXT_CHARACTERS) return err(`Error: memory content must contain 1–${String(MAX_MEMORY_TEXT_CHARACTERS)} characters.`);
|
|
176
295
|
try {
|
|
177
296
|
const result = await ctx.client.memoryLearn({
|
|
178
297
|
text,
|
|
@@ -193,7 +312,7 @@ const registerMemoryTools = (server, ctx) => {
|
|
|
193
312
|
});
|
|
194
313
|
server.registerTool("memory_forget", {
|
|
195
314
|
description: "Search for and delete memories matching a query.",
|
|
196
|
-
inputSchema: { query: z.string().describe("Search for memories to delete") }
|
|
315
|
+
inputSchema: { query: z.string().trim().min(1).max(4e3).describe("Search for memories to delete") }
|
|
197
316
|
}, async (args) => {
|
|
198
317
|
try {
|
|
199
318
|
const results = await ctx.client.memorySearch(args.query, { limit: 5 });
|
|
@@ -217,7 +336,7 @@ const registerMemoryTools = (server, ctx) => {
|
|
|
217
336
|
});
|
|
218
337
|
server.registerTool("memory_graph", {
|
|
219
338
|
description: "Look up what you know about a specific entity from the knowledge graph.",
|
|
220
|
-
inputSchema: { entity: z.string().describe("The entity to look up (person, concept, system)") }
|
|
339
|
+
inputSchema: { entity: z.string().trim().min(1).max(512).describe("The entity to look up (person, concept, system)") }
|
|
221
340
|
}, async ({ entity }) => {
|
|
222
341
|
try {
|
|
223
342
|
const result = await ctx.client.memoryLookupEntity(entity);
|
|
@@ -265,8 +384,26 @@ const registerMemoryTools = (server, ctx) => {
|
|
|
265
384
|
};
|
|
266
385
|
//#endregion
|
|
267
386
|
//#region src/audio.ts
|
|
387
|
+
const MAX_WAV_PCM_BYTES = 4294967259;
|
|
388
|
+
const SUPPORTED_BIT_DEPTHS = new Set([
|
|
389
|
+
8,
|
|
390
|
+
16,
|
|
391
|
+
24,
|
|
392
|
+
32
|
|
393
|
+
]);
|
|
394
|
+
function validatePcmFraming(framing, pcmLength) {
|
|
395
|
+
const { sampleRate, channels, bitDepth } = framing;
|
|
396
|
+
if (!Number.isInteger(sampleRate) || sampleRate < 8e3 || sampleRate > 384e3) throw new Error("WAV sample rate must be an integer between 8000 and 384000 Hz.");
|
|
397
|
+
if (!Number.isInteger(channels) || channels < 1 || channels > 32) throw new Error("WAV channel count must be an integer between 1 and 32.");
|
|
398
|
+
if (!SUPPORTED_BIT_DEPTHS.has(bitDepth)) throw new Error("WAV bit depth must be one of 8, 16, 24, or 32.");
|
|
399
|
+
const blockAlign = channels * (bitDepth / 8);
|
|
400
|
+
if (pcmLength % blockAlign !== 0) throw new Error("PCM byte length must contain a whole number of sample frames.");
|
|
401
|
+
if (pcmLength > MAX_WAV_PCM_BYTES) throw new Error("PCM payload is too large for a RIFF/WAV container.");
|
|
402
|
+
if (sampleRate * blockAlign > 4294967295) throw new Error("WAV byte rate exceeds the RIFF field limit.");
|
|
403
|
+
}
|
|
268
404
|
/** Prepend a canonical 44-byte PCM WAV header to raw PCM samples. */
|
|
269
405
|
function pcmToWav(pcm, framing) {
|
|
406
|
+
validatePcmFraming(framing, pcm.length);
|
|
270
407
|
const { sampleRate, channels, bitDepth } = framing;
|
|
271
408
|
const blockAlign = channels * (bitDepth / 8);
|
|
272
409
|
const byteRate = sampleRate * blockAlign;
|
|
@@ -287,35 +424,59 @@ function pcmToWav(pcm, framing) {
|
|
|
287
424
|
return Buffer.concat([header, pcm]);
|
|
288
425
|
}
|
|
289
426
|
/**
|
|
290
|
-
* Parse a WAV buffer into its PCM payload + framing. Returns `null` when
|
|
291
|
-
* buffer is not
|
|
292
|
-
*
|
|
293
|
-
*
|
|
427
|
+
* Parse a WAV buffer into its PCM payload + framing. Returns `null` only when
|
|
428
|
+
* the buffer is not RIFF data (caller may then treat it as raw PCM). A RIFF
|
|
429
|
+
* buffer that claims to be WAV but is truncated, unsupported, or internally
|
|
430
|
+
* inconsistent throws instead of silently uploading container bytes as PCM.
|
|
294
431
|
*/
|
|
295
432
|
function parseWav(buf) {
|
|
296
|
-
if (buf.length <
|
|
297
|
-
if (buf.
|
|
298
|
-
if (buf.toString("ascii", 8, 12) !== "WAVE")
|
|
433
|
+
if (buf.length < 4 || buf.toString("ascii", 0, 4) !== "RIFF") return null;
|
|
434
|
+
if (buf.length < 12) throw new Error("Malformed WAV: truncated RIFF header.");
|
|
435
|
+
if (buf.toString("ascii", 8, 12) !== "WAVE") throw new Error("Unsupported RIFF container: expected WAVE.");
|
|
436
|
+
const declaredEnd = buf.readUInt32LE(4) + 8;
|
|
437
|
+
if (declaredEnd !== buf.length) throw new Error("Malformed WAV: RIFF size does not match the file length.");
|
|
299
438
|
let sampleRate = 0;
|
|
300
439
|
let channels = 0;
|
|
301
440
|
let bitDepth = 0;
|
|
302
441
|
let pcm = null;
|
|
442
|
+
let blockAlign = 0;
|
|
443
|
+
let byteRate = 0;
|
|
444
|
+
let sawFmt = false;
|
|
445
|
+
let sawData = false;
|
|
303
446
|
let offset = 12;
|
|
304
|
-
while (offset
|
|
447
|
+
while (offset < declaredEnd) {
|
|
448
|
+
if (offset + 8 > declaredEnd) throw new Error("Malformed WAV: truncated chunk header.");
|
|
305
449
|
const chunkId = buf.toString("ascii", offset, offset + 4);
|
|
306
450
|
const chunkSize = buf.readUInt32LE(offset + 4);
|
|
307
451
|
const bodyStart = offset + 8;
|
|
308
|
-
|
|
452
|
+
const bodyEnd = bodyStart + chunkSize;
|
|
453
|
+
const nextOffset = bodyEnd + chunkSize % 2;
|
|
454
|
+
if (bodyEnd > declaredEnd || nextOffset > declaredEnd) throw new Error(`Malformed WAV: truncated ${chunkId} chunk.`);
|
|
455
|
+
if (chunkId === "fmt ") {
|
|
456
|
+
if (sawFmt) throw new Error("Malformed WAV: duplicate fmt chunk.");
|
|
457
|
+
if (chunkSize < 16) throw new Error("Malformed WAV: fmt chunk is too short.");
|
|
458
|
+
if (buf.readUInt16LE(bodyStart) !== 1) throw new Error("Unsupported WAV encoding: only integer PCM is accepted.");
|
|
309
459
|
channels = buf.readUInt16LE(bodyStart + 2);
|
|
310
460
|
sampleRate = buf.readUInt32LE(bodyStart + 4);
|
|
461
|
+
byteRate = buf.readUInt32LE(bodyStart + 8);
|
|
462
|
+
blockAlign = buf.readUInt16LE(bodyStart + 12);
|
|
311
463
|
bitDepth = buf.readUInt16LE(bodyStart + 14);
|
|
464
|
+
sawFmt = true;
|
|
312
465
|
} else if (chunkId === "data") {
|
|
313
|
-
|
|
314
|
-
pcm = buf.subarray(bodyStart,
|
|
466
|
+
if (sawData) throw new Error("Malformed WAV: duplicate data chunk.");
|
|
467
|
+
pcm = buf.subarray(bodyStart, bodyEnd);
|
|
468
|
+
sawData = true;
|
|
315
469
|
}
|
|
316
|
-
offset =
|
|
470
|
+
offset = nextOffset;
|
|
317
471
|
}
|
|
318
|
-
if (!
|
|
472
|
+
if (!sawFmt || !sawData || pcm === null) throw new Error("Malformed WAV: both fmt and data chunks are required.");
|
|
473
|
+
validatePcmFraming({
|
|
474
|
+
sampleRate,
|
|
475
|
+
channels,
|
|
476
|
+
bitDepth
|
|
477
|
+
}, pcm.length);
|
|
478
|
+
const expectedBlockAlign = channels * (bitDepth / 8);
|
|
479
|
+
if (blockAlign !== expectedBlockAlign || byteRate !== sampleRate * expectedBlockAlign) throw new Error("Malformed WAV: byte rate or block alignment is inconsistent with its framing.");
|
|
319
480
|
return {
|
|
320
481
|
pcm,
|
|
321
482
|
sampleRate,
|
|
@@ -325,6 +486,9 @@ function parseWav(buf) {
|
|
|
325
486
|
}
|
|
326
487
|
//#endregion
|
|
327
488
|
//#region src/voice.ts
|
|
489
|
+
const MAX_AUDIO_FILE_BYTES = 10 * 1024 * 1024;
|
|
490
|
+
const MAX_TTS_RESPONSE_BYTES = 32 * 1024 * 1024;
|
|
491
|
+
const VOICE_ID_PATTERN = /^[A-Za-z0-9_-]{1,200}$/u;
|
|
328
492
|
/**
|
|
329
493
|
* Voice tools — a runtime-agnostic mirror of the one-shot TTS/STT tools in
|
|
330
494
|
* the OpenClaw voice plugin (`@alfe.ai/openclaw-voice`). The channel-only
|
|
@@ -343,42 +507,48 @@ function parseWav(buf) {
|
|
|
343
507
|
*/
|
|
344
508
|
const registerVoiceTools = (server, ctx) => {
|
|
345
509
|
const voiceClient = ctx.voiceClient ?? ctx.client;
|
|
346
|
-
const resolveWorkspacePath = (p) => isAbsolute(p) ? p : resolve(process.cwd(), p);
|
|
347
510
|
server.registerTool("voice_tts", {
|
|
348
511
|
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.",
|
|
349
512
|
inputSchema: {
|
|
350
|
-
text: z.string().describe("Text to synthesize (1–5000 characters)."),
|
|
351
|
-
outputPath: z.string().optional().describe("
|
|
513
|
+
text: z.string().trim().min(1).max(5e3).describe("Text to synthesize (1–5000 characters)."),
|
|
514
|
+
outputPath: z.string().min(1).max(1024).optional().describe("New workspace-relative audio file to create. Existing files are never overwritten. Defaults to alfe-tts-<timestamp>.wav."),
|
|
352
515
|
format: z.enum(["wav", "pcm"]).optional().describe("Output container. 'wav' (default) is a playable file; 'pcm' is headerless 24kHz/mono/16-bit raw PCM."),
|
|
353
|
-
voiceId: z.string().optional().describe("ElevenLabs voice ID. Platform default when omitted."),
|
|
516
|
+
voiceId: z.string().regex(VOICE_ID_PATTERN).optional().describe("ElevenLabs voice ID. Platform default when omitted."),
|
|
354
517
|
model: z.enum(["eleven_turbo_v2_5", "eleven_multilingual_v2"]).optional().describe("TTS model. eleven_turbo_v2_5 (lower latency) when omitted.")
|
|
355
518
|
}
|
|
356
519
|
}, async (args) => {
|
|
357
520
|
try {
|
|
358
|
-
const { writeFile } = await import("node:fs/promises");
|
|
359
521
|
const format = args.format ?? "wav";
|
|
522
|
+
const text = args.text.trim();
|
|
523
|
+
if (text.length < 1 || text.length > 5e3) return err("voice_tts text must contain 1–5000 characters.");
|
|
524
|
+
if (args.voiceId !== void 0 && !VOICE_ID_PATTERN.test(args.voiceId)) return err("voice_tts voiceId contains unsupported characters.");
|
|
525
|
+
const outputPath = args.outputPath ?? `alfe-tts-${String(Date.now())}.${format}`;
|
|
526
|
+
await resolveWorkspaceOutputPath(outputPath);
|
|
360
527
|
const result = await voiceClient.tts({
|
|
361
|
-
text
|
|
528
|
+
text,
|
|
362
529
|
voiceId: args.voiceId,
|
|
363
530
|
model: args.model
|
|
364
531
|
});
|
|
532
|
+
if (result.audio.length === 0 || result.audio.length > MAX_TTS_RESPONSE_BYTES) return err(`voice_tts returned an invalid audio payload size (${String(result.audio.length)} bytes).`);
|
|
533
|
+
validatePcmFraming({
|
|
534
|
+
sampleRate: result.sampleRate,
|
|
535
|
+
channels: result.channels,
|
|
536
|
+
bitDepth: result.bitDepth
|
|
537
|
+
}, result.audio.length);
|
|
365
538
|
const bytes = format === "wav" ? pcmToWav(result.audio, {
|
|
366
539
|
sampleRate: result.sampleRate,
|
|
367
540
|
channels: result.channels,
|
|
368
541
|
bitDepth: result.bitDepth
|
|
369
542
|
}) : result.audio;
|
|
370
|
-
const outputPath = args.outputPath ?? `alfe-tts-${String(Date.now())}.${format}`;
|
|
371
|
-
const absolutePath = resolveWorkspacePath(outputPath);
|
|
372
|
-
await writeFile(absolutePath, bytes);
|
|
373
543
|
return ok({
|
|
374
544
|
path: outputPath,
|
|
375
|
-
absolutePath,
|
|
545
|
+
absolutePath: await writeWorkspaceFileExclusive(outputPath, bytes),
|
|
376
546
|
format,
|
|
377
547
|
sampleRate: result.sampleRate,
|
|
378
548
|
channels: result.channels,
|
|
379
549
|
bitDepth: result.bitDepth,
|
|
380
550
|
bytes: bytes.length,
|
|
381
|
-
characters:
|
|
551
|
+
characters: text.length
|
|
382
552
|
});
|
|
383
553
|
} catch (error) {
|
|
384
554
|
return err(error instanceof Error ? error.message : "voice_tts failed");
|
|
@@ -387,13 +557,12 @@ const registerVoiceTools = (server, ctx) => {
|
|
|
387
557
|
server.registerTool("voice_stt", {
|
|
388
558
|
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.",
|
|
389
559
|
inputSchema: {
|
|
390
|
-
path: z.string().describe("
|
|
391
|
-
sampleRate: z.coerce.number().optional().describe("Sample rate in Hz (8000–48000). Only used for headerless PCM input; ignored for WAV (its header wins). Defaults to 24000.")
|
|
560
|
+
path: z.string().min(1).max(1024).describe("Workspace-relative path to a WAV or raw linear16 mono PCM file (maximum 10 MiB)."),
|
|
561
|
+
sampleRate: z.coerce.number().int().min(8e3).max(48e3).optional().describe("Sample rate in Hz (8000–48000). Only used for headerless PCM input; ignored for WAV (its header wins). Defaults to 24000.")
|
|
392
562
|
}
|
|
393
563
|
}, async (args) => {
|
|
394
564
|
try {
|
|
395
|
-
const {
|
|
396
|
-
const raw = await readFile(resolveWorkspacePath(args.path));
|
|
565
|
+
const { data: raw } = await readWorkspaceFile(args.path, MAX_AUDIO_FILE_BYTES);
|
|
397
566
|
const wav = parseWav(raw);
|
|
398
567
|
let audio;
|
|
399
568
|
let sampleRate;
|
|
@@ -405,6 +574,8 @@ const registerVoiceTools = (server, ctx) => {
|
|
|
405
574
|
audio = raw;
|
|
406
575
|
sampleRate = args.sampleRate ?? 24e3;
|
|
407
576
|
}
|
|
577
|
+
if (!Number.isInteger(sampleRate) || sampleRate < 8e3 || sampleRate > 48e3) return err("voice_stt sampleRate must be an integer between 8000 and 48000 Hz.");
|
|
578
|
+
if (audio.length === 0 || audio.length % 2 !== 0) return err("voice_stt needs non-empty 16-bit PCM with an even byte length.");
|
|
408
579
|
return ok(await voiceClient.stt({
|
|
409
580
|
audio,
|
|
410
581
|
sampleRate
|
|
@@ -430,8 +601,8 @@ const registerMessagingTools = (server, ctx) => {
|
|
|
430
601
|
server.registerTool("send_text_message", {
|
|
431
602
|
description: "Send an SMS text message to a phone number from your assigned phone number. The recipient is any E.164 number (e.g. +14155550123). You send from the single number assigned to you — you cannot choose the sender. Billed per message segment to your tenant credit pool. Fails if you have no active phone number.",
|
|
432
603
|
inputSchema: {
|
|
433
|
-
to: z.string().
|
|
434
|
-
body: z.string().min(1).describe("The text message body to send.")
|
|
604
|
+
to: z.string().regex(/^\+[1-9]\d{6,14}$/u).describe("Recipient phone number in E.164 format (e.g. +14155550123)."),
|
|
605
|
+
body: z.string().min(1).max(4400).describe("The text message body to send.")
|
|
435
606
|
}
|
|
436
607
|
}, async (args) => {
|
|
437
608
|
try {
|