daemora 1.0.1 → 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 +835 -38
  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
@@ -0,0 +1,136 @@
1
+ /**
2
+ * googlePlaces - Search and get details from Google Places API.
3
+ * Requires GOOGLE_PLACES_API_KEY env var.
4
+ */
5
+
6
+ export async function googlePlaces(action, paramsJson) {
7
+ if (!action) return "Error: action required. Valid: search, details, nearby, autocomplete";
8
+ const params = paramsJson
9
+ ? (typeof paramsJson === "string" ? JSON.parse(paramsJson) : paramsJson)
10
+ : {};
11
+
12
+ const apiKey = params.apiKey || process.env.GOOGLE_PLACES_API_KEY;
13
+ if (!apiKey) return "Error: GOOGLE_PLACES_API_KEY env var required";
14
+
15
+ const fetchFn = globalThis.fetch || (await import("node-fetch")).default;
16
+ const BASE = "https://maps.googleapis.com/maps/api";
17
+
18
+ if (action === "search") {
19
+ const { query, location, radius = 5000, type } = params;
20
+ if (!query) return "Error: query is required";
21
+
22
+ const qs = new URLSearchParams({
23
+ query,
24
+ key: apiKey,
25
+ ...(location ? { location } : {}),
26
+ ...(radius ? { radius: String(radius) } : {}),
27
+ ...(type ? { type } : {}),
28
+ });
29
+
30
+ const res = await fetchFn(`${BASE}/place/textsearch/json?${qs}`);
31
+ const data = await res.json();
32
+ if (data.status !== "OK" && data.status !== "ZERO_RESULTS") {
33
+ return `Places API error: ${data.status} — ${data.error_message || ""}`;
34
+ }
35
+ if (!data.results?.length) return `No places found for "${query}"`;
36
+
37
+ return data.results.slice(0, params.limit || 5).map(p => [
38
+ `Name: ${p.name}`,
39
+ `Address: ${p.formatted_address}`,
40
+ `Rating: ${p.rating || "N/A"} (${p.user_ratings_total || 0} reviews)`,
41
+ `Place ID: ${p.place_id}`,
42
+ p.opening_hours?.open_now !== undefined ? `Open now: ${p.opening_hours.open_now}` : "",
43
+ ].filter(Boolean).join("\n")).join("\n\n");
44
+ }
45
+
46
+ if (action === "details") {
47
+ const { placeId, fields = "name,formatted_address,formatted_phone_number,website,rating,opening_hours,reviews" } = params;
48
+ if (!placeId) return "Error: placeId is required";
49
+
50
+ const qs = new URLSearchParams({ place_id: placeId, fields, key: apiKey });
51
+ const res = await fetchFn(`${BASE}/place/details/json?${qs}`);
52
+ const data = await res.json();
53
+ if (data.status !== "OK") return `Places details error: ${data.status}`;
54
+
55
+ const r = data.result;
56
+ const lines = [
57
+ `Name: ${r.name}`,
58
+ `Address: ${r.formatted_address}`,
59
+ r.formatted_phone_number ? `Phone: ${r.formatted_phone_number}` : null,
60
+ r.website ? `Website: ${r.website}` : null,
61
+ r.rating ? `Rating: ${r.rating}/5 (${r.user_ratings_total} reviews)` : null,
62
+ ].filter(Boolean);
63
+
64
+ if (r.opening_hours?.weekday_text) {
65
+ lines.push("Hours:\n" + r.opening_hours.weekday_text.map(h => ` ${h}`).join("\n"));
66
+ }
67
+
68
+ if (r.reviews?.length && params.includeReviews) {
69
+ lines.push("Top reviews:");
70
+ r.reviews.slice(0, 3).forEach(rev => {
71
+ lines.push(` ⭐${rev.rating} — ${rev.author_name}: "${rev.text?.slice(0, 100)}..."`);
72
+ });
73
+ }
74
+
75
+ return lines.join("\n");
76
+ }
77
+
78
+ if (action === "nearby") {
79
+ const { location, radius = 1000, type } = params;
80
+ if (!location) return "Error: location is required (e.g. '37.7749,-122.4194')";
81
+
82
+ const qs = new URLSearchParams({
83
+ location,
84
+ radius: String(radius),
85
+ key: apiKey,
86
+ ...(type ? { type } : {}),
87
+ });
88
+
89
+ const res = await fetchFn(`${BASE}/place/nearbysearch/json?${qs}`);
90
+ const data = await res.json();
91
+ if (data.status !== "OK" && data.status !== "ZERO_RESULTS") {
92
+ return `Nearby search error: ${data.status}`;
93
+ }
94
+ if (!data.results?.length) return "No places found nearby";
95
+
96
+ return data.results.slice(0, params.limit || 5).map(p =>
97
+ `${p.name} — ${p.vicinity} (Rating: ${p.rating || "N/A"})`
98
+ ).join("\n");
99
+ }
100
+
101
+ if (action === "autocomplete") {
102
+ const { input, location, radius } = params;
103
+ if (!input) return "Error: input is required";
104
+
105
+ const qs = new URLSearchParams({
106
+ input,
107
+ key: apiKey,
108
+ ...(location ? { location } : {}),
109
+ ...(radius ? { radius: String(radius) } : {}),
110
+ });
111
+
112
+ const res = await fetchFn(`${BASE}/place/autocomplete/json?${qs}`);
113
+ const data = await res.json();
114
+ if (data.status !== "OK" && data.status !== "ZERO_RESULTS") {
115
+ return `Autocomplete error: ${data.status}`;
116
+ }
117
+ if (!data.predictions?.length) return `No suggestions for "${input}"`;
118
+
119
+ return data.predictions.map(p => `${p.description} (${p.place_id})`).join("\n");
120
+ }
121
+
122
+ return `Unknown action: "${action}". Valid: search, details, nearby, autocomplete`;
123
+ }
124
+
125
+ export const googlePlacesDescription =
126
+ `googlePlaces(action: string, paramsJson?: object) - Search places, get details, find nearby locations.
127
+ action: "search" | "details" | "nearby" | "autocomplete"
128
+ search params: { query, location?: "lat,lng", radius?: 5000, type?, limit?: 5 }
129
+ details params: { placeId, fields?, includeReviews?: false }
130
+ nearby params: { location: "lat,lng", radius?: 1000, type?, limit?: 5 }
131
+ autocomplete params: { input, location?, radius? }
132
+ Env var: GOOGLE_PLACES_API_KEY
133
+ Examples:
134
+ googlePlaces("search", {"query":"coffee shops in San Francisco"})
135
+ googlePlaces("nearby", {"location":"37.7749,-122.4194","radius":500,"type":"restaurant"})
136
+ googlePlaces("details", {"placeId":"ChIJN1t_tDeuEmsRUsoyG83frY4"})`;
package/src/tools/grep.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * grep(pattern, optionsJson?) Advanced content search with context lines.
3
- * Inspired by Claude Code's Grep tool. Pure Node.js no shell dependency.
2
+ * grep(pattern, optionsJson?) - Advanced content search with context lines.
3
+ * Inspired by Claude Code's Grep tool. Pure Node.js - no shell dependency.
4
4
  */
