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.
Files changed (115) hide show
  1. package/README.md +666 -0
  2. package/SOUL.md +104 -0
  3. package/config/hooks.json +14 -0
  4. package/config/mcp.json +145 -0
  5. package/package.json +86 -0
  6. package/skills/.gitkeep +0 -0
  7. package/skills/apple-notes.md +193 -0
  8. package/skills/apple-reminders.md +189 -0
  9. package/skills/camsnap.md +162 -0
  10. package/skills/coding.md +14 -0
  11. package/skills/documents.md +13 -0
  12. package/skills/email.md +13 -0
  13. package/skills/gif-search.md +196 -0
  14. package/skills/healthcheck.md +225 -0
  15. package/skills/image-gen.md +147 -0
  16. package/skills/model-usage.md +182 -0
  17. package/skills/obsidian.md +207 -0
  18. package/skills/pdf.md +211 -0
  19. package/skills/research.md +13 -0
  20. package/skills/skill-creator.md +142 -0
  21. package/skills/spotify.md +149 -0
  22. package/skills/summarize.md +230 -0
  23. package/skills/things.md +199 -0
  24. package/skills/tmux.md +204 -0
  25. package/skills/trello.md +183 -0
  26. package/skills/video-frames.md +202 -0
  27. package/skills/weather.md +127 -0
  28. package/src/a2a/A2AClient.js +136 -0
  29. package/src/a2a/A2AServer.js +316 -0
  30. package/src/a2a/AgentCard.js +79 -0
  31. package/src/agents/SubAgentManager.js +369 -0
  32. package/src/agents/Supervisor.js +192 -0
  33. package/src/channels/BaseChannel.js +104 -0
  34. package/src/channels/DiscordChannel.js +288 -0
  35. package/src/channels/EmailChannel.js +172 -0
  36. package/src/channels/GoogleChatChannel.js +316 -0
  37. package/src/channels/HttpChannel.js +26 -0
  38. package/src/channels/LineChannel.js +168 -0
  39. package/src/channels/SignalChannel.js +186 -0
  40. package/src/channels/SlackChannel.js +329 -0
  41. package/src/channels/TeamsChannel.js +272 -0
  42. package/src/channels/TelegramChannel.js +347 -0
  43. package/src/channels/WhatsAppChannel.js +219 -0
  44. package/src/channels/index.js +198 -0
  45. package/src/cli.js +1267 -0
  46. package/src/config/agentProfiles.js +120 -0
  47. package/src/config/channels.js +32 -0
  48. package/src/config/default.js +206 -0
  49. package/src/config/models.js +123 -0
  50. package/src/config/permissions.js +167 -0
  51. package/src/core/AgentLoop.js +446 -0
  52. package/src/core/Compaction.js +143 -0
  53. package/src/core/CostTracker.js +116 -0
  54. package/src/core/EventBus.js +46 -0
  55. package/src/core/Task.js +67 -0
  56. package/src/core/TaskQueue.js +206 -0
  57. package/src/core/TaskRunner.js +226 -0
  58. package/src/daemon/DaemonManager.js +301 -0
  59. package/src/hooks/HookRunner.js +230 -0
  60. package/src/index.js +482 -0
  61. package/src/mcp/MCPAgentRunner.js +112 -0
  62. package/src/mcp/MCPClient.js +186 -0
  63. package/src/mcp/MCPManager.js +412 -0
  64. package/src/models/ModelRouter.js +180 -0
  65. package/src/safety/AuditLog.js +135 -0
  66. package/src/safety/CircuitBreaker.js +126 -0
  67. package/src/safety/FilesystemGuard.js +169 -0
  68. package/src/safety/GitRollback.js +139 -0
  69. package/src/safety/HumanApproval.js +156 -0
  70. package/src/safety/InputSanitizer.js +72 -0
  71. package/src/safety/PermissionGuard.js +83 -0
  72. package/src/safety/Sandbox.js +70 -0
  73. package/src/safety/SecretScanner.js +100 -0
  74. package/src/safety/SecretVault.js +250 -0
  75. package/src/scheduler/Heartbeat.js +115 -0
  76. package/src/scheduler/Scheduler.js +228 -0
  77. package/src/services/models/outputSchema.js +15 -0
  78. package/src/services/openai.js +25 -0
  79. package/src/services/sessions.js +65 -0
  80. package/src/setup/theme.js +110 -0
  81. package/src/setup/wizard.js +788 -0
  82. package/src/skills/SkillLoader.js +168 -0
  83. package/src/storage/TaskStore.js +69 -0
  84. package/src/systemPrompt.js +526 -0
  85. package/src/tenants/TenantContext.js +19 -0
  86. package/src/tenants/TenantManager.js +379 -0
  87. package/src/tools/ToolRegistry.js +141 -0
  88. package/src/tools/applyPatch.js +144 -0
  89. package/src/tools/browserAutomation.js +223 -0
  90. package/src/tools/createDocument.js +265 -0
  91. package/src/tools/cronTool.js +105 -0
  92. package/src/tools/editFile.js +139 -0
  93. package/src/tools/executeCommand.js +123 -0
  94. package/src/tools/glob.js +67 -0
  95. package/src/tools/grep.js +121 -0
  96. package/src/tools/imageAnalysis.js +120 -0
  97. package/src/tools/index.js +173 -0
  98. package/src/tools/listDirectory.js +47 -0
  99. package/src/tools/manageAgents.js +47 -0
  100. package/src/tools/manageMCP.js +159 -0
  101. package/src/tools/memory.js +478 -0
  102. package/src/tools/messageChannel.js +45 -0
  103. package/src/tools/projectTracker.js +259 -0
  104. package/src/tools/readFile.js +52 -0
  105. package/src/tools/screenCapture.js +112 -0
  106. package/src/tools/searchContent.js +76 -0
  107. package/src/tools/searchFiles.js +75 -0
  108. package/src/tools/sendEmail.js +118 -0
  109. package/src/tools/sendFile.js +63 -0
  110. package/src/tools/textToSpeech.js +161 -0
  111. package/src/tools/transcribeAudio.js +82 -0
  112. package/src/tools/useMCP.js +29 -0
  113. package/src/tools/webFetch.js +150 -0
  114. package/src/tools/webSearch.js +134 -0
  115. 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.';