daemora 1.0.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 +666 -0
- package/SOUL.md +104 -0
- package/config/hooks.json +14 -0
- package/config/mcp.json +145 -0
- package/package.json +86 -0
- package/skills/.gitkeep +0 -0
- package/skills/apple-notes.md +193 -0
- package/skills/apple-reminders.md +189 -0
- package/skills/camsnap.md +162 -0
- package/skills/coding.md +14 -0
- package/skills/documents.md +13 -0
- package/skills/email.md +13 -0
- package/skills/gif-search.md +196 -0
- package/skills/healthcheck.md +225 -0
- package/skills/image-gen.md +147 -0
- package/skills/model-usage.md +182 -0
- package/skills/obsidian.md +207 -0
- package/skills/pdf.md +211 -0
- package/skills/research.md +13 -0
- package/skills/skill-creator.md +142 -0
- package/skills/spotify.md +149 -0
- package/skills/summarize.md +230 -0
- package/skills/things.md +199 -0
- package/skills/tmux.md +204 -0
- package/skills/trello.md +183 -0
- package/skills/video-frames.md +202 -0
- package/skills/weather.md +127 -0
- package/src/a2a/A2AClient.js +136 -0
- package/src/a2a/A2AServer.js +316 -0
- package/src/a2a/AgentCard.js +79 -0
- package/src/agents/SubAgentManager.js +369 -0
- package/src/agents/Supervisor.js +192 -0
- package/src/channels/BaseChannel.js +104 -0
- package/src/channels/DiscordChannel.js +288 -0
- package/src/channels/EmailChannel.js +172 -0
- package/src/channels/GoogleChatChannel.js +316 -0
- package/src/channels/HttpChannel.js +26 -0
- package/src/channels/LineChannel.js +168 -0
- package/src/channels/SignalChannel.js +186 -0
- package/src/channels/SlackChannel.js +329 -0
- package/src/channels/TeamsChannel.js +272 -0
- package/src/channels/TelegramChannel.js +347 -0
- package/src/channels/WhatsAppChannel.js +219 -0
- package/src/channels/index.js +198 -0
- package/src/cli.js +1267 -0
- package/src/config/agentProfiles.js +120 -0
- package/src/config/channels.js +32 -0
- package/src/config/default.js +206 -0
- package/src/config/models.js +123 -0
- package/src/config/permissions.js +167 -0
- package/src/core/AgentLoop.js +446 -0
- package/src/core/Compaction.js +143 -0
- package/src/core/CostTracker.js +116 -0
- package/src/core/EventBus.js +46 -0
- package/src/core/Task.js +67 -0
- package/src/core/TaskQueue.js +206 -0
- package/src/core/TaskRunner.js +226 -0
- package/src/daemon/DaemonManager.js +301 -0
- package/src/hooks/HookRunner.js +230 -0
- package/src/index.js +482 -0
- package/src/mcp/MCPAgentRunner.js +112 -0
- package/src/mcp/MCPClient.js +186 -0
- package/src/mcp/MCPManager.js +412 -0
- package/src/models/ModelRouter.js +180 -0
- package/src/safety/AuditLog.js +135 -0
- package/src/safety/CircuitBreaker.js +126 -0
- package/src/safety/FilesystemGuard.js +169 -0
- package/src/safety/GitRollback.js +139 -0
- package/src/safety/HumanApproval.js +156 -0
- package/src/safety/InputSanitizer.js +72 -0
- package/src/safety/PermissionGuard.js +83 -0
- package/src/safety/Sandbox.js +70 -0
- package/src/safety/SecretScanner.js +100 -0
- package/src/safety/SecretVault.js +250 -0
- package/src/scheduler/Heartbeat.js +115 -0
- package/src/scheduler/Scheduler.js +228 -0
- package/src/services/models/outputSchema.js +15 -0
- package/src/services/openai.js +25 -0
- package/src/services/sessions.js +65 -0
- package/src/setup/theme.js +110 -0
- package/src/setup/wizard.js +788 -0
- package/src/skills/SkillLoader.js +168 -0
- package/src/storage/TaskStore.js +69 -0
- package/src/systemPrompt.js +526 -0
- package/src/tenants/TenantContext.js +19 -0
- package/src/tenants/TenantManager.js +379 -0
- package/src/tools/ToolRegistry.js +141 -0
- package/src/tools/applyPatch.js +144 -0
- package/src/tools/browserAutomation.js +223 -0
- package/src/tools/createDocument.js +265 -0
- package/src/tools/cronTool.js +105 -0
- package/src/tools/editFile.js +139 -0
- package/src/tools/executeCommand.js +123 -0
- package/src/tools/glob.js +67 -0
- package/src/tools/grep.js +121 -0
- package/src/tools/imageAnalysis.js +120 -0
- package/src/tools/index.js +173 -0
- package/src/tools/listDirectory.js +47 -0
- package/src/tools/manageAgents.js +47 -0
- package/src/tools/manageMCP.js +159 -0
- package/src/tools/memory.js +478 -0
- package/src/tools/messageChannel.js +45 -0
- package/src/tools/projectTracker.js +259 -0
- package/src/tools/readFile.js +52 -0
- package/src/tools/screenCapture.js +112 -0
- package/src/tools/searchContent.js +76 -0
- package/src/tools/searchFiles.js +75 -0
- package/src/tools/sendEmail.js +118 -0
- package/src/tools/sendFile.js +63 -0
- package/src/tools/textToSpeech.js +161 -0
- package/src/tools/transcribeAudio.js +82 -0
- package/src/tools/useMCP.js +29 -0
- package/src/tools/webFetch.js +150 -0
- package/src/tools/webSearch.js +134 -0
- package/src/tools/writeFile.js +26 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Send Email — sends email via SMTP (nodemailer).
|
|
3
|
+
* Upgraded: CC/BCC, attachments, replyTo, email validation, optionsJson support.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
7
|
+
|
|
8
|
+
function validateEmail(addr) {
|
|
9
|
+
return EMAIL_REGEX.test(addr.trim());
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parseAddressList(val) {
|
|
13
|
+
if (!val) return null;
|
|
14
|
+
return val.split(",").map((a) => a.trim()).filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let transporter = null;
|
|
18
|
+
|
|
19
|
+
async function getTransporter() {
|
|
20
|
+
if (transporter) return transporter;
|
|
21
|
+
|
|
22
|
+
const user = process.env.EMAIL_USER;
|
|
23
|
+
const pass = process.env.EMAIL_PASSWORD;
|
|
24
|
+
const host = process.env.EMAIL_SMTP_HOST || "smtp.gmail.com";
|
|
25
|
+
const port = parseInt(process.env.EMAIL_SMTP_PORT || "587", 10);
|
|
26
|
+
|
|
27
|
+
if (!user || !pass) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const nodemailer = await import("nodemailer");
|
|
32
|
+
transporter = nodemailer.default.createTransport({
|
|
33
|
+
host,
|
|
34
|
+
port,
|
|
35
|
+
secure: port === 465,
|
|
36
|
+
auth: { user, pass },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return transporter;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function sendEmail(to, subject, body, optionsJson) {
|
|
43
|
+
if (!to || !subject || !body) {
|
|
44
|
+
return "Error: to, subject, and body are all required.";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Parse options
|
|
48
|
+
let opts = {};
|
|
49
|
+
if (optionsJson) {
|
|
50
|
+
try { opts = JSON.parse(optionsJson); } catch {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const cc = opts.cc ? parseAddressList(opts.cc) : null;
|
|
54
|
+
const bcc = opts.bcc ? parseAddressList(opts.bcc) : null;
|
|
55
|
+
const replyTo = opts.replyTo || null;
|
|
56
|
+
const attachments = Array.isArray(opts.attachments) ? opts.attachments : null; // [{filename, path}]
|
|
57
|
+
|
|
58
|
+
// Validate addresses
|
|
59
|
+
const toList = parseAddressList(to);
|
|
60
|
+
if (!toList || toList.length === 0) return "Error: 'to' must have at least one valid address.";
|
|
61
|
+
for (const addr of toList) {
|
|
62
|
+
if (!validateEmail(addr)) return `Error: Invalid email address: "${addr}"`;
|
|
63
|
+
}
|
|
64
|
+
if (cc) {
|
|
65
|
+
for (const addr of cc) {
|
|
66
|
+
if (!validateEmail(addr)) return `Error: Invalid CC address: "${addr}"`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (bcc) {
|
|
70
|
+
for (const addr of bcc) {
|
|
71
|
+
if (!validateEmail(addr)) return `Error: Invalid BCC address: "${addr}"`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(` [sendEmail] To: ${to} | Subject: "${subject}"${cc ? ` | CC: ${cc.join(",")}` : ""}${bcc ? ` | BCC: ${bcc.join(",")}` : ""}`);
|
|
76
|
+
|
|
77
|
+
const smtp = await getTransporter();
|
|
78
|
+
if (!smtp) {
|
|
79
|
+
return "Error: Email not configured. Set EMAIL_USER and EMAIL_PASSWORD in .env";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const mailOptions = {
|
|
84
|
+
from: process.env.EMAIL_USER,
|
|
85
|
+
to: toList.join(", "),
|
|
86
|
+
subject,
|
|
87
|
+
text: body,
|
|
88
|
+
html: body.includes("<") ? body : undefined,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
if (cc) mailOptions.cc = cc.join(", ");
|
|
92
|
+
if (bcc) mailOptions.bcc = bcc.join(", ");
|
|
93
|
+
if (replyTo) mailOptions.replyTo = replyTo;
|
|
94
|
+
if (attachments) {
|
|
95
|
+
mailOptions.attachments = attachments.map((a) => ({
|
|
96
|
+
filename: a.filename,
|
|
97
|
+
path: a.path,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const info = await smtp.sendMail(mailOptions);
|
|
102
|
+
|
|
103
|
+
console.log(` [sendEmail] Sent: ${info.messageId}`);
|
|
104
|
+
|
|
105
|
+
const extra = [];
|
|
106
|
+
if (cc) extra.push(`CC: ${cc.join(", ")}`);
|
|
107
|
+
if (bcc) extra.push(`BCC: ${bcc.join(", ")}`);
|
|
108
|
+
if (attachments) extra.push(`${attachments.length} attachment(s)`);
|
|
109
|
+
|
|
110
|
+
return `Email sent to ${to}${extra.length ? ` (${extra.join(", ")})` : ""}. Message ID: ${info.messageId}`;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.log(` [sendEmail] Failed: ${error.message}`);
|
|
113
|
+
return `Failed to send email: ${error.message}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const sendEmailDescription =
|
|
118
|
+
'sendEmail(to: string, subject: string, body: string, optionsJson?: string) - Send email via SMTP. optionsJson: {"cc":"a@b.com,c@d.com","bcc":"e@f.com","replyTo":"r@s.com","attachments":[{"filename":"report.pdf","path":"/tmp/report.pdf"}]}. Requires EMAIL_USER + EMAIL_PASSWORD in .env.';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sendFile(channel, target, filePath, caption?) — Send a file/image/video to a user on any channel.
|
|
3
|
+
*
|
|
4
|
+
* The agent uses this to proactively deliver:
|
|
5
|
+
* - Screenshots it captured (screenCapture → sendFile)
|
|
6
|
+
* - Images it generated or processed
|
|
7
|
+
* - Videos it recorded
|
|
8
|
+
* - Documents/PDFs it created (createDocument → sendFile)
|
|
9
|
+
* - Any other file the user should receive
|
|
10
|
+
*
|
|
11
|
+
* channel: "telegram" | "discord" | "slack" | "whatsapp" | "email"
|
|
12
|
+
* target: chat ID, user ID, channel ID, phone number, or email — depends on channel
|
|
13
|
+
* filePath: absolute path to the local file to send
|
|
14
|
+
* caption: optional text caption alongside the file
|
|
15
|
+
*/
|
|
16
|
+
import channelRegistry from "../channels/index.js";
|
|
17
|
+
import { existsSync, statSync } from "node:fs";
|
|
18
|
+
|
|
19
|
+
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB — most platforms limit around this
|
|
20
|
+
|
|
21
|
+
export async function sendFile(channel, target, filePath, caption) {
|
|
22
|
+
try {
|
|
23
|
+
if (!channel) return "Error: channel is required";
|
|
24
|
+
if (!target) return "Error: target is required (chat ID, user ID, phone, or email)";
|
|
25
|
+
if (!filePath) return "Error: filePath is required";
|
|
26
|
+
|
|
27
|
+
if (!existsSync(filePath)) {
|
|
28
|
+
return `Error: File not found: ${filePath}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const size = statSync(filePath).size;
|
|
32
|
+
if (size > MAX_FILE_SIZE) {
|
|
33
|
+
return `Error: File too large (${(size / 1024 / 1024).toFixed(1)} MB). Maximum is 50 MB.`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ch = channelRegistry.get(channel.toLowerCase());
|
|
37
|
+
if (!ch) {
|
|
38
|
+
const available = channelRegistry.list().map((c) => c.name).join(", ");
|
|
39
|
+
return `Error: Channel "${channel}" not found. Available: ${available || "none"}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!ch.running) {
|
|
43
|
+
return `Error: Channel "${channel}" is not running.`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (typeof ch.sendFile !== "function") {
|
|
47
|
+
return `Error: Channel "${channel}" does not support file sending yet.`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await ch.sendFile({ chatId: target, userId: target, channelId: target }, filePath, caption || "");
|
|
51
|
+
|
|
52
|
+
return `File sent via ${channel} to ${target}: ${filePath}`;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return `Error sending file: ${error.message}`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const sendFileDescription =
|
|
59
|
+
'sendFile(channel, target, filePath, caption?) — Send a file, image, or video to a user. ' +
|
|
60
|
+
'channel: "telegram"|"discord"|"slack"|"email". ' +
|
|
61
|
+
'target: chat ID (Telegram), user/channel ID (Discord/Slack), or email. ' +
|
|
62
|
+
'filePath: absolute path to the file. caption: optional text alongside the file. ' +
|
|
63
|
+
'Use after screenCapture, createDocument, or imageAnalysis to deliver results to the user.';
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* textToSpeech(text, optionsJson?) — Convert text to speech and save as audio file.
|
|
3
|
+
*
|
|
4
|
+
* Primary: OpenAI TTS (tts-1-hd) — uses the same OPENAI_API_KEY already configured.
|
|
5
|
+
* Optional: ElevenLabs via ELEVENLABS_API_KEY (higher quality, more voices).
|
|
6
|
+
*
|
|
7
|
+
* Unlike OpenClaw's /voice command (config-only, iOS-only), this is a proper
|
|
8
|
+
* agent-callable tool. Chain with sendFile() to deliver audio to the user.
|
|
9
|
+
*
|
|
10
|
+
* OpenAI voices: alloy, echo, fable, onyx, nova (default), shimmer
|
|
11
|
+
* ElevenLabs: any voice from your ElevenLabs account, set via voiceId option
|
|
12
|
+
*
|
|
13
|
+
* Auto-splits text > 4096 chars (OpenAI hard limit) into sequential MP3 files.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
|
|
20
|
+
const TMP_DIR = join(tmpdir(), "daemora-tts");
|
|
21
|
+
const OPENAI_CHAR_LIMIT = 4096;
|
|
22
|
+
const ELEVENLABS_CHAR_LIMIT = 5000;
|
|
23
|
+
|
|
24
|
+
export async function textToSpeech(text, optionsJson) {
|
|
25
|
+
try {
|
|
26
|
+
if (!text || text.trim().length === 0) {
|
|
27
|
+
return "Error: text is required";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const opts = optionsJson ? JSON.parse(optionsJson) : {};
|
|
31
|
+
const provider = opts.provider?.toLowerCase() || "openai";
|
|
32
|
+
|
|
33
|
+
// Prefer ElevenLabs if key is present and provider not forced
|
|
34
|
+
if (provider === "elevenlabs" || (provider === "auto" && process.env.ELEVENLABS_API_KEY)) {
|
|
35
|
+
return await _elevenLabs(text.trim(), opts);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return await _openAI(text.trim(), opts);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return `Error in textToSpeech: ${err.message}`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── OpenAI TTS ────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
async function _openAI(text, opts) {
|
|
47
|
+
if (!process.env.OPENAI_API_KEY) {
|
|
48
|
+
return "Error: textToSpeech requires OPENAI_API_KEY";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const { default: OpenAI } = await import("openai");
|
|
52
|
+
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
53
|
+
|
|
54
|
+
const voice = opts.voice || "nova"; // nova = clear, neutral, works great for most use cases
|
|
55
|
+
const speed = Math.max(0.25, Math.min(4.0, parseFloat(opts.speed || "1.0")));
|
|
56
|
+
const format = opts.format || "mp3"; // mp3 | opus | aac | flac
|
|
57
|
+
const model = opts.hd === false ? "tts-1" : "tts-1-hd"; // tts-1-hd = better quality
|
|
58
|
+
|
|
59
|
+
mkdirSync(TMP_DIR, { recursive: true });
|
|
60
|
+
|
|
61
|
+
// Split into chunks if text exceeds API limit
|
|
62
|
+
const chunks = _splitText(text, OPENAI_CHAR_LIMIT);
|
|
63
|
+
|
|
64
|
+
if (chunks.length === 1) {
|
|
65
|
+
const response = await client.audio.speech.create({ model, voice, input: chunks[0], speed, response_format: format });
|
|
66
|
+
const filePath = join(TMP_DIR, `speech-${Date.now()}.${format}`);
|
|
67
|
+
writeFileSync(filePath, Buffer.from(await response.arrayBuffer()));
|
|
68
|
+
return `Audio saved to: ${filePath}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Multiple chunks — save each sequentially, return all paths
|
|
72
|
+
const paths = [];
|
|
73
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
74
|
+
const response = await client.audio.speech.create({ model, voice, input: chunks[i], speed, response_format: format });
|
|
75
|
+
const filePath = join(TMP_DIR, `speech-${Date.now()}-part${i + 1}.${format}`);
|
|
76
|
+
writeFileSync(filePath, Buffer.from(await response.arrayBuffer()));
|
|
77
|
+
paths.push(filePath);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return `Text was split into ${paths.length} audio files:\n${paths.join("\n")}\nUse sendFile() to deliver each one.`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── ElevenLabs TTS ────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
async function _elevenLabs(text, opts) {
|
|
86
|
+
const apiKey = process.env.ELEVENLABS_API_KEY;
|
|
87
|
+
if (!apiKey) {
|
|
88
|
+
return "Error: provider=elevenlabs requires ELEVENLABS_API_KEY";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Default: Rachel — professional female voice, works well for most content
|
|
92
|
+
const voiceId = opts.voiceId || "21m00Tcm4TlvDq8ikWAM";
|
|
93
|
+
const modelId = opts.modelId || "eleven_multilingual_v2"; // supports 29 languages
|
|
94
|
+
const stability = parseFloat(opts.stability || "0.5");
|
|
95
|
+
const similarityBoost = parseFloat(opts.similarityBoost || "0.75");
|
|
96
|
+
|
|
97
|
+
const chunk = text.slice(0, ELEVENLABS_CHAR_LIMIT);
|
|
98
|
+
if (text.length > ELEVENLABS_CHAR_LIMIT) {
|
|
99
|
+
console.log(`[textToSpeech] ElevenLabs: text truncated to ${ELEVENLABS_CHAR_LIMIT} chars`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "xi-api-key": apiKey, "Content-Type": "application/json" },
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
text: chunk,
|
|
107
|
+
model_id: modelId,
|
|
108
|
+
voice_settings: { stability, similarity_boost: similarityBoost },
|
|
109
|
+
}),
|
|
110
|
+
signal: AbortSignal.timeout(60000),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
const body = await res.text().catch(() => "");
|
|
115
|
+
return `Error: ElevenLabs API returned HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ""}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
mkdirSync(TMP_DIR, { recursive: true });
|
|
119
|
+
const filePath = join(TMP_DIR, `speech-eleven-${Date.now()}.mp3`);
|
|
120
|
+
writeFileSync(filePath, Buffer.from(await res.arrayBuffer()));
|
|
121
|
+
return `Audio saved to: ${filePath}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Split text at sentence boundaries to keep chunks under maxLength.
|
|
128
|
+
* Sentence-aware: tries to break at ". ", "? ", "! " before hard-cutting.
|
|
129
|
+
*/
|
|
130
|
+
function _splitText(text, maxLength) {
|
|
131
|
+
if (text.length <= maxLength) return [text];
|
|
132
|
+
|
|
133
|
+
const chunks = [];
|
|
134
|
+
let remaining = text;
|
|
135
|
+
|
|
136
|
+
while (remaining.length > 0) {
|
|
137
|
+
if (remaining.length <= maxLength) {
|
|
138
|
+
chunks.push(remaining);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Try to break at sentence boundary near the limit
|
|
143
|
+
let idx = -1;
|
|
144
|
+
for (const sep of [". ", "? ", "! ", "\n\n", "\n", " "]) {
|
|
145
|
+
const pos = remaining.lastIndexOf(sep, maxLength);
|
|
146
|
+
if (pos > maxLength * 0.5) { idx = pos + sep.length; break; }
|
|
147
|
+
}
|
|
148
|
+
if (idx === -1) idx = maxLength;
|
|
149
|
+
|
|
150
|
+
chunks.push(remaining.slice(0, idx).trim());
|
|
151
|
+
remaining = remaining.slice(idx).trimStart();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return chunks.filter(Boolean);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const textToSpeechDescription =
|
|
158
|
+
'textToSpeech(text: string, optionsJson?: string) — Convert text to an audio file using OpenAI TTS (default) or ElevenLabs. ' +
|
|
159
|
+
'optionsJson: {"voice":"nova|alloy|echo|fable|onyx|shimmer","speed":1.0,"format":"mp3","hd":true,"provider":"openai|elevenlabs","voiceId":"<elevenlabs-id>"}. ' +
|
|
160
|
+
'Requires OPENAI_API_KEY (or ELEVENLABS_API_KEY for ElevenLabs). ' +
|
|
161
|
+
'Auto-splits long texts. Returns the saved file path. Chain with sendFile() to deliver audio to the user.';
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcribeAudio(audioPath, prompt?) — Transcribe audio/voice files to text using OpenAI Whisper.
|
|
3
|
+
*
|
|
4
|
+
* Supports: local file paths and HTTPS URLs.
|
|
5
|
+
* Formats: mp3, mp4, mpeg, mpga, m4a, wav, webm, ogg, oga, flac
|
|
6
|
+
*
|
|
7
|
+
* Used by channels to convert voice messages to text before processing as tasks.
|
|
8
|
+
* Can also be called directly by the agent to transcribe any audio file.
|
|
9
|
+
*/
|
|
10
|
+
import { createReadStream, writeFileSync, existsSync } from "node:fs";
|
|
11
|
+
import { join, extname, basename } from "node:path";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import OpenAI from "openai";
|
|
14
|
+
|
|
15
|
+
const SUPPORTED_EXTENSIONS = new Set([
|
|
16
|
+
".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg", ".oga", ".flac"
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
// Telegram voices come as .oga (ogg audio) — map to .ogg for Whisper compatibility
|
|
20
|
+
const EXT_REMAP = { ".oga": ".ogg" };
|
|
21
|
+
|
|
22
|
+
export async function transcribeAudio(audioPath, prompt) {
|
|
23
|
+
try {
|
|
24
|
+
if (!audioPath) return "Error: audioPath is required";
|
|
25
|
+
|
|
26
|
+
if (!process.env.OPENAI_API_KEY) {
|
|
27
|
+
return "Error: transcribeAudio requires OPENAI_API_KEY (uses OpenAI Whisper API)";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let localPath = audioPath;
|
|
31
|
+
|
|
32
|
+
// Download if URL
|
|
33
|
+
if (audioPath.startsWith("https://") || audioPath.startsWith("http://")) {
|
|
34
|
+
const ext = extname(new URL(audioPath).pathname) || ".ogg";
|
|
35
|
+
const tmpPath = join(tmpdir(), `audio-${Date.now()}${ext}`);
|
|
36
|
+
|
|
37
|
+
const res = await fetch(audioPath, { signal: AbortSignal.timeout(30000) });
|
|
38
|
+
if (!res.ok) return `Error downloading audio: HTTP ${res.status}`;
|
|
39
|
+
|
|
40
|
+
const buffer = await res.arrayBuffer();
|
|
41
|
+
writeFileSync(tmpPath, Buffer.from(buffer));
|
|
42
|
+
localPath = tmpPath;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!existsSync(localPath)) {
|
|
46
|
+
return `Error: Audio file not found: ${localPath}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let ext = extname(localPath).toLowerCase();
|
|
50
|
+
// Remap extensions Whisper doesn't recognise
|
|
51
|
+
if (EXT_REMAP[ext]) {
|
|
52
|
+
ext = EXT_REMAP[ext];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!SUPPORTED_EXTENSIONS.has(ext)) {
|
|
56
|
+
return `Error: Unsupported audio format: ${ext}. Supported: ${[...SUPPORTED_EXTENSIONS].join(", ")}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
60
|
+
|
|
61
|
+
const transcription = await openai.audio.transcriptions.create({
|
|
62
|
+
file: createReadStream(localPath),
|
|
63
|
+
model: "whisper-1",
|
|
64
|
+
prompt: prompt || undefined, // optional context hint
|
|
65
|
+
response_format: "text",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// transcription is a string when response_format is "text"
|
|
69
|
+
const text = typeof transcription === "string"
|
|
70
|
+
? transcription.trim()
|
|
71
|
+
: (transcription.text || "").trim();
|
|
72
|
+
|
|
73
|
+
if (!text) return "Transcription returned empty — audio may be silent or too short.";
|
|
74
|
+
return text;
|
|
75
|
+
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return `Error transcribing audio: ${error.message}`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const transcribeAudioDescription =
|
|
82
|
+
'transcribeAudio(audioPath: string, prompt?: string) — Transcribe a voice/audio file to text using OpenAI Whisper. audioPath: local file path or HTTPS URL. Formats: mp3, mp4, m4a, wav, webm, ogg, flac. Requires OPENAI_API_KEY.';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { runMCPAgent } from "../mcp/MCPAgentRunner.js";
|
|
2
|
+
import tenantContext from "../tenants/TenantContext.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* useMCP — delegate a task to a specialist agent for a specific MCP server.
|
|
6
|
+
*
|
|
7
|
+
* The specialist agent receives ONLY that server's tools and a focused system prompt.
|
|
8
|
+
* This keeps context lean: main agent stays uncluttered, specialist stays focused.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} serverName - MCP server name (e.g. "github", "notion", "slack")
|
|
11
|
+
* @param {string} taskDescription - Full task spec — the agent has no other context
|
|
12
|
+
* @returns {Promise<string>} - Specialist agent's final response
|
|
13
|
+
*/
|
|
14
|
+
export async function useMCP(serverName, taskDescription) {
|
|
15
|
+
// Enforce per-tenant MCP server allowlist
|
|
16
|
+
const store = tenantContext.getStore();
|
|
17
|
+
const allowedMcpServers = store?.resolvedConfig?.mcpServers ?? null;
|
|
18
|
+
if (allowedMcpServers !== null && !allowedMcpServers.includes(serverName)) {
|
|
19
|
+
return `Access denied: MCP server "${serverName}" is not in your allowed list. Contact the operator.`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return runMCPAgent(serverName, taskDescription);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const useMCPDescription =
|
|
26
|
+
`useMCP(serverName: string, taskDescription: string) - Delegate a task to a specialist MCP agent for the named server.
|
|
27
|
+
- serverName: the MCP server to use (use manageMCP("list") to see available servers)
|
|
28
|
+
- taskDescription: comprehensive task spec — the specialist has no other context, so include all details
|
|
29
|
+
- The specialist gets ONLY that server's tools — lean context, no confusion with built-in tools`;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* webFetch(url, optionsJson?) — Fetch URL content with proper HTML conversion, caching, and SSRF protection.
|
|
3
|
+
* Upgraded: html-to-text library, 15-min cache, SSRF guard, 50K char limit, GitHub URL conversion.
|
|
4
|
+
*/
|
|
5
|
+
import { convert } from "html-to-text";
|
|
6
|
+
import { URL } from "node:url";
|
|
7
|
+
|
|
8
|
+
// Private IP ranges — SSRF protection
|
|
9
|
+
const PRIVATE_RANGES = [
|
|
10
|
+
/^127\./,
|
|
11
|
+
/^10\./,
|
|
12
|
+
/^172\.(1[6-9]|2[0-9]|3[01])\./,
|
|
13
|
+
/^192\.168\./,
|
|
14
|
+
/^169\.254\./,
|
|
15
|
+
/^0\./,
|
|
16
|
+
/^::1$/,
|
|
17
|
+
/^fc00:/i,
|
|
18
|
+
/^fe80:/i,
|
|
19
|
+
/^localhost$/i,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
// Response cache: url → { content, expiresAt }
|
|
23
|
+
const cache = new Map();
|
|
24
|
+
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
|
25
|
+
const MAX_CACHE_SIZE = 100;
|
|
26
|
+
|
|
27
|
+
function isPrivateIP(hostname) {
|
|
28
|
+
return PRIVATE_RANGES.some((r) => r.test(hostname));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function convertGitHubUrl(url) {
|
|
32
|
+
// Convert GitHub blob URLs to raw content URLs
|
|
33
|
+
const match = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/(.+)$/);
|
|
34
|
+
if (match) {
|
|
35
|
+
return `https://raw.githubusercontent.com/${match[1]}/${match[2]}/${match[3]}`;
|
|
36
|
+
}
|
|
37
|
+
return url;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function checkCache(url) {
|
|
41
|
+
const entry = cache.get(url);
|
|
42
|
+
if (entry && Date.now() < entry.expiresAt) {
|
|
43
|
+
return entry.content;
|
|
44
|
+
}
|
|
45
|
+
if (entry) cache.delete(url); // expired
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function setCache(url, content) {
|
|
50
|
+
if (cache.size >= MAX_CACHE_SIZE) {
|
|
51
|
+
// Evict oldest entry
|
|
52
|
+
const firstKey = cache.keys().next().value;
|
|
53
|
+
cache.delete(firstKey);
|
|
54
|
+
}
|
|
55
|
+
cache.set(url, { content, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function webFetch(url, optionsJson) {
|
|
59
|
+
const opts = optionsJson ? JSON.parse(optionsJson) : {};
|
|
60
|
+
const maxChars = opts.maxChars ? parseInt(opts.maxChars) : 50000;
|
|
61
|
+
|
|
62
|
+
console.log(` [webFetch] Fetching: ${url}`);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
// Validate URL
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = new URL(url);
|
|
69
|
+
} catch {
|
|
70
|
+
return `Error: Invalid URL: ${url}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
74
|
+
return `Error: Only http and https URLs are supported (got ${parsed.protocol})`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// SSRF protection
|
|
78
|
+
if (isPrivateIP(parsed.hostname)) {
|
|
79
|
+
return `Error: Access to private/internal addresses is not allowed: ${parsed.hostname}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// GitHub blob → raw URL
|
|
83
|
+
url = convertGitHubUrl(url);
|
|
84
|
+
|
|
85
|
+
// Check cache
|
|
86
|
+
const cached = checkCache(url);
|
|
87
|
+
if (cached) {
|
|
88
|
+
console.log(` [webFetch] Cache hit for ${url}`);
|
|
89
|
+
return cached;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const startTime = Date.now();
|
|
93
|
+
const response = await fetch(url, {
|
|
94
|
+
headers: {
|
|
95
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
96
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
97
|
+
"Accept-Language": "en-US,en;q=0.5",
|
|
98
|
+
},
|
|
99
|
+
signal: AbortSignal.timeout(15000),
|
|
100
|
+
redirect: "follow",
|
|
101
|
+
});
|
|
102
|
+
const elapsed = Date.now() - startTime;
|
|
103
|
+
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
return `HTTP Error ${response.status}: ${response.statusText}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const contentType = response.headers.get("content-type") || "";
|
|
109
|
+
console.log(` [webFetch] ${response.status} | ${contentType} | ${elapsed}ms`);
|
|
110
|
+
|
|
111
|
+
let result;
|
|
112
|
+
|
|
113
|
+
if (contentType.includes("application/json")) {
|
|
114
|
+
const json = await response.json();
|
|
115
|
+
result = JSON.stringify(json, null, 2).slice(0, maxChars);
|
|
116
|
+
} else if (contentType.includes("text/html")) {
|
|
117
|
+
const html = await response.text();
|
|
118
|
+
// Use html-to-text for proper conversion
|
|
119
|
+
result = convert(html, {
|
|
120
|
+
wordwrap: 120,
|
|
121
|
+
selectors: [
|
|
122
|
+
{ selector: "a", options: { ignoreHref: false } },
|
|
123
|
+
{ selector: "img", format: "skip" },
|
|
124
|
+
{ selector: "script", format: "skip" },
|
|
125
|
+
{ selector: "style", format: "skip" },
|
|
126
|
+
{ selector: "nav", format: "skip" },
|
|
127
|
+
{ selector: "footer", format: "skip" },
|
|
128
|
+
{ selector: "header", options: { uppercase: false } },
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
result = result.replace(/\n{3,}/g, "\n\n").trim().slice(0, maxChars);
|
|
132
|
+
} else {
|
|
133
|
+
result = (await response.text()).slice(0, maxChars);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (result.length === maxChars) {
|
|
137
|
+
result += `\n\n[Content truncated at ${maxChars} chars. Use optionsJson '{"maxChars":100000}' for more.]`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.log(` [webFetch] Got ${result.length} chars`);
|
|
141
|
+
setCache(url, result);
|
|
142
|
+
return result;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
console.log(` [webFetch] Failed: ${error.message}`);
|
|
145
|
+
return `Error fetching URL: ${error.message}`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const webFetchDescription =
|
|
150
|
+
'webFetch(url: string, optionsJson?: string) - Fetch content from a URL. HTML is properly converted to readable text. optionsJson: {"maxChars":50000}. Responses are cached for 15 minutes. Private/internal URLs are blocked for security.';
|