5
5
  import { readdirSync, readFileSync, statSync } from "node:fs";
6
6
  import { join, extname, relative } from "node:path";
@@ -0,0 +1,86 @@
1
+ /**
2
+ * iMessageTool - Send iMessages and SMS via macOS Messages app.
3
+ * Requires macOS with Messages app configured and accessibility permissions.
4
+ * Uses osascript (AppleScript) — no external deps.
5
+ */
6
+ import { execSync } from "node:child_process";
7
+
8
+ export async function iMessageTool(action, paramsJson) {
9
+ if (!action) return "Error: action required. Valid: send, read";
10
+ const params = paramsJson
11
+ ? (typeof paramsJson === "string" ? JSON.parse(paramsJson) : paramsJson)
12
+ : {};
13
+
14
+ if (process.platform !== "darwin") {
15
+ return "Error: iMessage tool is macOS-only (requires Messages app via AppleScript)";
16
+ }
17
+
18
+ if (action === "send") {
19
+ const { to, message, service = "iMessage" } = params;
20
+ if (!to) return "Error: 'to' (phone number or email) is required";
21
+ if (!message) return "Error: 'message' is required";
22
+
23
+ // Validate service type
24
+ const validServices = ["iMessage", "SMS"];
25
+ if (!validServices.includes(service)) {
26
+ return `Error: invalid service "${service}". Valid: iMessage, SMS`;
27
+ }
28
+
29
+ const script = `
30
+ tell application "Messages"
31
+ set targetService to 1st service whose service type = ${service}
32
+ set targetBuddy to buddy "${to.replace(/"/g, '\\"')}" of targetService
33
+ send "${message.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}" to targetBuddy
34
+ end tell
35
+ `;
36
+
37
+ try {
38
+ execSync(`osascript -e '${script.replace(/'/g, "\\'")}'`, { timeout: 10000 });
39
+ return `${service} sent to ${to}: "${message.length > 60 ? message.slice(0, 60) + "..." : message}"`;
40
+ } catch (err) {
41
+ return `iMessage error: ${err.message}. Make sure Messages app is open and has the contact.`;
42
+ }
43
+ }
44
+
45
+ if (action === "read") {
46
+ const { count = 10 } = params;
47
+ // AppleScript to read recent messages
48
+ const script = `
49
+ tell application "Messages"
50
+ set output to ""
51
+ set allChats to chats
52
+ repeat with aChat in allChats
53
+ set msgCount to count of messages of aChat
54
+ if msgCount > 0 then
55
+ set lastMsg to last message of aChat
56
+ set output to output & name of aChat & ": " & content of lastMsg & "\\n"
57
+ end if
58
+ end repeat
59
+ return output
60
+ end tell
61
+ `;
62
+
63
+ try {
64
+ const result = execSync(`osascript -e '${script.replace(/'/g, "\\'")}'`, {
65
+ encoding: "utf-8",
66
+ timeout: 10000,
67
+ });
68
+ if (!result.trim()) return "No recent messages found";
69
+ return `Recent messages:\n${result.trim()}`;
70
+ } catch (err) {
71
+ return `Error reading messages: ${err.message}`;
72
+ }
73
+ }
74
+
75
+ return `Unknown action: "${action}". Valid: send, read`;
76
+ }
77
+
78
+ export const iMessageToolDescription =
79
+ `iMessageTool(action: string, paramsJson?: object) - Send and read iMessages/SMS on macOS.
80
+ action: "send" | "read"
81
+ send params: { to: "+1234567890"|"email@icloud.com", message: "text", service?: "iMessage"|"SMS" }
82
+ read params: { count?: 10 }
83
+ Requires: macOS with Messages app configured + Accessibility permissions for osascript
84
+ Examples:
85
+ iMessageTool("send", {"to":"+15551234567","message":"Hello!"})
86
+ iMessageTool("read", {"count":5})`;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * imageAnalysis(imagePath, prompt?) Analyze images using vision AI models.
2
+ * imageAnalysis(imagePath, prompt?) - Analyze images using vision AI models.
3
3
  * Supports local files, URLs, and data: URIs.
