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
@@ -1,17 +1,35 @@
1
1
  import mcpManager from "../mcp/MCPManager.js";
2
2
 
3
3
  /**
4
- * manageMCP inspect, add, remove, and reload MCP server connections at runtime.
4
+ * Strip credentials from an MCP server config object before it can reach agent output.
5
+ * env contains API keys (GITHUB_TOKEN etc.), headers contains Bearer tokens.
6
+ * We replace values with "[REDACTED]" rather than deleting keys so the agent can see
7
+ * which credential fields are configured without seeing the actual values.
8
+ */
9
+ function _stripCredentials(serverConfig) {
10
+ if (!serverConfig || typeof serverConfig !== "object") return serverConfig;
11
+ const safe = { ...serverConfig };
12
+ if (safe.env && typeof safe.env === "object") {
13
+ safe.env = Object.fromEntries(Object.keys(safe.env).map(k => [k, "[REDACTED]"]));
14
+ }
15
+ if (safe.headers && typeof safe.headers === "object") {
16
+ safe.headers = Object.fromEntries(Object.keys(safe.headers).map(k => [k, "[REDACTED]"]));
17
+ }
18
+ return safe;
19
+ }
20
+
21
+ /**
22
+ * manageMCP - inspect, add, remove, and reload MCP server connections at runtime.
5
23
  *
6
24
  * Actions:
7
- * list all configured servers and their tool names
8
- * tools full tool list with descriptions for a specific server
9
- * status connection status summary (same as list)
10
- * add add a new MCP server (saved to config/mcp.json + connected immediately)
11
- * remove disconnect and remove a server from config
12
- * enable enable a disabled server (reconnects it)
13
- * disable disable a server (disconnects it, keeps in config)
14
- * reload reconnect a server (useful after config changes)
25
+ * list - all configured servers and their tool names
26
+ * tools - full tool list with descriptions for a specific server
27
+ * status - connection status summary (same as list)
28
+ * add - add a new MCP server (saved to config/mcp.json + connected immediately)
29
+ * remove - disconnect and remove a server from config
30
+ * enable - enable a disabled server (reconnects it)
31
+ * disable - disable a server (disconnects it, keeps in config)
32
+ * reload - reconnect a server (useful after config changes)
15
33
  */
