daemora 1.0.2 → 1.0.3
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 +106 -76
- package/SOUL.md +100 -28
- package/config/mcp.json +9 -9
- package/package.json +15 -8
- package/skills/apple-notes.md +0 -52
- package/skills/apple-reminders.md +1 -87
- package/skills/camsnap.md +20 -144
- package/skills/coding.md +7 -7
- package/skills/documents.md +6 -6
- package/skills/email.md +6 -6
- package/skills/gif-search.md +28 -171
- package/skills/healthcheck.md +21 -203
- package/skills/image-gen.md +24 -123
- package/skills/model-usage.md +18 -165
- package/skills/obsidian.md +28 -174
- package/skills/pdf.md +30 -181
- package/skills/research.md +6 -6
- package/skills/skill-creator.md +35 -111
- package/skills/spotify.md +2 -17
- package/skills/summarize.md +36 -193
- package/skills/things.md +23 -175
- package/skills/tmux.md +1 -91
- package/skills/trello.md +32 -157
- package/skills/video-frames.md +26 -166
- package/skills/weather.md +6 -6
- package/src/a2a/A2AClient.js +2 -2
- package/src/a2a/A2AServer.js +6 -6
- package/src/a2a/AgentCard.js +2 -2
- package/src/agents/SubAgentManager.js +61 -19
- package/src/agents/Supervisor.js +4 -4
- package/src/channels/BaseChannel.js +6 -6
- package/src/channels/BlueBubblesChannel.js +112 -0
- package/src/channels/DiscordChannel.js +8 -8
- package/src/channels/EmailChannel.js +54 -26
- package/src/channels/FeishuChannel.js +140 -0
- package/src/channels/GoogleChatChannel.js +8 -8
- package/src/channels/HttpChannel.js +2 -2
- package/src/channels/IRCChannel.js +144 -0
- package/src/channels/LineChannel.js +13 -13
- package/src/channels/MatrixChannel.js +97 -0
- package/src/channels/MattermostChannel.js +119 -0
- package/src/channels/NextcloudChannel.js +133 -0
- package/src/channels/NostrChannel.js +175 -0
- package/src/channels/SignalChannel.js +9 -9
- package/src/channels/SlackChannel.js +10 -10
- package/src/channels/TeamsChannel.js +10 -10
- package/src/channels/TelegramChannel.js +8 -8
- package/src/channels/TwitchChannel.js +128 -0
- package/src/channels/WhatsAppChannel.js +10 -10
- package/src/channels/ZaloChannel.js +119 -0
- package/src/channels/iMessageChannel.js +150 -0
- package/src/channels/index.js +241 -11
- package/src/cli.js +834 -37
- package/src/config/agentProfiles.js +19 -19
- package/src/config/channels.js +1 -1
- package/src/config/default.js +12 -7
- package/src/config/models.js +3 -3
- package/src/config/permissions.js +2 -2
- package/src/core/AgentLoop.js +13 -13
- package/src/core/Compaction.js +3 -3
- package/src/core/CostTracker.js +2 -2
- package/src/core/EventBus.js +15 -15
- package/src/core/TaskQueue.js +24 -7
- package/src/core/TaskRunner.js +19 -6
- package/src/daemon/DaemonManager.js +4 -4
- package/src/hooks/HookRunner.js +4 -4
- package/src/index.js +6 -2
- package/src/mcp/MCPAgentRunner.js +3 -3
- package/src/mcp/MCPClient.js +9 -9
- package/src/mcp/MCPManager.js +14 -14
- package/src/models/ModelRouter.js +2 -2
- package/src/safety/AuditLog.js +3 -3
- package/src/safety/CircuitBreaker.js +2 -2
- package/src/safety/CommandGuard.js +132 -0
- package/src/safety/FilesystemGuard.js +23 -3
- package/src/safety/GitRollback.js +5 -5
- package/src/safety/HumanApproval.js +9 -9
- package/src/safety/InputSanitizer.js +81 -8
- package/src/safety/PermissionGuard.js +2 -2
- package/src/safety/Sandbox.js +1 -1
- package/src/safety/SecretScanner.js +90 -28
- package/src/safety/SecretVault.js +2 -2
- package/src/scheduler/Heartbeat.js +3 -3
- package/src/scheduler/Scheduler.js +6 -6
- package/src/setup/theme.js +171 -66
- package/src/setup/wizard.js +432 -57
- package/src/skills/SkillLoader.js +145 -8
- package/src/storage/TaskStore.js +39 -15
- package/src/systemPrompt.js +45 -43
- package/src/tenants/TenantManager.js +79 -22
- package/src/tools/ToolRegistry.js +3 -3
- package/src/tools/applyPatch.js +2 -2
- package/src/tools/browserAutomation.js +4 -4
- package/src/tools/calendar.js +155 -0
- package/src/tools/clipboard.js +71 -0
- package/src/tools/contacts.js +138 -0
- package/src/tools/createDocument.js +2 -2
- package/src/tools/cronTool.js +14 -14
- package/src/tools/database.js +165 -0
- package/src/tools/editFile.js +10 -10
- package/src/tools/executeCommand.js +11 -3
- package/src/tools/generateImage.js +79 -0
- package/src/tools/gitTool.js +141 -0
- package/src/tools/glob.js +1 -1
- package/src/tools/googlePlaces.js +136 -0
- package/src/tools/grep.js +2 -2
- package/src/tools/iMessageTool.js +86 -0
- package/src/tools/imageAnalysis.js +3 -3
- package/src/tools/index.js +56 -2
- package/src/tools/makeVoiceCall.js +283 -0
- package/src/tools/manageAgents.js +2 -2
- package/src/tools/manageMCP.js +38 -20
- package/src/tools/memory.js +25 -32
- package/src/tools/messageChannel.js +1 -1
- package/src/tools/notification.js +90 -0
- package/src/tools/philipsHue.js +147 -0
- package/src/tools/projectTracker.js +8 -8
- package/src/tools/readFile.js +1 -1
- package/src/tools/readPDF.js +73 -0
- package/src/tools/screenCapture.js +6 -6
- package/src/tools/searchContent.js +2 -2
- package/src/tools/searchFiles.js +1 -1
- package/src/tools/sendEmail.js +79 -24
- package/src/tools/sendFile.js +4 -4
- package/src/tools/sonos.js +137 -0
- package/src/tools/sshTool.js +130 -0
- package/src/tools/textToSpeech.js +5 -5
- package/src/tools/transcribeAudio.js +4 -4
- package/src/tools/useMCP.js +4 -4
- package/src/tools/webFetch.js +2 -2
- package/src/tools/webSearch.js +1 -1
- package/src/utils/Embeddings.js +79 -0
- package/src/voice/VoiceSessionManager.js +170 -0
- package/src/voice/VoiceWebhook.js +188 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* screenCapture(optionsJson?)
|
|
2
|
+
* screenCapture(optionsJson?) - Take a screenshot or record a screen video.
|
|
3
3
|
*
|
|
4
4
|
* Modes:
|
|
5
|
-
* screenshot (default)
|
|
6
|
-
* video
|
|
5
|
+
* screenshot (default) - single still image (PNG)
|
|
6
|
+
* video - screen recording (MP4), uses `duration` seconds (default 10)
|
|
7
7
|
*
|
|
8
8
|
* macOS: uses built-in `screencapture` command.
|
|
9
9
|
* Linux: screenshots via ImageMagick/gnome-screenshot/scrot; video via ffmpeg.
|
|
@@ -20,9 +20,9 @@ export function screenCapture(optionsJson) {
|
|
|
20
20
|
try {
|
|
21
21
|
const opts = optionsJson ? JSON.parse(optionsJson) : {};
|
|
22
22
|
const outputDir = opts.outputDir || "/tmp";
|
|
23
|
-
const region = opts.region; // { x, y, width, height }
|
|
23
|
+
const region = opts.region; // { x, y, width, height } - screenshot only
|
|
24
24
|
const mode = (opts.mode || "screenshot").toLowerCase();
|
|
25
|
-
const duration = parseInt(opts.duration || "10", 10); // seconds
|
|
25
|
+
const duration = parseInt(opts.duration || "10", 10); // seconds - video only
|
|
26
26
|
|
|
27
27
|
if (!existsSync(outputDir)) {
|
|
28
28
|
mkdirSync(outputDir, { recursive: true });
|
|
@@ -106,7 +106,7 @@ export function screenCapture(optionsJson) {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
export const screenCaptureDescription =
|
|
109
|
-
'screenCapture(optionsJson?)
|
|
109
|
+
'screenCapture(optionsJson?) - Capture a screenshot or record a screen video. ' +
|
|
110
110
|
'optionsJson: {"mode":"screenshot"|"video","outputDir":"/tmp","duration":10,"region":{"x":0,"y":0,"width":800,"height":600}}. ' +
|
|
111
111
|
'mode defaults to "screenshot". duration (seconds) only applies to video mode. ' +
|
|
112
112
|
'Returns the file path. Chain with imageAnalysis to analyze screenshots, or sendFile to deliver to user.';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* searchContent(pattern, directory?, optionsJson?)
|
|
2
|
+
* searchContent(pattern, directory?, optionsJson?) - Search file contents.
|
|
3
3
|
* Upgraded: context lines, case-insensitive, file type filter, extended regex support.
|
|
4
4
|
*/
|
|
5
5
|
import { execSync } from "node:child_process";
|
|
@@ -63,7 +63,7 @@ export function searchContent(pattern, directory = ".", optionsJson) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
const lines = trimmed.split("\n");
|
|
66
|
-
const suffix = lines.length >= limit ? ` (limit: ${limit}, may have more
|
|
66
|
+
const suffix = lines.length >= limit ? ` (limit: ${limit}, may have more - increase with optionsJson {"limit":200})` : "";
|
|
67
67
|
console.log(` [searchContent] Found ${lines.length} match(es)`);
|
|
68
68
|
return `Found ${lines.length} match(es) for "${pattern}"${suffix}:\n\n${trimmed}`;
|
|
69
69
|
} catch (error) {
|
package/src/tools/searchFiles.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* searchFiles(pattern, directory?, optionsJson?)
|
|
2
|
+
* searchFiles(pattern, directory?, optionsJson?) - Find files by name pattern.
|
|
3
3
|
* Upgraded: modification time sorting, depth control, size filters.
|
|
4
4
|
*/
|
|
5
5
|
import { execSync } from "node:child_process";
|
package/src/tools/sendEmail.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Send Email
|
|
3
|
-
*
|
|
2
|
+
* Send Email - sends email via SMTP or Resend (nodemailer).
|
|
3
|
+
*
|
|
4
|
+
* Credential resolution order (first match wins):
|
|
5
|
+
* 1. Per-tenant channel config (daemora tenant channel set <id> resend_api_key ...)
|
|
6
|
+
* 2. Global .env (RESEND_API_KEY / EMAIL_USER + EMAIL_PASSWORD)
|
|
7
|
+
*
|
|
8
|
+
* This means each tenant can use their own email credentials without affecting others.
|
|
9
|
+
* Concurrent requests are safe — tenant credentials are never written to process.env.
|
|
4
10
|
*/
|
|
5
11
|
|
|
12
|
+
import tenantContext from "../tenants/TenantContext.js";
|
|
13
|
+
|
|
6
14
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
7
15
|
|
|
8
16
|
function validateEmail(addr) {
|
|
@@ -14,29 +22,76 @@ function parseAddressList(val) {
|
|
|
14
22
|
return val.split(",").map((a) => a.trim()).filter(Boolean);
|
|
15
23
|
}
|
|
16
24
|
|
|
17
|
-
|
|
25
|
+
// Module-level singleton for global (non-tenant) SMTP — reused across requests for performance.
|
|
26
|
+
// Tenant-specific transporters are always fresh (never cached) to avoid cross-tenant bleed.
|
|
27
|
+
let _globalTransporter = null;
|
|
18
28
|
|
|
19
29
|
async function getTransporter() {
|
|
20
|
-
|
|
30
|
+
const store = tenantContext.getStore();
|
|
31
|
+
const ch = store?.resolvedConfig?.channelConfig || {};
|
|
21
32
|
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
33
|
+
// Resolve credentials: tenant config > global env
|
|
34
|
+
const resendKey = ch.resend_api_key || process.env.RESEND_API_KEY || null;
|
|
35
|
+
const resendFrom = ch.resend_from || process.env.RESEND_FROM || null;
|
|
36
|
+
const smtpUser = ch.email || process.env.EMAIL_USER || null;
|
|
37
|
+
const smtpPass = ch.email_password || process.env.EMAIL_PASSWORD || null;
|
|
38
|
+
const smtpHost = process.env.EMAIL_SMTP_HOST || "smtp.gmail.com";
|
|
39
|
+
const smtpPort = parseInt(process.env.EMAIL_SMTP_PORT || "587", 10);
|
|
26
40
|
|
|
27
|
-
if (!
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
41
|
+
if (!resendKey && !smtpUser) return { transporter: null, from: null };
|
|
30
42
|
|
|
31
43
|
const nodemailer = await import("nodemailer");
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
const hasTenantCreds = !!(ch.resend_api_key || ch.email);
|
|
45
|
+
|
|
46
|
+
if (hasTenantCreds) {
|
|
47
|
+
// Tenant-specific: always create a fresh transporter (never cache — different per tenant)
|
|
48
|
+
if (resendKey) {
|
|
49
|
+
return {
|
|
50
|
+
transporter: nodemailer.default.createTransport({
|
|
51
|
+
host: "smtp.resend.com",
|
|
52
|
+
port: 465,
|
|
53
|
+
secure: true,
|
|
54
|
+
auth: { user: "resend", pass: resendKey },
|
|
55
|
+
}),
|
|
56
|
+
from: resendFrom || `daemora@resend.dev`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
transporter: nodemailer.default.createTransport({
|
|
61
|
+
host: smtpHost,
|
|
62
|
+
port: smtpPort,
|
|
63
|
+
secure: smtpPort === 465,
|
|
64
|
+
auth: { user: smtpUser, pass: smtpPass },
|
|
65
|
+
}),
|
|
66
|
+
from: smtpUser,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Global config: use singleton cache
|
|
71
|
+
if (!_globalTransporter) {
|
|
72
|
+
if (resendKey) {
|
|
73
|
+
_globalTransporter = {
|
|
74
|
+
transporter: nodemailer.default.createTransport({
|
|
75
|
+
host: "smtp.resend.com",
|
|
76
|
+
port: 465,
|
|
77
|
+
secure: true,
|
|
78
|
+
auth: { user: "resend", pass: resendKey },
|
|
79
|
+
}),
|
|
80
|
+
from: resendFrom || `daemora@resend.dev`,
|
|
81
|
+
};
|
|
82
|
+
} else {
|
|
83
|
+
_globalTransporter = {
|
|
84
|
+
transporter: nodemailer.default.createTransport({
|
|
85
|
+
host: smtpHost,
|
|
86
|
+
port: smtpPort,
|
|
87
|
+
secure: smtpPort === 465,
|
|
88
|
+
auth: { user: smtpUser, pass: smtpPass },
|
|
89
|
+
}),
|
|
90
|
+
from: smtpUser,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return _globalTransporter;
|
|
40
95
|
}
|
|
41
96
|
|
|
42
97
|
export async function sendEmail(to, subject, body, optionsJson) {
|
|
@@ -53,7 +108,7 @@ export async function sendEmail(to, subject, body, optionsJson) {
|
|
|
53
108
|
const cc = opts.cc ? parseAddressList(opts.cc) : null;
|
|
54
109
|
const bcc = opts.bcc ? parseAddressList(opts.bcc) : null;
|
|
55
110
|
const replyTo = opts.replyTo || null;
|
|
56
|
-
const attachments = Array.isArray(opts.attachments) ? opts.attachments : null;
|
|
111
|
+
const attachments = Array.isArray(opts.attachments) ? opts.attachments : null;
|
|
57
112
|
|
|
58
113
|
// Validate addresses
|
|
59
114
|
const toList = parseAddressList(to);
|
|
@@ -74,14 +129,14 @@ export async function sendEmail(to, subject, body, optionsJson) {
|
|
|
74
129
|
|
|
75
130
|
console.log(` [sendEmail] To: ${to} | Subject: "${subject}"${cc ? ` | CC: ${cc.join(",")}` : ""}${bcc ? ` | BCC: ${bcc.join(",")}` : ""}`);
|
|
76
131
|
|
|
77
|
-
const smtp = await getTransporter();
|
|
132
|
+
const { transporter: smtp, from } = await getTransporter();
|
|
78
133
|
if (!smtp) {
|
|
79
|
-
return "Error: Email not configured. Set
|
|
134
|
+
return "Error: Email not configured. Set RESEND_API_KEY or EMAIL_USER+EMAIL_PASSWORD in .env, or use: daemora tenant channel set <id> resend_api_key <key>";
|
|
80
135
|
}
|
|
81
136
|
|
|
82
137
|
try {
|
|
83
138
|
const mailOptions = {
|
|
84
|
-
from
|
|
139
|
+
from,
|
|
85
140
|
to: toList.join(", "),
|
|
86
141
|
subject,
|
|
87
142
|
text: body,
|
|
@@ -115,4 +170,4 @@ export async function sendEmail(to, subject, body, optionsJson) {
|
|
|
115
170
|
}
|
|
116
171
|
|
|
117
172
|
export const sendEmailDescription =
|
|
118
|
-
'sendEmail(to: string, subject: string, body: string, optionsJson?: string) - Send email
|
|
173
|
+
'sendEmail(to: string, subject: string, body: string, optionsJson?: string) - Send email. Uses per-tenant channel config if set (daemora tenant channel set), otherwise falls back to global RESEND_API_KEY or EMAIL_USER+EMAIL_PASSWORD. optionsJson: {"cc":"a@b.com","bcc":"e@f.com","replyTo":"r@s.com","attachments":[{"filename":"report.pdf","path":"/tmp/report.pdf"}]}';
|
package/src/tools/sendFile.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* sendFile(channel, target, filePath, caption?)
|
|
2
|
+
* sendFile(channel, target, filePath, caption?) - Send a file/image/video to a user on any channel.
|
|
3
3
|
*
|
|
4
4
|
* The agent uses this to proactively deliver:
|
|
5
5
|
* - Screenshots it captured (screenCapture → sendFile)
|
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
* - Any other file the user should receive
|
|
10
10
|
*
|
|
11
11
|
* channel: "telegram" | "discord" | "slack" | "whatsapp" | "email"
|
|
12
|
-
* target: chat ID, user ID, channel ID, phone number, or email
|
|
12
|
+
* target: chat ID, user ID, channel ID, phone number, or email - depends on channel
|
|
13
13
|
* filePath: absolute path to the local file to send
|
|
14
14
|
* caption: optional text caption alongside the file
|
|
15
15
|
*/
|
|
16
16
|
import channelRegistry from "../channels/index.js";
|
|
17
17
|
import { existsSync, statSync } from "node:fs";
|
|
18
18
|
|
|
19
|
-
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
19
|
+
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB - most platforms limit around this
|
|
20
20
|
|
|
21
21
|
export async function sendFile(channel, target, filePath, caption) {
|
|
22
22
|
try {
|
|
@@ -56,7 +56,7 @@ export async function sendFile(channel, target, filePath, caption) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
export const sendFileDescription =
|
|
59
|
-
'sendFile(channel, target, filePath, caption?)
|
|
59
|
+
'sendFile(channel, target, filePath, caption?) - Send a file, image, or video to a user. ' +
|
|
60
60
|
'channel: "telegram"|"discord"|"slack"|"email". ' +
|
|
61
61
|
'target: chat ID (Telegram), user/channel ID (Discord/Slack), or email. ' +
|
|
62
62
|
'filePath: absolute path to the file. caption: optional text alongside the file. ' +
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sonos - Control Sonos speakers via local network API.
|
|
3
|
+
* Uses the Sonos UPNP/SOAP API or the newer Sonos Control API (cloud).
|
|
4
|
+
* Local control (no cloud): sends SOAP requests to speaker IP on port 1400.
|
|
5
|
+
* Requires SONOS_SPEAKER_IP or uses discovery.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const SONOS_PORT = 1400;
|
|
9
|
+
|
|
10
|
+
async function sonosSoap(speakerIp, service, action, body = "") {
|
|
11
|
+
const fetchFn = globalThis.fetch || (await import("node-fetch")).default;
|
|
12
|
+
const serviceMap = {
|
|
13
|
+
"AVTransport": { path: "/MediaRenderer/AVTransport/Control", xmlns: "urn:schemas-upnp-org:service:AVTransport:1" },
|
|
14
|
+
"RenderingControl": { path: "/MediaRenderer/RenderingControl/Control", xmlns: "urn:schemas-upnp-org:service:RenderingControl:1" },
|
|
15
|
+
"ZoneGroupTopology": { path: "/ZoneGroupTopology/Control", xmlns: "urn:schemas-upnp-org:service:ZoneGroupTopology:1" },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const svc = serviceMap[service];
|
|
19
|
+
if (!svc) throw new Error(`Unknown service: ${service}`);
|
|
20
|
+
|
|
21
|
+
const envelope = `<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:${action} xmlns:u="${svc.xmlns}">${body}</u:${action}></s:Body></s:Envelope>`;
|
|
22
|
+
|
|
23
|
+
const res = await fetchFn(`http://${speakerIp}:${SONOS_PORT}${svc.path}`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: {
|
|
26
|
+
"Content-Type": "text/xml; charset=utf-8",
|
|
27
|
+
"SOAPAction": `"${svc.xmlns}#${action}"`,
|
|
28
|
+
},
|
|
29
|
+
body: envelope,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const text = await res.text();
|
|
33
|
+
if (!res.ok) throw new Error(`SOAP error ${res.status}: ${text.slice(0, 200)}`);
|
|
34
|
+
return text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function sonos(action, paramsJson) {
|
|
38
|
+
if (!action) return "Error: action required. Valid: play, pause, stop, next, prev, volume, mute, queue, info";
|
|
39
|
+
const params = paramsJson
|
|
40
|
+
? (typeof paramsJson === "string" ? JSON.parse(paramsJson) : paramsJson)
|
|
41
|
+
: {};
|
|
42
|
+
|
|
43
|
+
const speakerIp = params.speakerIp || process.env.SONOS_SPEAKER_IP;
|
|
44
|
+
if (!speakerIp) return "Error: SONOS_SPEAKER_IP env var or speakerIp param required";
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
if (action === "play") {
|
|
48
|
+
await sonosSoap(speakerIp, "AVTransport", "Play", "<InstanceID>0</InstanceID><Speed>1</Speed>");
|
|
49
|
+
return "Playback started";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (action === "pause") {
|
|
53
|
+
await sonosSoap(speakerIp, "AVTransport", "Pause", "<InstanceID>0</InstanceID>");
|
|
54
|
+
return "Playback paused";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (action === "stop") {
|
|
58
|
+
await sonosSoap(speakerIp, "AVTransport", "Stop", "<InstanceID>0</InstanceID>");
|
|
59
|
+
return "Playback stopped";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (action === "next") {
|
|
63
|
+
await sonosSoap(speakerIp, "AVTransport", "Next", "<InstanceID>0</InstanceID>");
|
|
64
|
+
return "Skipped to next track";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (action === "prev" || action === "previous") {
|
|
68
|
+
await sonosSoap(speakerIp, "AVTransport", "Previous", "<InstanceID>0</InstanceID>");
|
|
69
|
+
return "Went to previous track";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (action === "volume") {
|
|
73
|
+
const { level } = params;
|
|
74
|
+
if (level === undefined) {
|
|
75
|
+
// Get current volume
|
|
76
|
+
const xml = await sonosSoap(speakerIp, "RenderingControl", "GetVolume",
|
|
77
|
+
"<InstanceID>0</InstanceID><Channel>Master</Channel>");
|
|
78
|
+
const match = xml.match(/<CurrentVolume>(\d+)<\/CurrentVolume>/);
|
|
79
|
+
return `Current volume: ${match?.[1] || "unknown"}`;
|
|
80
|
+
}
|
|
81
|
+
const vol = Math.max(0, Math.min(100, Math.round(level)));
|
|
82
|
+
await sonosSoap(speakerIp, "RenderingControl", "SetVolume",
|
|
83
|
+
`<InstanceID>0</InstanceID><Channel>Master</Channel><DesiredVolume>${vol}</DesiredVolume>`);
|
|
84
|
+
return `Volume set to ${vol}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (action === "mute") {
|
|
88
|
+
const muted = params.muted !== false; // default true (mute)
|
|
89
|
+
await sonosSoap(speakerIp, "RenderingControl", "SetMute",
|
|
90
|
+
`<InstanceID>0</InstanceID><Channel>Master</Channel><DesiredMute>${muted ? "1" : "0"}</DesiredMute>`);
|
|
91
|
+
return muted ? "Speaker muted" : "Speaker unmuted";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (action === "info") {
|
|
95
|
+
const xml = await sonosSoap(speakerIp, "AVTransport", "GetTransportInfo", "<InstanceID>0</InstanceID>");
|
|
96
|
+
const stateMatch = xml.match(/<CurrentTransportState>([^<]+)<\/CurrentTransportState>/);
|
|
97
|
+
|
|
98
|
+
const posXml = await sonosSoap(speakerIp, "AVTransport", "GetPositionInfo", "<InstanceID>0</InstanceID>");
|
|
99
|
+
const trackMatch = posXml.match(/<TrackURI>([^<]*)<\/TrackURI>/);
|
|
100
|
+
const metaMatch = posXml.match(/<TrackMetaData>([^<]*)<\/TrackMetaData>/);
|
|
101
|
+
|
|
102
|
+
const lines = [
|
|
103
|
+
`State: ${stateMatch?.[1] || "unknown"}`,
|
|
104
|
+
trackMatch?.[1] ? `Track: ${decodeURIComponent(trackMatch[1]).split("/").pop()}` : "",
|
|
105
|
+
].filter(Boolean);
|
|
106
|
+
return lines.join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (action === "queue") {
|
|
110
|
+
const { uri, title } = params;
|
|
111
|
+
if (!uri) return "Error: uri required to queue a track (e.g. spotify URI or HTTP stream URL)";
|
|
112
|
+
await sonosSoap(speakerIp, "AVTransport", "SetAVTransportURI",
|
|
113
|
+
`<InstanceID>0</InstanceID><CurrentURI>${uri}</CurrentURI><CurrentURIMetaData>${title ? `<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="-1" parentID="-1" restricted="true"><dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">${title}</dc:title></item></DIDL-Lite>` : ""}</CurrentURIMetaData>`);
|
|
114
|
+
return `Queued: ${title || uri}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return `Sonos error: ${err.message}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return `Unknown action: "${action}". Valid: play, pause, stop, next, prev, volume, mute, queue, info`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const sonosDescription =
|
|
125
|
+
`sonos(action: string, paramsJson?: object) - Control Sonos speakers via local network.
|
|
126
|
+
action: "play" | "pause" | "stop" | "next" | "prev" | "volume" | "mute" | "queue" | "info"
|
|
127
|
+
play/pause/stop/next/prev: { speakerIp? }
|
|
128
|
+
volume: { level?: 0-100, speakerIp? } (omit level to get current volume)
|
|
129
|
+
mute: { muted?: true, speakerIp? }
|
|
130
|
+
queue: { uri: "spotify:track:...|http://...", title?, speakerIp? }
|
|
131
|
+
info: { speakerIp? } → playback state + current track
|
|
132
|
+
Env var: SONOS_SPEAKER_IP
|
|
133
|
+
Examples:
|
|
134
|
+
sonos("play")
|
|
135
|
+
sonos("volume", {"level":40})
|
|
136
|
+
sonos("info")
|
|
137
|
+
sonos("queue", {"uri":"x-sonosapi-stream:s95362?sid=254","title":"Radio"})`;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sshTool - Execute commands on remote servers via SSH.
|
|
3
|
+
* Uses child_process to call the system ssh binary.
|
|
4
|
+
* Supports password auth (via sshpass) and key-based auth.
|
|
5
|
+
* Security: commands are passed as arguments (not shell-interpolated).
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { writeFileSync, unlinkSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
|
|
13
|
+
export async function sshTool(action, paramsJson) {
|
|
14
|
+
if (!action) return "Error: action required. Valid: exec, upload, download, tunnel";
|
|
15
|
+
const params = paramsJson
|
|
16
|
+
? (typeof paramsJson === "string" ? JSON.parse(paramsJson) : paramsJson)
|
|
17
|
+
: {};
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
host,
|
|
21
|
+
user = "root",
|
|
22
|
+
port = 22,
|
|
23
|
+
keyPath = null,
|
|
24
|
+
timeout = 30,
|
|
25
|
+
} = params;
|
|
26
|
+
|
|
27
|
+
if (!host) return "Error: host is required";
|
|
28
|
+
|
|
29
|
+
// Build ssh base args
|
|
30
|
+
const baseArgs = [
|
|
31
|
+
"-o", "StrictHostKeyChecking=no",
|
|
32
|
+
"-o", "BatchMode=yes",
|
|
33
|
+
"-o", `ConnectTimeout=${timeout}`,
|
|
34
|
+
"-p", String(port),
|
|
35
|
+
...(keyPath ? ["-i", keyPath] : []),
|
|
36
|
+
`${user}@${host}`,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
if (action === "exec") {
|
|
40
|
+
const { command } = params;
|
|
41
|
+
if (!command) return "Error: command is required for exec";
|
|
42
|
+
|
|
43
|
+
// Security: command is passed as a string to ssh (executed via remote shell)
|
|
44
|
+
// We don't shell-interpolate it locally
|
|
45
|
+
try {
|
|
46
|
+
const out = execFileSync("ssh", [...baseArgs, command], {
|
|
47
|
+
encoding: "utf-8",
|
|
48
|
+
timeout: timeout * 1000,
|
|
49
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
50
|
+
});
|
|
51
|
+
return out.trim() || "(command produced no output)";
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const msg = err.stderr?.trim() || err.message;
|
|
54
|
+
return `SSH exec error: ${msg}`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (action === "upload") {
|
|
59
|
+
const { localPath, remotePath } = params;
|
|
60
|
+
if (!localPath || !remotePath) return "Error: localPath and remotePath required for upload";
|
|
61
|
+
|
|
62
|
+
const scpArgs = [
|
|
63
|
+
"-o", "StrictHostKeyChecking=no",
|
|
64
|
+
"-P", String(port),
|
|
65
|
+
...(keyPath ? ["-i", keyPath] : []),
|
|
66
|
+
localPath,
|
|
67
|
+
`${user}@${host}:${remotePath}`,
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
execFileSync("scp", scpArgs, { encoding: "utf-8", timeout: timeout * 1000 });
|
|
72
|
+
return `Uploaded ${localPath} → ${user}@${host}:${remotePath}`;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return `SCP upload error: ${err.stderr?.trim() || err.message}`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (action === "download") {
|
|
79
|
+
const { remotePath, localPath } = params;
|
|
80
|
+
if (!remotePath || !localPath) return "Error: remotePath and localPath required for download";
|
|
81
|
+
|
|
82
|
+
const scpArgs = [
|
|
83
|
+
"-o", "StrictHostKeyChecking=no",
|
|
84
|
+
"-P", String(port),
|
|
85
|
+
...(keyPath ? ["-i", keyPath] : []),
|
|
86
|
+
`${user}@${host}:${remotePath}`,
|
|
87
|
+
localPath,
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
execFileSync("scp", scpArgs, { encoding: "utf-8", timeout: timeout * 1000 });
|
|
92
|
+
return `Downloaded ${user}@${host}:${remotePath} → ${localPath}`;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return `SCP download error: ${err.stderr?.trim() || err.message}`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (action === "keygen") {
|
|
99
|
+
// Generate a new SSH key pair for the agent's use
|
|
100
|
+
const keyDir = join(tmpdir(), `daemora-ssh-${randomBytes(4).toString("hex")}`);
|
|
101
|
+
mkdirSync(keyDir, { recursive: true });
|
|
102
|
+
const keyFile = join(keyDir, "id_ed25519");
|
|
103
|
+
try {
|
|
104
|
+
execFileSync("ssh-keygen", ["-t", "ed25519", "-C", "daemora-agent", "-f", keyFile, "-N", ""], {
|
|
105
|
+
encoding: "utf-8",
|
|
106
|
+
timeout: 10000,
|
|
107
|
+
});
|
|
108
|
+
const { readFileSync } = await import("node:fs");
|
|
109
|
+
const pub = readFileSync(`${keyFile}.pub`, "utf-8").trim();
|
|
110
|
+
return `SSH key pair generated:\nPrivate key: ${keyFile}\nPublic key: ${pub}`;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return `ssh-keygen error: ${err.message}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return `Unknown action: "${action}". Valid: exec, upload, download, keygen`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const sshToolDescription =
|
|
120
|
+
`sshTool(action: string, paramsJson?: object) - Execute commands or transfer files over SSH.
|
|
121
|
+
action: "exec" | "upload" | "download" | "keygen"
|
|
122
|
+
exec params: { host, user?, port?, keyPath?, command, timeout? }
|
|
123
|
+
upload params: { host, user?, port?, keyPath?, localPath, remotePath }
|
|
124
|
+
download params: { host, user?, port?, keyPath?, remotePath, localPath }
|
|
125
|
+
keygen: generates a new ed25519 SSH key pair (no params needed)
|
|
126
|
+
Note: Uses system ssh/scp binaries. StrictHostKeyChecking disabled for agent use.
|
|
127
|
+
Examples:
|
|
128
|
+
sshTool("exec", {"host":"192.168.1.10","user":"ubuntu","command":"df -h"})
|
|
129
|
+
sshTool("upload", {"host":"server.com","localPath":"/tmp/file.txt","remotePath":"/home/user/file.txt"})
|
|
130
|
+
sshTool("exec", {"host":"prod.example.com","keyPath":"~/.ssh/id_ed25519","command":"systemctl status nginx"})`;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* textToSpeech(text, optionsJson?)
|
|
2
|
+
* textToSpeech(text, optionsJson?) - Convert text to speech and save as audio file.
|
|
3
3
|
*
|
|
4
|
-
* Primary: OpenAI TTS (tts-1-hd)
|
|
4
|
+
* Primary: OpenAI TTS (tts-1-hd) - uses the same OPENAI_API_KEY already configured.
|
|
5
5
|
* Optional: ElevenLabs via ELEVENLABS_API_KEY (higher quality, more voices).
|
|
6
6
|
*
|
|
7
7
|
* Unlike OpenClaw's /voice command (config-only, iOS-only), this is a proper
|
|
@@ -68,7 +68,7 @@ async function _openAI(text, opts) {
|
|
|
68
68
|
return `Audio saved to: ${filePath}`;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
// Multiple chunks
|
|
71
|
+
// Multiple chunks - save each sequentially, return all paths
|
|
72
72
|
const paths = [];
|
|
73
73
|
for (let i = 0; i < chunks.length; i++) {
|
|
74
74
|
const response = await client.audio.speech.create({ model, voice, input: chunks[i], speed, response_format: format });
|
|
@@ -88,7 +88,7 @@ async function _elevenLabs(text, opts) {
|
|
|
88
88
|
return "Error: provider=elevenlabs requires ELEVENLABS_API_KEY";
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
// Default: Rachel
|
|
91
|
+
// Default: Rachel - professional female voice, works well for most content
|
|
92
92
|
const voiceId = opts.voiceId || "21m00Tcm4TlvDq8ikWAM";
|
|
93
93
|
const modelId = opts.modelId || "eleven_multilingual_v2"; // supports 29 languages
|
|
94
94
|
const stability = parseFloat(opts.stability || "0.5");
|
|
@@ -155,7 +155,7 @@ function _splitText(text, maxLength) {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
export const textToSpeechDescription =
|
|
158
|
-
'textToSpeech(text: string, optionsJson?: string)
|
|
158
|
+
'textToSpeech(text: string, optionsJson?: string) - Convert text to an audio file using OpenAI TTS (default) or ElevenLabs. ' +
|
|
159
159
|
'optionsJson: {"voice":"nova|alloy|echo|fable|onyx|shimmer","speed":1.0,"format":"mp3","hd":true,"provider":"openai|elevenlabs","voiceId":"<elevenlabs-id>"}. ' +
|
|
160
160
|
'Requires OPENAI_API_KEY (or ELEVENLABS_API_KEY for ElevenLabs). ' +
|
|
161
161
|
'Auto-splits long texts. Returns the saved file path. Chain with sendFile() to deliver audio to the user.';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* transcribeAudio(audioPath, prompt?)
|
|
2
|
+
* transcribeAudio(audioPath, prompt?) - Transcribe audio/voice files to text using OpenAI Whisper.
|
|
3
3
|
*
|
|
4
4
|
* Supports: local file paths and HTTPS URLs.
|
|
5
5
|
* Formats: mp3, mp4, mpeg, mpga, m4a, wav, webm, ogg, oga, flac
|
|
@@ -16,7 +16,7 @@ const SUPPORTED_EXTENSIONS = new Set([
|
|
|
16
16
|
".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg", ".oga", ".flac"
|
|
17
17
|
]);
|
|
18
18
|
|
|
19
|
-
// Telegram voices come as .oga (ogg audio)
|
|
19
|
+
// Telegram voices come as .oga (ogg audio) - map to .ogg for Whisper compatibility
|
|
20
20
|
const EXT_REMAP = { ".oga": ".ogg" };
|
|
21
21
|
|
|
22
22
|
export async function transcribeAudio(audioPath, prompt) {
|
|
@@ -70,7 +70,7 @@ export async function transcribeAudio(audioPath, prompt) {
|
|
|
70
70
|
? transcription.trim()
|
|
71
71
|
: (transcription.text || "").trim();
|
|
72
72
|
|
|
73
|
-
if (!text) return "Transcription returned empty
|
|
73
|
+
if (!text) return "Transcription returned empty - audio may be silent or too short.";
|
|
74
74
|
return text;
|
|
75
75
|
|
|
76
76
|
} catch (error) {
|
|
@@ -79,4 +79,4 @@ export async function transcribeAudio(audioPath, prompt) {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
export const transcribeAudioDescription =
|
|
82
|
-
'transcribeAudio(audioPath: string, prompt?: string)
|
|
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.';
|
package/src/tools/useMCP.js
CHANGED
|
@@ -2,13 +2,13 @@ import { runMCPAgent } from "../mcp/MCPAgentRunner.js";
|
|
|
2
2
|
import tenantContext from "../tenants/TenantContext.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* useMCP
|
|
5
|
+
* useMCP - delegate a task to a specialist agent for a specific MCP server.
|
|
6
6
|
*
|
|
7
7
|
* The specialist agent receives ONLY that server's tools and a focused system prompt.
|
|
8
8
|
* This keeps context lean: main agent stays uncluttered, specialist stays focused.
|
|
9
9
|
*
|
|
10
10
|
* @param {string} serverName - MCP server name (e.g. "github", "notion", "slack")
|
|
11
|
-
* @param {string} taskDescription - Full task spec
|
|
11
|
+
* @param {string} taskDescription - Full task spec - the agent has no other context
|
|
12
12
|
* @returns {Promise<string>} - Specialist agent's final response
|
|
13
13
|
*/
|
|
14
14
|
export async function useMCP(serverName, taskDescription) {
|
|
@@ -25,5 +25,5 @@ export async function useMCP(serverName, taskDescription) {
|
|
|
25
25
|
export const useMCPDescription =
|
|
26
26
|
`useMCP(serverName: string, taskDescription: string) - Delegate a task to a specialist MCP agent for the named server.
|
|
27
27
|
- serverName: the MCP server to use (use manageMCP("list") to see available servers)
|
|
28
|
-
- taskDescription: comprehensive task spec
|
|
29
|
-
- The specialist gets ONLY that server's tools
|
|
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`;
|
package/src/tools/webFetch.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* webFetch(url, optionsJson?)
|
|
2
|
+
* webFetch(url, optionsJson?) - Fetch URL content with proper HTML conversion, caching, and SSRF protection.
|
|
3
3
|
* Upgraded: html-to-text library, 15-min cache, SSRF guard, 50K char limit, GitHub URL conversion.
|
|
4
4
|
*/
|
|
5
5
|
import { convert } from "html-to-text";
|
|
6
6
|
import { URL } from "node:url";
|
|
7
7
|
|
|
8
|
-
// Private IP ranges
|
|
8
|
+
// Private IP ranges - SSRF protection
|
|
9
9
|
const PRIVATE_RANGES = [
|
|
10
10
|
/^127\./,
|
|
11
11
|
/^10\./,
|
package/src/tools/webSearch.js
CHANGED