4
4
  * Uses the Vercel AI SDK with whatever vision-capable model is configured.
5
5
  */
@@ -38,13 +38,13 @@ export async function imageAnalysis(imagePath, prompt) {
38
38
  }
39
39
 
40
40
  if (imagePath.startsWith("data:")) {
41
- // data: URI extract base64 and mime type
41
+ // data: URI - extract base64 and mime type
42
42
  const match = imagePath.match(/^data:([^;]+);base64,(.+)$/);
43
43
  if (!match) return "Error: Invalid data: URI format";
44
44
  mimeType = match[1];
45
45
  imageData = match[2];
46
46
  } else if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
47
- // URL fetch and convert to base64
47
+ // URL - fetch and convert to base64
48
48
  const controller = new AbortController();
49
49
  const timeout = setTimeout(() => controller.abort(), 15000);
50
50
  try {
@@ -28,7 +28,7 @@ import { transcribeAudio, transcribeAudioDescription } from "./transcribeAudio.j
28
28
  import { sendFile, sendFileDescription } from "./sendFile.js";
29
29
  import { textToSpeech, textToSpeechDescription } from "./textToSpeech.js";
30
30
 
31
- // ─── New tools ─────────────────────────────────────────────────────────────────
31
+ // ─── Search & code tools ───────────────────────────────────────────────────────
32
32
  import { globSearch, globSearchDescription } from "./glob.js";
33
33
  import { grep, grepDescription } from "./grep.js";
34
34
  import { applyPatch, applyPatchDescription } from "./applyPatch.js";
@@ -40,6 +40,22 @@ import { messageChannel, messageChannelDescription } from "./messageChannel.js";
40
40
  import { projectTracker, projectTrackerDescription } from "./projectTracker.js";
41
41
  import { manageMCP, manageMCPDescription } from "./manageMCP.js";
42
42
  import { useMCP, useMCPDescription } from "./useMCP.js";
43
+ import { makeVoiceCall, makeVoiceCallDescription } from "./makeVoiceCall.js";
44
+
45
+ // ─── Phase 24 tools ────────────────────────────────────────────────────────────
46
+ import { generateImage, generateImageDescription } from "./generateImage.js";
47
+ import { readPDF, readPDFDescription } from "./readPDF.js";
48
+ import { gitTool, gitToolDescription } from "./gitTool.js";
49
+ import { clipboard, clipboardDescription } from "./clipboard.js";
50
+ import { notification, notificationDescription } from "./notification.js";
51
+ import { iMessageTool, iMessageToolDescription } from "./iMessageTool.js";
52
+ import { calendar, calendarDescription } from "./calendar.js";
53
+ import { sshTool, sshToolDescription } from "./sshTool.js";
54
+ import { database, databaseDescription } from "./database.js";
55
+ import { contacts, contactsDescription } from "./contacts.js";
56
+ import { googlePlaces, googlePlacesDescription } from "./googlePlaces.js";
57
+ import { philipsHue, philipsHueDescription } from "./philipsHue.js";
58
+ import { sonos, sonosDescription } from "./sonos.js";
43
59
 
44
60
  // ─── Wrap spawnAgent for the tool interface ────────────────────────────────────
45
61
  function spawnAgent(taskDescription, optionsJson) {
@@ -74,7 +90,7 @@ export const toolFunctions = {
74
90
  listDirectory,
75
91
  searchFiles,
76
92
  searchContent,
77
- // Advanced search (new)
93
+ // Advanced search
78
94
  glob: globSearch,
79
95
  grep,
80
96
  applyPatch,
@@ -116,6 +132,25 @@ export const toolFunctions = {
116
132
  // MCP management
117
133
  manageMCP,
118
134
  useMCP,
135
+ // Voice
136
+ makeVoiceCall,
137
+ // Phase 24: Image & document
138
+ generateImage,
139
+ readPDF,
140
+ // Phase 24: Developer tools
141
+ gitTool,
142
+ clipboard,
143
+ sshTool,
144
+ database,
145
+ // Phase 24: macOS / notifications
146
+ notification,
147
+ iMessageTool,
148
+ calendar,
149
+ contacts,
150
+ // Phase 24: External services
151
+ googlePlaces,
152
+ philipsHue,
153
+ sonos,
119
154
  };
120
155
 
121
156
  // ─── Tool Descriptions Array ───────────────────────────────────────────────────
@@ -170,4 +205,23 @@ export const toolDescriptions = [
170
205
  // MCP management
171
206
  manageMCPDescription,
172
207
  useMCPDescription,
208
+ // Voice
209
+ makeVoiceCallDescription,
210
+ // Phase 24: Image & document
211
+ generateImageDescription,
212
+ readPDFDescription,
213
+ // Phase 24: Developer tools
214
+ gitToolDescription,
215
+ clipboardDescription,
216
+ sshToolDescription,
217
+ databaseDescription,
218
+ // Phase 24: macOS / notifications
219
+ notificationDescription,
220
+ iMessageToolDescription,
221
+ calendarDescription,
222
+ contactsDescription,
223
+ // Phase 24: External services
224
+ googlePlacesDescription,
225
+ philipsHueDescription,
226
+ sonosDescription,
173
227
  ];
@@ -0,0 +1,283 @@
1
+ /**
2
+ * makeVoiceCall - Interactive outbound voice calls via Twilio REST API.
3
+ *
4
+ * Supports two modes:
5
+ *
6
+ * ONE-SHOT: initiate a call that plays a message and hangs up
7
+ * makeVoiceCall("call", "+1555...", {"message":"Your order is ready"})
8
+ *
9
+ * INTERACTIVE: full two-way conversation — agent speaks, listens, responds
10
+ * makeVoiceCall("initiate", "+1555...", {"greeting":"Hi, how can I help?"})
11
+ * makeVoiceCall("listen", sessionId) ← blocks until caller speaks
12
+ * makeVoiceCall("speak", sessionId, {"message":"..."}) ← agent says something
13
+ * makeVoiceCall("end", sessionId) ← hang up
14
+ *
15
+ * Credential resolution order (first match wins):
16
+ * 1. Per-tenant channel config (daemora tenant channel set <id> twilio_account_sid ...)
17
+ * 2. Global .env (TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_FROM)
18
+ *
19
+ * Required env for interactive mode:
20
+ * VOICE_WEBHOOK_BASE_URL=https://your-public-url.com (Twilio must reach this URL)
21
+ */
22
+
23
+ import tenantContext from "../tenants/TenantContext.js";
24
+ import voiceSessionManager from "../voice/VoiceSessionManager.js";
25
+
26
+ const TWILIO_API = "https://api.twilio.com/2010-04-01";
27
+
28
+ /** Resolve Twilio credentials: tenant config first, then global env */
29
+ function _getCreds() {
30
+ const store = tenantContext.getStore();
31
+ const ch = store?.resolvedConfig?.channelConfig || {};
32
+
33
+ return {
34
+ accountSid: ch.twilio_account_sid || process.env.TWILIO_ACCOUNT_SID || null,
35
+ authToken: ch.twilio_auth_token || process.env.TWILIO_AUTH_TOKEN || null,
36
+ fromNumber: ch.twilio_phone_from || process.env.TWILIO_PHONE_FROM || null,
37
+ };
38
+ }
39
+
40
+ /** Make an authenticated Twilio REST request */
41
+ async function _twilioRequest(accountSid, authToken, method, path, body = null) {
42
+ const url = `${TWILIO_API}/Accounts/${accountSid}${path}`;
43
+ const headers = {
44
+ Authorization: "Basic " + Buffer.from(`${accountSid}:${authToken}`).toString("base64"),
45
+ Accept: "application/json",
46
+ };
47
+ const opts = { method, headers };
48
+
49
+ if (body) {
50
+ opts.headers["Content-Type"] = "application/x-www-form-urlencoded";
51
+ opts.body = new URLSearchParams(body).toString();
52
+ }
53
+
54
+ const res = await fetch(url, opts);
55
+ const text = await res.text();
56
+ try {
57
+ return { ok: res.ok, status: res.status, data: JSON.parse(text) };
58
+ } catch {
59
+ return { ok: res.ok, status: res.status, data: { message: text } };
60
+ }
61
+ }
62
+
63
+ /** Build simple one-shot TwiML (speak a message, hang up) */
64
+ function _buildSayTwiML(message, voice = "Polly.Joanna", language = "en-US") {
65
+ const escaped = message
66
+ .replace(/&/g, "&amp;")
67
+ .replace(/</g, "&lt;")
68
+ .replace(/>/g, "&gt;");
69
+ return `<?xml version="1.0" encoding="UTF-8"?><Response><Say voice="${voice}" language="${language}">${escaped}</Say></Response>`;
70
+ }
71
+
72
+ // ─── Main tool function ────────────────────────────────────────────────────────
73
+
74
+ export async function makeVoiceCall(action, phoneNumberOrSessionId, optionsJson) {
75
+ if (!action) return 'Error: action required. Use: initiate|listen|speak|end|status|list (or "call" for one-shot)';
76
+
77
+ const { accountSid, authToken, fromNumber } = _getCreds();
78
+
79
+ // ── Non-Twilio session actions (don't need credentials) ─────────────────────
80
+ // listen / speak / end operate on an existing session
81
+
82
+ if (action === "listen") {
83
+ const sessionId = phoneNumberOrSessionId;
84
+ if (!sessionId) return "Error: provide the session ID returned by initiate.";
85
+ const session = voiceSessionManager.get(sessionId);
86
+ if (!session) return `Error: No active session "${sessionId}". Did the call end?`;
87
+ if (session.status === "ended") return "Call has already ended.";
88
+
89
+ let opts = {};
90
+ if (optionsJson) { try { opts = JSON.parse(optionsJson); } catch {} }
91
+ const timeout = (opts.timeout || 120) * 1000;
92
+
93
+ try {
94
+ const text = await session.waitForCallerInput(timeout);
95
+ return `Caller said: "${text}"`;
96
+ } catch (err) {
97
+ return `${err.message}`;
98
+ }
99
+ }
100
+
101
+ if (action === "speak") {
102
+ const sessionId = phoneNumberOrSessionId;
103
+ if (!sessionId) return "Error: provide the session ID returned by initiate.";
104
+ const session = voiceSessionManager.get(sessionId);
105
+ if (!session) return `Error: No active session "${sessionId}".`;
106
+ if (session.status === "ended") return "Call has already ended.";
107
+
108
+ let opts = {};
109
+ if (optionsJson) { try { opts = JSON.parse(optionsJson); } catch {} }
110
+ const message = opts.message;
111
+ if (!message) return 'Error: optionsJson {"message":"..."} is required for speak.';
112
+
113
+ session.setAgentResponse(message);
114
+ return `Speaking to caller: "${message}"`;
115
+ }
116
+
117
+ if (action === "end") {
118
+ const sessionId = phoneNumberOrSessionId;
119
+ if (!sessionId) return "Error: provide the session ID returned by initiate.";
120
+ const session = voiceSessionManager.get(sessionId);
121
+ if (!session) return `Error: No active session "${sessionId}".`;
122
+
123
+ session.setAgentResponse("__HANGUP__");
124
+ return `Ending call for session ${sessionId}.`;
125
+ }
126
+
127
+ if (action === "status") {
128
+ const sessionId = phoneNumberOrSessionId;
129
+ const session = voiceSessionManager.get(sessionId);
130
+ if (!session) return `No active session "${sessionId}".`;
131
+
132
+ const turns = session.transcript.length;
133
+ const lastEntry = session.transcript[turns - 1];
134
+ const lastTurn = lastEntry ? ` | Last: [${lastEntry.role}] "${lastEntry.text.slice(0, 60)}"` : "";
135
+ return `Session ${session.id} | Status: ${session.status} | Turns: ${turns}${lastTurn}`;
136
+ }
137
+
138
+ // ── Twilio API actions — credentials required ────────────────────────────────
139
+
140
+ if (!accountSid || !authToken) {
141
+ return "Error: Twilio not configured. Set TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN in .env, or: daemora tenant channel set <id> twilio_account_sid <sid>";
142
+ }
143
+
144
+ let opts = {};
145
+ if (optionsJson) { try { opts = JSON.parse(optionsJson); } catch { return "Error: optionsJson must be valid JSON."; } }
146
+
147
+ // ── initiate — start an interactive call ────────────────────────────────────
148
+ if (action === "initiate") {
149
+ const to = phoneNumberOrSessionId;
150
+ if (!to) return "Error: phoneNumber is required for initiate.";
151
+ const from = opts.from || fromNumber;
152
+ if (!from) return 'Error: No outbound number. Set TWILIO_PHONE_FROM in .env or pass optionsJson {"from":"+1555..."}';
153
+
154
+ const webhookBase = process.env.VOICE_WEBHOOK_BASE_URL?.replace(/\/$/, "");
155
+ if (!webhookBase) {
156
+ return "Error: VOICE_WEBHOOK_BASE_URL not set. Set it to your public URL (e.g. https://myagent.example.com) so Twilio can reach the webhooks.";
157
+ }
158
+
159
+ // Create session before the call so the webhook URL is ready
160
+ const session = voiceSessionManager.create({
161
+ callSid: null, // updated after Twilio responds
162
+ greeting: opts.greeting || null,
163
+ });
164
+
165
+ // Store voice model preference on session for TaskRunner to pick up
166
+ // Voice uses the fastest model by default (VOICE_MODEL env var or haiku/flash)
167
+ session.preferredModel = opts.model || process.env.VOICE_MODEL || null;
168
+
169
+ const body = {
170
+ To: to,
171
+ From: from,
172
+ Url: `${webhookBase}/voice/answer/${session.id}`,
173
+ StatusCallback: `${webhookBase}/voice/status/${session.id}`,
174
+ StatusCallbackMethod: "POST",
175
+ Method: "POST",
176
+ };
177
+ if (opts.timeout) body.Timeout = String(opts.timeout);
178
+ if (opts.record === true) body.Record = "true";
179
+ if (opts.machineDetection) body.MachineDetection = opts.machineDetection;
180
+
181
+ console.log(`[makeVoiceCall] Initiating interactive call to ${to} from ${from} | session ${session.id}`);
182
+ const result = await _twilioRequest(accountSid, authToken, "POST", "/Calls.json", body);
183
+
184
+ if (!result.ok) {
185
+ voiceSessionManager.delete(session.id);
186
+ return `Failed to initiate call: ${result.data?.message || result.data?.code || result.status}`;
187
+ }
188
+
189
+ // Update session with real callSid from Twilio
190
+ session.callSid = result.data.sid;
191
+
192
+ const d = result.data;
193
+ return (
194
+ `Interactive call started.\n` +
195
+ `Session ID: ${session.id} ← use this for listen/speak/end\n` +
196
+ `Call SID: ${d.sid}\n` +
197
+ `Status: ${d.status} (will update to "in-progress" when answered)\n` +
198
+ `To: ${d.to} | From: ${d.from}\n\n` +
199
+ `Next step: call makeVoiceCall("listen", "${session.id}") to wait for the caller to speak.`
200
+ );
201
+ }
202
+
203
+ // ── call — one-shot call that speaks a message and hangs up ─────────────────
204
+ if (action === "call") {
205
+ const to = phoneNumberOrSessionId;
206
+ if (!to) return "Error: phoneNumber is required for call.";
207
+ const from = opts.from || fromNumber;
208
+ if (!from) return 'Error: No outbound number. Set TWILIO_PHONE_FROM in .env or pass optionsJson {"from":"+1555..."}';
209
+
210
+ const body = { To: to, From: from };
211
+
212
+ if (opts.url) {
213
+ body.Url = opts.url;
214
+ } else if (opts.message) {
215
+ body.Twiml = _buildSayTwiML(opts.message, opts.voice || "Polly.Joanna", opts.language || "en-US");
216
+ } else {
217
+ return 'Error: Provide optionsJson {"message":"Hello"} to speak a message, or {"url":"https://twiml-url"} for custom TwiML.';
218
+ }
219
+
220
+ if (opts.statusCallback) body.StatusCallback = opts.statusCallback;
221
+ if (opts.timeout) body.Timeout = String(opts.timeout);
222
+ if (opts.record === true) body.Record = "true";
223
+
224
+ console.log(`[makeVoiceCall] One-shot call to ${to} from ${from}`);
225
+ const result = await _twilioRequest(accountSid, authToken, "POST", "/Calls.json", body);
226
+
227
+ if (!result.ok) {
228
+ return `Failed to initiate call: ${result.data?.message || result.data?.code || result.status}`;
229
+ }
230
+
231
+ const d = result.data;
232
+ return `Call initiated. SID: ${d.sid} | Status: ${d.status} | To: ${d.to} | From: ${d.from}`;
233
+ }
234
+
235
+ // ── hangup — end a call by SID (non-session, admin use) ─────────────────────
236
+ if (action === "hangup") {
237
+ const sid = opts.sid || phoneNumberOrSessionId;
238
+ if (!sid) return 'Error: Provide call SID via optionsJson {"sid":"CA..."} or as the second argument.';
239
+
240
+ const result = await _twilioRequest(accountSid, authToken, "POST", `/Calls/${sid}.json`, { Status: "completed" });
241
+ if (!result.ok) return `Failed to hang up: ${result.data?.message || result.status}`;
242
+ return `Call ${result.data.sid} ended. Final status: ${result.data.status}`;
243
+ }
244
+
245
+ // ── list — recent calls ──────────────────────────────────────────────────────
246
+ if (action === "list") {
247
+ const limit = opts.limit || 20;
248
+ const statusFilter = opts.status ? `&Status=${opts.status}` : "";
249
+ const toFilter = opts.to ? `&To=${encodeURIComponent(opts.to)}` : "";
250
+
251
+ const result = await _twilioRequest(
252
+ accountSid, authToken, "GET",
253
+ `/Calls.json?PageSize=${limit}${statusFilter}${toFilter}`
254
+ );
255
+
256
+ if (!result.ok) return `Failed to list calls: ${result.data?.message || result.status}`;
257
+
258
+ const calls = result.data?.calls || [];
259
+ if (calls.length === 0) return "No calls found.";
260
+
261
+ const lines = calls.map((c) => {
262
+ const dur = c.duration ? ` (${c.duration}s)` : "";
263
+ return ` ${c.sid} | ${c.status.padEnd(12)} | ${c.to} ← ${c.from}${dur} | ${c.start_time || c.date_created}`;
264
+ });
265
+ return `Recent calls (${calls.length}):\n${lines.join("\n")}`;
266
+ }
267
+
268
+ return `Error: Unknown action "${action}". Supported: initiate, listen, speak, end, status, call, hangup, list.`;
269
+ }
270
+
271
+ export const makeVoiceCallDescription =
272
+ 'makeVoiceCall(action: string, phoneNumberOrSessionId?: string, optionsJson?: string) - Outbound voice calls via Twilio.\n' +
273
+ 'INTERACTIVE MODE (two-way conversation):\n' +
274
+ ' action=initiate: start a call. optionsJson: {"greeting":"Hi, how can I help?","from":"+1555..."}. Returns sessionId.\n' +
275
+ ' action=listen: wait for caller to speak. First arg = sessionId. Returns: "Caller said: \\"...\\""\n' +
276
+ ' action=speak: say something to caller. First arg = sessionId. optionsJson: {"message":"Got it, one moment..."}\n' +
277
+ ' action=end: hang up the call. First arg = sessionId.\n' +
278
+ ' action=status: show session state and transcript. First arg = sessionId.\n' +
279
+ 'ONE-SHOT MODE:\n' +
280
+ ' action=call: dial a number and speak a message. optionsJson: {"message":"Your order is ready"} or {"url":"https://twiml-url"}\n' +
281
+ ' action=hangup: end a call by SID. optionsJson: {"sid":"CA..."}\n' +
282
+ ' action=list: recent calls. optionsJson: {"limit":20,"status":"completed","to":"+1555..."}\n' +
283
+ 'Credentials: TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN + TWILIO_PHONE_FROM in .env. Interactive mode also needs VOICE_WEBHOOK_BASE_URL.';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * manageAgents(action, paramsJson?) List, kill, or steer running sub-agents.
2
+ * manageAgents(action, paramsJson?) - List, kill, or steer running sub-agents.
3
3
  * Inspired by OpenClaw's subagents tool.
4
4
  */
5
5
  import {
@@ -17,7 +17,7 @@ export function manageAgents(action, paramsJson) {
17
17
  const agents = listActiveAgents();
18
18
  if (agents.length === 0) return "No active sub-agents running.";
19
19
  const lines = agents.map(
20
- (a) => `• ${a.id} "${a.task}" (running ${Math.round(a.elapsedMs / 1000)}s)`
20
+ (a) => `• ${a.id} - "${a.task}" (running ${Math.round(a.elapsedMs / 1000)}s)`
21
21
  );
22
22
  return `Active sub-agents (${agents.length}):\n${lines.join("\n")}`;
23
23
  }