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.
Files changed (134) hide show
  1. package/README.md +106 -76
  2. package/SOUL.md +100 -28
  3. package/config/mcp.json +9 -9
  4. package/package.json +15 -8
  5. package/skills/apple-notes.md +0 -52
  6. package/skills/apple-reminders.md +1 -87
  7. package/skills/camsnap.md +20 -144
  8. package/skills/coding.md +7 -7
  9. package/skills/documents.md +6 -6
  10. package/skills/email.md +6 -6
  11. package/skills/gif-search.md +28 -171
  12. package/skills/healthcheck.md +21 -203
  13. package/skills/image-gen.md +24 -123
  14. package/skills/model-usage.md +18 -165
  15. package/skills/obsidian.md +28 -174
  16. package/skills/pdf.md +30 -181
  17. package/skills/research.md +6 -6
  18. package/skills/skill-creator.md +35 -111
  19. package/skills/spotify.md +2 -17
  20. package/skills/summarize.md +36 -193
  21. package/skills/things.md +23 -175
  22. package/skills/tmux.md +1 -91
  23. package/skills/trello.md +32 -157
  24. package/skills/video-frames.md +26 -166
  25. package/skills/weather.md +6 -6
  26. package/src/a2a/A2AClient.js +2 -2
  27. package/src/a2a/A2AServer.js +6 -6
  28. package/src/a2a/AgentCard.js +2 -2
  29. package/src/agents/SubAgentManager.js +61 -19
  30. package/src/agents/Supervisor.js +4 -4
  31. package/src/channels/BaseChannel.js +6 -6
  32. package/src/channels/BlueBubblesChannel.js +112 -0
  33. package/src/channels/DiscordChannel.js +8 -8
  34. package/src/channels/EmailChannel.js +54 -26
  35. package/src/channels/FeishuChannel.js +140 -0
  36. package/src/channels/GoogleChatChannel.js +8 -8
  37. package/src/channels/HttpChannel.js +2 -2
  38. package/src/channels/IRCChannel.js +144 -0
  39. package/src/channels/LineChannel.js +13 -13
  40. package/src/channels/MatrixChannel.js +97 -0
  41. package/src/channels/MattermostChannel.js +119 -0
  42. package/src/channels/NextcloudChannel.js +133 -0
  43. package/src/channels/NostrChannel.js +175 -0
  44. package/src/channels/SignalChannel.js +9 -9
  45. package/src/channels/SlackChannel.js +10 -10
  46. package/src/channels/TeamsChannel.js +10 -10
  47. package/src/channels/TelegramChannel.js +8 -8
  48. package/src/channels/TwitchChannel.js +128 -0
  49. package/src/channels/WhatsAppChannel.js +10 -10
  50. package/src/channels/ZaloChannel.js +119 -0
  51. package/src/channels/iMessageChannel.js +150 -0
  52. package/src/channels/index.js +241 -11
  53. package/src/cli.js +834 -37
  54. package/src/config/agentProfiles.js +19 -19
  55. package/src/config/channels.js +1 -1
  56. package/src/config/default.js +12 -7
  57. package/src/config/models.js +3 -3
  58. package/src/config/permissions.js +2 -2
  59. package/src/core/AgentLoop.js +13 -13
  60. package/src/core/Compaction.js +3 -3
  61. package/src/core/CostTracker.js +2 -2
  62. package/src/core/EventBus.js +15 -15
  63. package/src/core/TaskQueue.js +24 -7
  64. package/src/core/TaskRunner.js +19 -6
  65. package/src/daemon/DaemonManager.js +4 -4
  66. package/src/hooks/HookRunner.js +4 -4
  67. package/src/index.js +6 -2
  68. package/src/mcp/MCPAgentRunner.js +3 -3
  69. package/src/mcp/MCPClient.js +9 -9
  70. package/src/mcp/MCPManager.js +14 -14
  71. package/src/models/ModelRouter.js +2 -2
  72. package/src/safety/AuditLog.js +3 -3
  73. package/src/safety/CircuitBreaker.js +2 -2
  74. package/src/safety/CommandGuard.js +132 -0
  75. package/src/safety/FilesystemGuard.js +23 -3
  76. package/src/safety/GitRollback.js +5 -5
  77. package/src/safety/HumanApproval.js +9 -9
  78. package/src/safety/InputSanitizer.js +81 -8
  79. package/src/safety/PermissionGuard.js +2 -2
  80. package/src/safety/Sandbox.js +1 -1
  81. package/src/safety/SecretScanner.js +90 -28
  82. package/src/safety/SecretVault.js +2 -2
  83. package/src/scheduler/Heartbeat.js +3 -3
  84. package/src/scheduler/Scheduler.js +6 -6
  85. package/src/setup/theme.js +171 -66
  86. package/src/setup/wizard.js +432 -57
  87. package/src/skills/SkillLoader.js +145 -8
  88. package/src/storage/TaskStore.js +39 -15
  89. package/src/systemPrompt.js +45 -43
  90. package/src/tenants/TenantManager.js +79 -22
  91. package/src/tools/ToolRegistry.js +3 -3
  92. package/src/tools/applyPatch.js +2 -2
  93. package/src/tools/browserAutomation.js +4 -4
  94. package/src/tools/calendar.js +155 -0
  95. package/src/tools/clipboard.js +71 -0
  96. package/src/tools/contacts.js +138 -0
  97. package/src/tools/createDocument.js +2 -2
  98. package/src/tools/cronTool.js +14 -14
  99. package/src/tools/database.js +165 -0
  100. package/src/tools/editFile.js +10 -10
  101. package/src/tools/executeCommand.js +11 -3
  102. package/src/tools/generateImage.js +79 -0
  103. package/src/tools/gitTool.js +141 -0
  104. package/src/tools/glob.js +1 -1
  105. package/src/tools/googlePlaces.js +136 -0
  106. package/src/tools/grep.js +2 -2
  107. package/src/tools/iMessageTool.js +86 -0
  108. package/src/tools/imageAnalysis.js +3 -3
  109. package/src/tools/index.js +56 -2
  110. package/src/tools/makeVoiceCall.js +283 -0
  111. package/src/tools/manageAgents.js +2 -2
  112. package/src/tools/manageMCP.js +38 -20
  113. package/src/tools/memory.js +25 -32
  114. package/src/tools/messageChannel.js +1 -1
  115. package/src/tools/notification.js +90 -0
  116. package/src/tools/philipsHue.js +147 -0
  117. package/src/tools/projectTracker.js +8 -8
  118. package/src/tools/readFile.js +1 -1
  119. package/src/tools/readPDF.js +73 -0
  120. package/src/tools/screenCapture.js +6 -6
  121. package/src/tools/searchContent.js +2 -2
  122. package/src/tools/searchFiles.js +1 -1
  123. package/src/tools/sendEmail.js +79 -24
  124. package/src/tools/sendFile.js +4 -4
  125. package/src/tools/sonos.js +137 -0
  126. package/src/tools/sshTool.js +130 -0
  127. package/src/tools/textToSpeech.js +5 -5
  128. package/src/tools/transcribeAudio.js +4 -4
  129. package/src/tools/useMCP.js +4 -4
  130. package/src/tools/webFetch.js +2 -2
  131. package/src/tools/webSearch.js +1 -1
  132. package/src/utils/Embeddings.js +79 -0
  133. package/src/voice/VoiceSessionManager.js +170 -0
  134. package/src/voice/VoiceWebhook.js +188 -0