16
34
  export async function manageMCP(action, paramsJson) {
17
35
  const params = paramsJson
@@ -33,13 +51,13 @@ export async function manageMCP(action, paramsJson) {
33
51
 
34
52
  const lines = [];
35
53
  for (const name of configuredNames) {
36
- const cfg = allConfig[name];
54
+ const cfg = _stripCredentials(allConfig[name]);
37
55
  const live = connected.find(s => s.name === name);
38
56
  if (cfg.enabled === false) {
39
57
  lines.push(`⏸️ disabled ${name}`);
40
58
  } else if (live?.connected) {
41
59
  const toolList = live.tools.length > 0 ? live.tools.join(", ") : "(no tools)";
42
- lines.push(`✅ connected ${name}: ${live.tools.length} tools ${toolList}`);
60
+ lines.push(`✅ connected ${name}: ${live.tools.length} tools - ${toolList}`);
43
61
  } else {
44
62
  lines.push(`❌ disconnected ${name}`);
45
63
  }
@@ -77,12 +95,12 @@ export async function manageMCP(action, paramsJson) {
77
95
 
78
96
  const serverConfig = {};
79
97
  if (command) {
80
- // stdio credentials go as env vars injected into the subprocess
98
+ // stdio - credentials go as env vars injected into the subprocess
81
99
  serverConfig.command = command;
82
100
  if (args) serverConfig.args = args;
83
101
  if (env) serverConfig.env = env; // { "GITHUB_TOKEN": "ghp_..." }
84
102
  } else {
85
- // http/sse credentials go as HTTP request headers
103
+ // http/sse - credentials go as HTTP request headers
86
104
  serverConfig.url = url;
87
105
  if (transport) serverConfig.transport = transport; // "sse" or omit for HTTP
88
106
  if (headers) serverConfig.headers = headers; // { "Authorization": "Bearer ${TOKEN}" }
@@ -143,8 +161,8 @@ export async function manageMCP(action, paramsJson) {
143
161
  export const manageMCPDescription =
144
162
  `manageMCP(action: string, paramsJson?: string) - Manage MCP server connections at runtime. Changes saved to config/mcp.json.
145
163
  Actions:
146
- list/status - no params all servers with connection status and tool names
147
- tools - {"server":"github"} full tool list for a server, or {} for all servers
164
+ list/status - no params - all servers with connection status and tool names
165
+ tools - {"server":"github"} - full tool list for a server, or {} for all servers
148
166
  add - Add and immediately connect a server:
149
167
  stdio (auth via env vars passed to subprocess):
150
168
  {"name":"github","command":"npx","args":["-y","@modelcontextprotocol/server-github"],"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"ghp_..."}}
@@ -152,8 +170,8 @@ export const manageMCPDescription =
152
170
  {"name":"myapi","url":"https://api.example.com/mcp","headers":{"Authorization":"Bearer \${MY_TOKEN}"}}
153
171
  SSE (auth via request headers, applied to both GET stream and POST calls):
154
172
  {"name":"myapi","url":"https://api.example.com/sse","transport":"sse","headers":{"Authorization":"Bearer \${MY_TOKEN}","X-API-Key":"\${MY_KEY}"}}
155
- Header values support \${VAR_NAME} expanded from process.env at connect time.
156
- remove - {"name":"github"} disconnect and remove from config
157
- enable - {"name":"github"} re-enable a disabled server (reconnects)
158
- disable - {"name":"github"} disconnect and mark disabled in config
159
- reload - {"name":"github"} reconnect (useful after editing config)`;
173
+ Header values support \${VAR_NAME} - expanded from process.env at connect time.
174
+ remove - {"name":"github"} - disconnect and remove from config
175
+ enable - {"name":"github"} - re-enable a disabled server (reconnects)
176
+ disable - {"name":"github"} - disconnect and mark disabled in config
177
+ reload - {"name":"github"} - reconnect (useful after editing config)`;
@@ -3,11 +3,12 @@ import { join } from "path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { config } from "../config/default.js";
5
5
  import tenantContext from "../tenants/TenantContext.js";
6
+ import { generateEmbedding, getEmbeddingProvider } from "../utils/Embeddings.js";
6
7
 
7
8
  /**
8
- * Memory tools read/write/search/prune persistent agent memory.
9
+ * Memory tools - read/write/search/prune persistent agent memory.
9
10
  * Upgraded: category tags, context lines in search, pruning old entries.
10
- * Phase 17: Per-tenant isolation each tenant gets their own memory dir.
11
+ * Phase 17: Per-tenant isolation - each tenant gets their own memory dir.
11
12
  *
12
13
  * - MEMORY.md: Long-term facts (timestamped entries with optional category)
13
14
  * - data/memory/YYYY-MM-DD.md: Daily logs
@@ -22,7 +23,7 @@ const _GLOBAL_EMBEDDINGS_PATH = join(config.memoryDir, "embeddings.json");
22
23
 
23
24
  /**
24
25
  * Get memory paths for the current tenant context (or global paths if no tenant).
25
- * Called at runtime from each function NOT at module load so AsyncLocalStorage is active.
26
+ * Called at runtime from each function - NOT at module load - so AsyncLocalStorage is active.
26
27
  */
27
28
  function _getMemoryPaths() {
28
29
  const store = tenantContext.getStore();
@@ -55,11 +56,11 @@ function _getPathsForTenantId(tenantId) {
55
56
 
56
57
  // ─── Vector / Semantic Memory ─────────────────────────────────────────────────
57
58
  // Stored separately from MEMORY.md so the markdown file stays human-readable.
58
- // Uses OpenAI text-embedding-3-small (512 dims) 3x smaller than default 1536,
59
+ // Uses OpenAI text-embedding-3-small (512 dims) - 3x smaller than default 1536,
59
60
  // same key as the rest of Daemora, no extra deps.
60
61
  // Falls back to keyword search if OPENAI_API_KEY is absent.
61
62
 
62
- // Patterns from adversarial testing prevent memory from becoming a prompt-injection vector
63
+ // Patterns from adversarial testing - prevent memory from becoming a prompt-injection vector
63
64
  const _INJECTION_PATTERNS = [
64
65
  /ignore (all|any|previous|above|prior) instructions/i,
65
66
  /do not follow (the )?(system|developer)/i,
@@ -97,7 +98,7 @@ function _loadEmbeddingsForPath(embeddingsPath) {
97
98
  try { return JSON.parse(readFileSync(embeddingsPath, "utf-8")); } catch { return []; }
98
99
  }
99
100
 
100
- // Standard cosine similarity correct metric for text embeddings (unlike OpenClaw's L2)
101
+ // Standard cosine similarity - correct metric for text embeddings (unlike OpenClaw's L2)
101
102
  function _cosineSim(a, b) {
102
103
  let dot = 0, na = 0, nb = 0;
103
104
  for (let i = 0; i < a.length; i++) {
@@ -109,22 +110,11 @@ function _cosineSim(a, b) {
109
110
  return dot / (Math.sqrt(na) * Math.sqrt(nb));
110
111
  }
111
112
 
112
- // Generate embedding using OpenAI text-embedding-3-small at 512 dims.
113
- // 512 dims = ~3x smaller JSON than default 1536, with minimal quality loss for recall.
114
- async function _generateEmbedding(text) {
115
- if (!process.env.OPENAI_API_KEY) return null;
116
- try {
117
- const { default: OpenAI } = await import("openai");
118
- const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
119
- const res = await client.embeddings.create({
120
- model: "text-embedding-3-small",
121
- input: text.slice(0, 8000), // API hard limit
122
- dimensions: 512,
123
- });
124
- return res.data[0].embedding;
125
- } catch {
126
- return null;
127
- }
113
+ // Delegate to shared provider-agnostic embedding utility.
114
+ // Supports OpenAI, Google Gemini, and Ollama - auto-detected from available API keys.
115
+ // Returns null if no provider configured → callers fall back to keyword search.
116
+ function _generateEmbedding(text) {
117
+ return generateEmbedding(text);
128
118
  }
129
119
 
130
120
  // Store a new memory entry's embedding. Called as fire-and-forget from writeMemory.
@@ -140,7 +130,8 @@ async function _indexEntry(text, category, timestamp) {
140
130
  if (e.vector && _cosineSim(e.vector, vector) > 0.92) return;
141
131
  }
142
132
 
143
- entries.push({ id: randomUUID(), timestamp, category: category || "general", text, vector });
133
+ const provider = getEmbeddingProvider() || "openai";
134
+ entries.push({ id: randomUUID(), timestamp, category: category || "general", text, vector, provider });
144
135
  _saveEmbeddings(entries);
145
136
  }
146
137
 
@@ -162,8 +153,9 @@ export async function getRelevantMemories(taskInput, topK = 5, tenantId = null)
162
153
  const entries = _loadEmbeddingsForPath(paths.embeddingsPath);
163
154
  if (entries.length === 0) return null;
164
155
 
156
+ const currentProvider = getEmbeddingProvider() || "openai";
165
157
  const scored = entries
166
- .filter((e) => e.vector)
158
+ .filter((e) => e.vector && (e.provider === currentProvider || (!e.provider && currentProvider === "openai")))
167
159
  .map((e) => ({ ...e, score: _cosineSim(e.vector, queryVector) }))
168
160
  .filter((e) => e.score >= 0.40)
169
161
  .sort((a, b) => b.score - a.score)
@@ -248,7 +240,7 @@ export async function writeMemory(entry, category) {
248
240
  writeFileSync(memoryPath, existing + formatted, "utf-8");
249
241
  console.log(` [memory] Entry added (${entry.length} chars)${category ? ` category=${category}` : ""}`);
250
242
 
251
- // Generate and store embedding in background does not block the tool response
243
+ // Generate and store embedding in background - does not block the tool response
252
244
  _indexEntry(entry, category, timestamp).catch(() => {});
253
245
 
254
246
  return `Memory saved${category ? ` [${category}]` : ""}: "${entry.slice(0, 80)}${entry.length > 80 ? "..." : ""}"`;
@@ -282,10 +274,10 @@ export function writeDailyLog(entry) {
282
274
  if (existsSync(logPath)) {
283
275
  existing = readFileSync(logPath, "utf-8");
284
276
  } else {
285
- existing = `# Daily Log ${today}\n\n`;
277
+ existing = `# Daily Log - ${today}\n\n`;
286
278
  }
287
279
 
288
- const formatted = `- **${timestamp}** ${entry.trim()}\n`;
280
+ const formatted = `- **${timestamp}** - ${entry.trim()}\n`;
289
281
  writeFileSync(logPath, existing + formatted, "utf-8");
290
282
 
291
283
  console.log(` [memory] Daily log entry added`);
@@ -309,16 +301,17 @@ export async function searchMemory(query, optionsJson) {
309
301
  const mode = opts.mode || "auto"; // "auto" | "semantic" | "keyword"
310
302
 
311
303
  // ── Semantic search (cosine similarity on stored embeddings) ─────────────────
312
- if (mode !== "keyword" && process.env.OPENAI_API_KEY) {
304
+ if (mode !== "keyword" && getEmbeddingProvider()) {
313
305
  const queryVector = await _generateEmbedding(query);
314
306
  if (queryVector) {
307
+ const currentProvider = getEmbeddingProvider() || "openai";
315
308
  let entries = _loadEmbeddings();
316
309
  if (filterCategory) {
317
310
  entries = entries.filter((e) => e.category === filterCategory.toLowerCase());
318
311
  }
319
312
 
320
313
  const scored = entries
321
- .filter((e) => e.vector)
314
+ .filter((e) => e.vector && (e.provider === currentProvider || (!e.provider && currentProvider === "openai")))
322
315
  .map((e) => ({ ...e, score: _cosineSim(e.vector, queryVector) }))
323
316
  .filter((e) => e.score >= minScore)
324
317
  .sort((a, b) => b.score - a.score)
@@ -329,12 +322,12 @@ export async function searchMemory(query, optionsJson) {
329
322
  const lines = scored.map(
330
323
  (e, i) =>
331
324
  `${i + 1}. [${e.category}] (${(e.score * 100).toFixed(0)}% match) ` +
332
- `${_escapeForPrompt(e.text)}\n ${e.timestamp.split("T")[0]}`
325
+ `${_escapeForPrompt(e.text)}\n - ${e.timestamp.split("T")[0]}`
333
326
  );
334
327
  return `Found ${scored.length} semantic match(es) for "${query}":\n\n${lines.join("\n")}`;
335
328
  }
336
329
 
337
- // Nothing above threshold fall through to keyword unless semantic-only requested
330
+ // Nothing above threshold - fall through to keyword unless semantic-only requested
338
331
  if (mode === "semantic") {
339
332
  return `No semantic matches found for "${query}" (threshold: ${minScore})`;
340
333
  }
@@ -399,7 +392,7 @@ export function pruneMemory(maxAgeDaysStr) {
399
392
  let prunedMemory = 0;
400
393
  let prunedLogs = 0;
401
394
 
402
- // Prune MEMORY.md keep entries newer than cutoff
395
+ // Prune MEMORY.md - keep entries newer than cutoff
403
396
  if (existsSync(memoryPath)) {
404
397
  const content = readFileSync(memoryPath, "utf-8");
405
398
  const entries = parseEntries(content);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * messageChannel(channel, target, message) Send a message to any configured channel.
2
+ * messageChannel(channel, target, message) - Send a message to any configured channel.
3
3
  * Allows the agent to proactively message users, not just reply to inbound tasks.
4
4
  * Inspired by OpenClaw's message tool.
5
5
  */
@@ -0,0 +1,90 @@
1
+ /**
2
+ * notification - Send desktop / mobile push notifications.
3
+ * macOS: osascript / terminal-notifier. Linux: notify-send. Windows: PowerShell.
4
+ * Cross-platform mobile: Pushover, Pushbullet, Ntfy.sh.
5
+ */
6
+ import { execSync } from "node:child_process";
7
+
8
+ function platform() { return process.platform; }
9
+
10
+ export async function notification(title, message, options = {}) {
11
+ if (!title) return "Error: title is required";
12
+ if (!message) return "Error: message is required";
13
+
14
+ const opts = typeof options === "string" ? JSON.parse(options) : (options || {});
15
+ const { sound = false, url = null, service = "desktop", topic = null } = opts;
16
+
17
+ // ── Desktop notification ────────────────────────────────────────────────
18
+ if (service === "desktop") {
19
+ try {
20
+ if (platform() === "darwin") {
21
+ // Use osascript for macOS (built-in, no deps)
22
+ const script = `display notification ${JSON.stringify(message)} with title ${JSON.stringify(title)}${sound ? " sound name \"Glass\"" : ""}`;
23
+ execSync(`osascript -e '${script.replace(/'/g, "\\'")}'`, { timeout: 5000 });
24
+ return `Notification sent: "${title}"`;
25
+ } else if (platform() === "linux") {
26
+ const urgency = opts.urgency || "normal";
27
+ const expireMs = opts.expireMs || 5000;
28
+ execSync(`notify-send -u ${urgency} -t ${expireMs} ${JSON.stringify(title)} ${JSON.stringify(message)}`, { timeout: 5000 });
29
+ return `Notification sent: "${title}"`;
30
+ } else if (platform() === "win32") {
31
+ const ps = `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; $template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); $template.SelectSingleNode('//text[@id=1]').AppendChild($template.CreateTextNode('${title.replace(/'/g, "''")}')) > $null; $template.SelectSingleNode('//text[@id=2]').AppendChild($template.CreateTextNode('${message.replace(/'/g, "''")}')) > $null; $toast = [Windows.UI.Notifications.ToastNotification]::new($template); [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Daemora').Show($toast)`;
32
+ execSync(`powershell -Command "${ps}"`, { timeout: 5000 });
33
+ return `Notification sent: "${title}"`;
34
+ } else {
35
+ return "Error: desktop notifications not supported on this platform";
36
+ }
37
+ } catch (err) {
38
+ return `Notification error: ${err.message}`;
39
+ }
40
+ }
41
+
42
+ // ── Ntfy.sh (HTTP push — open source, self-hostable) ───────────────────
43
+ if (service === "ntfy") {
44
+ const ntfyUrl = process.env.NTFY_URL || "https://ntfy.sh";
45
+ const ntfyTopic = topic || process.env.NTFY_TOPIC;
46
+ if (!ntfyTopic) return "Error: NTFY_TOPIC env var or topic option required for ntfy service";
47
+
48
+ const { default: fetch } = await import("node-fetch").catch(() => ({ default: globalThis.fetch }));
49
+ const res = await fetch(`${ntfyUrl}/${ntfyTopic}`, {
50
+ method: "POST",
51
+ body: message,
52
+ headers: {
53
+ "Title": title,
54
+ ...(url ? { "Click": url } : {}),
55
+ ...(process.env.NTFY_TOKEN ? { "Authorization": `Bearer ${process.env.NTFY_TOKEN}` } : {}),
56
+ },
57
+ });
58
+ if (!res.ok) return `Ntfy error: ${res.status} ${await res.text()}`;
59
+ return `Ntfy notification sent to topic "${ntfyTopic}": "${title}"`;
60
+ }
61
+
62
+ // ── Pushover ────────────────────────────────────────────────────────────
63
+ if (service === "pushover") {
64
+ const token = process.env.PUSHOVER_API_TOKEN;
65
+ const user = process.env.PUSHOVER_USER_KEY;
66
+ if (!token || !user) return "Error: PUSHOVER_API_TOKEN and PUSHOVER_USER_KEY env vars required";
67
+
68
+ const body = new URLSearchParams({ token, user, title, message });
69
+ if (url) body.set("url", url);
70
+
71
+ const { default: fetch } = await import("node-fetch").catch(() => ({ default: globalThis.fetch }));
72
+ const res = await fetch("https://api.pushover.net/1/messages.json", { method: "POST", body });
73
+ const data = await res.json();
74
+ if (data.status !== 1) return `Pushover error: ${JSON.stringify(data.errors)}`;
75
+ return `Pushover notification sent: "${title}"`;
76
+ }
77
+
78
+ return `Unknown service: "${service}". Valid: desktop, ntfy, pushover`;
79
+ }
80
+
81
+ export const notificationDescription =
82
+ `notification(title: string, message: string, options?: object) - Send desktop or push notifications.
83
+ options.service: "desktop" (default) | "ntfy" | "pushover"
84
+ options.sound: boolean (macOS only, plays Glass sound)
85
+ options.url: URL to open on click (ntfy/pushover)
86
+ options.topic: ntfy topic name (or set NTFY_TOPIC env)
87
+ Env vars: NTFY_URL, NTFY_TOPIC, NTFY_TOKEN, PUSHOVER_API_TOKEN, PUSHOVER_USER_KEY
88
+ Examples:
89
+ notification("Task done", "Your report is ready") → desktop alert
90
+ notification("Alert", "Server down", {"service":"ntfy","topic":"myalerts"})`;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * philipsHue - Control Philips Hue smart lights via local Bridge API.
3
+ * Requires HUE_BRIDGE_IP and HUE_API_KEY env vars.
4
+ * All requests go to the local bridge — no cloud dependency.
5
+ */
6
+
7
+ export async function philipsHue(action, paramsJson) {
8
+ if (!action) return "Error: action required. Valid: list, on, off, color, brightness, scene, discover";
9
+ const params = paramsJson
10
+ ? (typeof paramsJson === "string" ? JSON.parse(paramsJson) : paramsJson)
11
+ : {};
12
+
13
+ const bridgeIp = params.bridgeIp || process.env.HUE_BRIDGE_IP;
14
+ const apiKey = params.apiKey || process.env.HUE_API_KEY;
15
+
16
+ // Discovery doesn't require credentials
17
+ if (action === "discover") {
18
+ const fetchFn = globalThis.fetch || (await import("node-fetch")).default;
19
+ try {
20
+ const res = await fetchFn("https://discovery.meethue.com/");
21
+ const data = await res.json();
22
+ if (!data.length) return "No Hue bridges found on network";
23
+ return data.map(b => `Bridge: ${b.id} at ${b.internalipaddress}`).join("\n");
24
+ } catch (err) {
25
+ return `Discovery error: ${err.message}`;
26
+ }
27
+ }
28
+
29
+ if (!bridgeIp) return "Error: HUE_BRIDGE_IP env var or bridgeIp param required";
30
+ if (!apiKey) return "Error: HUE_API_KEY env var or apiKey param required";
31
+
32
+ const BASE = `http://${bridgeIp}/api/${apiKey}`;
33
+ const fetchFn = globalThis.fetch || (await import("node-fetch")).default;
34
+
35
+ const hueReq = async (method, path, body = null) => {
36
+ const opts = { method, headers: { "Content-Type": "application/json" } };
37
+ if (body) opts.body = JSON.stringify(body);
38
+ const res = await fetchFn(`${BASE}${path}`, opts);
39
+ return res.json();
40
+ };
41
+
42
+ if (action === "list") {
43
+ const data = await hueReq("GET", "/lights");
44
+ if (!data || typeof data !== "object") return "Error reading lights";
45
+ const entries = Object.entries(data);
46
+ if (!entries.length) return "No lights found";
47
+ return entries.map(([id, light]) =>
48
+ `[${id}] ${light.name} — ${light.state.on ? "ON" : "OFF"} — brightness: ${light.state.bri || "N/A"} — ${light.state.reachable ? "reachable" : "unreachable"}`
49
+ ).join("\n");
50
+ }
51
+
52
+ const { lightId, groupId } = params;
53
+ const targetPath = groupId
54
+ ? `/groups/${groupId}/action`
55
+ : lightId
56
+ ? `/lights/${lightId}/state`
57
+ : null;
58
+
59
+ if (action === "on") {
60
+ if (!targetPath) return "Error: lightId or groupId required";
61
+ await hueReq("PUT", targetPath, { on: true });
62
+ return `Light ${lightId || `group ${groupId}`} turned ON`;
63
+ }
64
+
65
+ if (action === "off") {
66
+ if (!targetPath) return "Error: lightId or groupId required";
67
+ await hueReq("PUT", targetPath, { on: false });
68
+ return `Light ${lightId || `group ${groupId}`} turned OFF`;
69
+ }
70
+
71
+ if (action === "brightness") {
72
+ if (!targetPath) return "Error: lightId or groupId required";
73
+ const { level } = params;
74
+ if (level === undefined) return "Error: level (0-254) required";
75
+ const bri = Math.max(0, Math.min(254, Math.round(level)));
76
+ await hueReq("PUT", targetPath, { on: true, bri });
77
+ return `Brightness set to ${level} for light ${lightId || `group ${groupId}`}`;
78
+ }
79
+
80
+ if (action === "color") {
81
+ if (!targetPath) return "Error: lightId or groupId required";
82
+ const { hue, sat, bri, xy, colorTemp, hex } = params;
83
+
84
+ let state = { on: true };
85
+
86
+ if (hex) {
87
+ // Convert hex to XY (approximate using CIE 1931 color space)
88
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
89
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
90
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
91
+ // Gamma correction
92
+ const toLinear = c => c > 0.04045 ? Math.pow((c + 0.055) / 1.055, 2.4) : c / 12.92;
93
+ const rL = toLinear(r), gL = toLinear(g), bL = toLinear(b);
94
+ const X = rL * 0.664511 + gL * 0.154324 + bL * 0.162028;
95
+ const Y = rL * 0.283881 + gL * 0.668433 + bL * 0.047685;
96
+ const Z = rL * 0.000088 + gL * 0.072310 + bL * 0.986039;
97
+ const sum = X + Y + Z || 1;
98
+ state.xy = [X / sum, Y / sum];
99
+ state.bri = Math.round(Y * 254);
100
+ } else if (xy) {
101
+ state.xy = xy;
102
+ } else if (hue !== undefined) {
103
+ state.hue = hue;
104
+ if (sat !== undefined) state.sat = sat;
105
+ if (bri !== undefined) state.bri = bri;
106
+ } else if (colorTemp !== undefined) {
107
+ state.ct = colorTemp; // Mired color temperature (153=cool, 500=warm)
108
+ } else {
109
+ return "Error: provide hex, xy, hue/sat, or colorTemp";
110
+ }
111
+
112
+ await hueReq("PUT", targetPath, state);
113
+ return `Color set for light ${lightId || `group ${groupId}`}`;
114
+ }
115
+
116
+ if (action === "scene") {
117
+ const { sceneId } = params;
118
+ const gId = groupId || "0";
119
+ if (!sceneId) {
120
+ // List scenes
121
+ const data = await hueReq("GET", "/scenes");
122
+ const entries = Object.entries(data || {});
123
+ if (!entries.length) return "No scenes found";
124
+ return entries.map(([id, s]) => `[${id}] ${s.name}`).join("\n");
125
+ }
126
+ await hueReq("PUT", `/groups/${gId}/action`, { scene: sceneId });
127
+ return `Scene "${sceneId}" activated`;
128
+ }
129
+
130
+ return `Unknown action: "${action}". Valid: list, on, off, brightness, color, scene, discover`;
131
+ }
132
+
133
+ export const philipsHueDescription =
134
+ `philipsHue(action: string, paramsJson?: object) - Control Philips Hue smart lights via local bridge.
135
+ action: "list" | "on" | "off" | "brightness" | "color" | "scene" | "discover"
136
+ list: {} → shows all lights with status
137
+ on/off: { lightId?: "1", groupId?: "1" }
138
+ brightness: { lightId, level: 0-254 }
139
+ color: { lightId, hex?: "#ff6600" | hue?: 0-65535, sat?: 0-254 | xy?: [x,y] | colorTemp?: 153-500 }
140
+ scene: { groupId?, sceneId? } (omit sceneId to list scenes)
141
+ discover: {} → finds bridges on local network
142
+ Env vars: HUE_BRIDGE_IP, HUE_API_KEY
143
+ Examples:
144
+ philipsHue("list")
145
+ philipsHue("on", {"lightId":"1"})
146
+ philipsHue("color", {"lightId":"2","hex":"#ff6600"})
147
+ philipsHue("brightness", {"groupId":"1","level":128})`;
@@ -6,18 +6,18 @@ import { v4 as uuidv4 } from "uuid";
6
6
  const WORKSPACES_DIR = join(config.dataDir, "workspaces");
7
7
 
8
8
  /**
9
- * Project Tracker SQLite-equivalent task/project tracking for the agent.
9
+ * Project Tracker - SQLite-equivalent task/project tracking for the agent.
10
10
  *
11
11
  * The agent uses this to plan multi-step work, track what's done vs pending,
12
12
  * and resume from where it left off if interrupted.
13
13
  *
14
14
  * Actions:
15
- * createProject create a project with optional initial task list
16
- * addTask add a task to an existing project
17
- * updateTask mark a task as in_progress / done / failed / skipped
18
- * getProject full status of one project (what's done, what's pending)
19
- * listProjects all projects with summary
20
- * deleteProject remove a completed/stale project
15
+ * createProject - create a project with optional initial task list
16
+ * addTask - add a task to an existing project
17
+ * updateTask - mark a task as in_progress / done / failed / skipped
18
+ * getProject - full status of one project (what's done, what's pending)
19
+ * listProjects - all projects with summary
20
+ * deleteProject - remove a completed/stale project
21
21
  *
22
22
  * Storage: data/projects/<id>.json (JSON files, no external deps)
23
23
  */
@@ -97,7 +97,7 @@ export function projectTracker(action, paramsJson) {
97
97
 
98
98
  const taskList = project.tasks.length > 0
99
99
  ? project.tasks.map(t => ` ${STATUS_ICON.pending} [${t.id}] ${t.title}`).join("\n")
100
- : " (no tasks yet use addTask to add them)";
100
+ : " (no tasks yet - use addTask to add them)";
101
101
 
102
102
  return `Project created: ${project.id}\nName: ${name}${description ? `\nDescription: ${description}` : ""}\nWorkspace: ${workspace}\nTasks (${project.tasks.length}):\n${taskList}`;
103
103
  }
@@ -40,7 +40,7 @@ export function readFile(filePath, offsetStr, limitStr) {
40
40
  result += `\n\n[... ${totalLines - endIdx} more lines. Use offset=${endIdx + 1} to continue reading.]`;
41
41
  }
42
42
 
43
- console.log(` [readFile] Done showing lines ${startIdx + 1}-${endIdx} of ${totalLines}`);
43
+ console.log(` [readFile] Done - showing lines ${startIdx + 1}-${endIdx} of ${totalLines}`);
44
44
  return result;
45
45
  } catch (error) {
46
46
  console.log(` [readFile] Failed: ${error.message}`);
@@ -0,0 +1,73 @@
1
+ /**
2
+ * readPDF - Extract text content from a PDF file.
3
+ * Uses pdftotext (poppler) if available, falls back to OpenAI vision API.
4
+ */
5
+ import { execSync } from "node:child_process";
6
+ import { readFileSync, existsSync } from "node:fs";
7
+ import filesystemGuard from "../safety/FilesystemGuard.js";
8
+ import tenantContext from "../tenants/TenantContext.js";
9
+
10
+ export async function readPDF(filePath, optionsJson) {
11
+ if (!filePath) return "Error: filePath is required.";
12
+
13
+ const guard = filesystemGuard.checkRead(filePath);
14
+ if (!guard.allowed) return `Access denied: ${guard.reason}`;
15
+ if (!existsSync(filePath)) return `Error: File not found: ${filePath}`;
16
+
17
+ let opts = {};
18
+ if (optionsJson) { try { opts = JSON.parse(optionsJson); } catch {} }
19
+ const { pages = null, method = "auto" } = opts;
20
+
21
+ // Method 1: pdftotext (poppler-utils) — fast, no API cost
22
+ if (method === "auto" || method === "pdftotext") {
23
+ try {
24
+ const pageFlag = pages ? `-f ${pages.split("-")[0]} -l ${pages.split("-")[1] || pages.split("-")[0]}` : "";
25
+ const text = execSync(`pdftotext ${pageFlag} "${filePath}" -`, { encoding: "utf-8", timeout: 30000 });
26
+ if (text.trim()) return text.trim();
27
+ } catch {
28
+ // pdftotext not available, fall through
29
+ }
30
+ }
31
+
32
+ // Method 2: OpenAI vision API — works without pdftotext installed
33
+ if (method === "auto" || method === "vision") {
34
+ const store = tenantContext.getStore();
35
+ const apiKey = store?.apiKeys?.OPENAI_API_KEY || process.env.OPENAI_API_KEY;
36
+ if (!apiKey) return "Error: pdftotext not found and OPENAI_API_KEY not set. Install poppler-utils or set OPENAI_API_KEY.";
37
+
38
+ try {
39
+ const fileBytes = readFileSync(filePath);
40
+ const b64 = fileBytes.toString("base64");
41
+ const res = await fetch("https://api.openai.com/v1/chat/completions", {
42
+ method: "POST",
43
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
44
+ body: JSON.stringify({
45
+ model: "gpt-4o",
46
+ messages: [{
47
+ role: "user",
48
+ content: [
49
+ { type: "text", text: "Extract all text content from this PDF. Return only the text, preserve structure." },
50
+ { type: "image_url", image_url: { url: `data:application/pdf;base64,${b64}` } },
51
+ ],
52
+ }],
53
+ max_tokens: 4096,
54
+ }),
55
+ });
56
+ const data = await res.json();
57
+ if (!res.ok) return `Error: ${data.error?.message || res.status}`;
58
+ return data.choices?.[0]?.message?.content || "No text extracted.";
59
+ } catch (err) {
60
+ return `Error extracting PDF: ${err.message}`;
61
+ }
62
+ }
63
+
64
+ return "Error: No extraction method available. Install poppler-utils (brew install poppler) or set OPENAI_API_KEY.";
65
+ }
66
+
67
+ export const readPDFDescription =
68
+ `readPDF(filePath: string, optionsJson?: string) - Extract text from a PDF file.
69
+ filePath: path to the PDF file
70
+ optionsJson: {"pages":"1-5","method":"auto"}
71
+ method: "auto" (pdftotext first, then vision), "pdftotext", "vision"
72
+ pages: page range like "1-5" (pdftotext only)
73
+ Returns extracted text content.`;