@@ -1,9 +1,9 @@
1
1
  /**
2
- * screenCapture(optionsJson?) Take a screenshot or record a screen video.
2
+ * screenCapture(optionsJson?) - Take a screenshot or record a screen video.
3
3
  *
4
4
  * Modes:
5
- * screenshot (default) single still image (PNG)
6
- * video screen recording (MP4), uses `duration` seconds (default 10)
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 } screenshot only
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 video only
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?) Capture a screenshot or record a screen video. ' +
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?) Search file contents.
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 increase with optionsJson {"limit":200})` : "";
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) {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * searchFiles(pattern, directory?, optionsJson?) Find files by name pattern.
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";
@@ -1,8 +1,16 @@
1
1
  /**
2
- * Send Email sends email via SMTP (nodemailer).
3
- * Upgraded: CC/BCC, attachments, replyTo, email validation, optionsJson support.
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
- let transporter = null;
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
- if (transporter) return transporter;
30
+ const store = tenantContext.getStore();
31
+ const ch = store?.resolvedConfig?.channelConfig || {};
21
32
 
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);
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 (!user || !pass) {
28
- return null;
29
- }
41
+ if (!resendKey && !smtpUser) return { transporter: null, from: null };
30
42
 
31
43
  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;
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; // [{filename, path}]
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 EMAIL_USER and EMAIL_PASSWORD in .env";
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: process.env.EMAIL_USER,
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 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.';
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"}]}';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * sendFile(channel, target, filePath, caption?) Send a file/image/video to a user on any channel.
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 depends on channel
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 most platforms limit around this
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?) Send a file, image, or video to a user. ' +
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?) Convert text to speech and save as audio file.
2
+ * textToSpeech(text, optionsJson?) - Convert text to speech and save as audio file.
3
3
  *
4
- * Primary: OpenAI TTS (tts-1-hd) uses the same OPENAI_API_KEY already configured.
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 save each sequentially, return all paths
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 professional female voice, works well for most content
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) Convert text to an audio file using OpenAI TTS (default) or ElevenLabs. ' +
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?) Transcribe audio/voice files to text using OpenAI Whisper.
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) map to .ogg for Whisper compatibility
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 audio may be silent or too short.";
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) 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.';
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.';
@@ -2,13 +2,13 @@ import { runMCPAgent } from "../mcp/MCPAgentRunner.js";
2
2
  import tenantContext from "../tenants/TenantContext.js";
3
3
 
4
4
  /**
5
- * useMCP delegate a task to a specialist agent for a specific MCP server.
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 the agent has no other context
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 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`;
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`;
@@ -1,11 +1,11 @@
1
1
  /**
2
- * webFetch(url, optionsJson?) Fetch URL content with proper HTML conversion, caching, and SSRF protection.
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 SSRF protection
8
+ // Private IP ranges - SSRF protection
9
9
  const PRIVATE_RANGES = [
10
10
  /^127\./,
11
11
  /^10\./,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Web Search DuckDuckGo (free) + Brave Search (if API key set).
2
+ * Web Search - DuckDuckGo (free) + Brave Search (if API key set).
3
3
  * Upgraded: result caching, freshness/date filters, optionsJson support.
4
4
  */
5
5