mercury-agent 0.4.5

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 (218) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +438 -0
  3. package/container/Dockerfile +127 -0
  4. package/container/Dockerfile.base +109 -0
  5. package/container/Dockerfile.power +17 -0
  6. package/container/agent-package.json +8 -0
  7. package/container/build.sh +54 -0
  8. package/docs/TODOS.md +147 -0
  9. package/docs/auth/dashboard.md +28 -0
  10. package/docs/auth/overview.md +109 -0
  11. package/docs/auth/whatsapp.md +173 -0
  12. package/docs/configuration.md +54 -0
  13. package/docs/container-lifecycle.md +349 -0
  14. package/docs/context-architecture.md +87 -0
  15. package/docs/deployment.md +199 -0
  16. package/docs/extensions.md +375 -0
  17. package/docs/graceful-shutdown.md +62 -0
  18. package/docs/kb-distillation.md +77 -0
  19. package/docs/media/overview.md +140 -0
  20. package/docs/media/whatsapp.md +171 -0
  21. package/docs/memory.md +137 -0
  22. package/docs/permissions.md +217 -0
  23. package/docs/pipeline.md +228 -0
  24. package/docs/prd-chat-memory.md +76 -0
  25. package/docs/prd-config-load.md +82 -0
  26. package/docs/rate-limiting.md +166 -0
  27. package/docs/scheduler.md +288 -0
  28. package/docs/setup-discord.md +100 -0
  29. package/docs/setup-slack.md +119 -0
  30. package/docs/setup-whatsapp.md +94 -0
  31. package/docs/subagents.md +166 -0
  32. package/docs/web-search.md +62 -0
  33. package/examples/extensions/README.md +12 -0
  34. package/examples/extensions/charts/index.ts +13 -0
  35. package/examples/extensions/charts/skill/SKILL.md +98 -0
  36. package/examples/extensions/gws/README.md +52 -0
  37. package/examples/extensions/gws/index.ts +106 -0
  38. package/examples/extensions/gws/skill/SKILL.md +57 -0
  39. package/examples/extensions/gws/skill/references/calendar.md +101 -0
  40. package/examples/extensions/gws/skill/references/docs.md +65 -0
  41. package/examples/extensions/gws/skill/references/drive.md +79 -0
  42. package/examples/extensions/gws/skill/references/gmail.md +85 -0
  43. package/examples/extensions/gws/skill/references/sheets.md +60 -0
  44. package/examples/extensions/napkin/index.ts +821 -0
  45. package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
  46. package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
  47. package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
  48. package/examples/extensions/napkin/skill/SKILL.md +728 -0
  49. package/examples/extensions/pdf/index.ts +23 -0
  50. package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
  51. package/examples/extensions/pdf/skill/SKILL.md +314 -0
  52. package/examples/extensions/pdf/skill/forms.md +294 -0
  53. package/examples/extensions/pdf/skill/reference.md +612 -0
  54. package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
  55. package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
  56. package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
  57. package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
  58. package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
  59. package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
  60. package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
  61. package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
  62. package/examples/extensions/permission-guard/index.ts +65 -0
  63. package/examples/extensions/pinchtab/index.ts +199 -0
  64. package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
  65. package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
  66. package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
  67. package/examples/extensions/pinchtab/skill/references/api.md +297 -0
  68. package/examples/extensions/pinchtab/skill/references/env.md +45 -0
  69. package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
  70. package/examples/extensions/tradestation/host/refresh.ts +102 -0
  71. package/examples/extensions/tradestation/index.ts +153 -0
  72. package/examples/extensions/tradestation/skill/SKILL.md +67 -0
  73. package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
  74. package/examples/extensions/voice-synth/index.ts +94 -0
  75. package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
  76. package/examples/extensions/voice-transcribe/index.ts +381 -0
  77. package/examples/extensions/voice-transcribe/requirements.txt +8 -0
  78. package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
  79. package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
  80. package/examples/extensions/web-search/index.ts +22 -0
  81. package/examples/extensions/web-search/skill/SKILL.md +114 -0
  82. package/examples/extensions/web-search/skill/references/apartments.md +178 -0
  83. package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
  84. package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
  85. package/examples/extensions/web-search/skill/references/flights.md +133 -0
  86. package/examples/extensions/web-search/skill/references/hotels.md +148 -0
  87. package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
  88. package/examples/extensions/yahoo-mail/cli/package.json +13 -0
  89. package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
  90. package/examples/extensions/yahoo-mail/index.ts +57 -0
  91. package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
  92. package/package.json +106 -0
  93. package/resources/agents/explore.md +50 -0
  94. package/resources/agents/worker.md +24 -0
  95. package/resources/builtin-extensions.txt +3 -0
  96. package/resources/connection-env-vars.json +25 -0
  97. package/resources/extensions/.gitkeep +0 -0
  98. package/resources/pi-extensions/subagent/agents.ts +126 -0
  99. package/resources/pi-extensions/subagent/index.ts +964 -0
  100. package/resources/profiles/coding/AGENTS.md +43 -0
  101. package/resources/profiles/coding/mercury-profile.yaml +15 -0
  102. package/resources/profiles/general/AGENTS.md +31 -0
  103. package/resources/profiles/general/mercury-profile.yaml +15 -0
  104. package/resources/profiles/research/AGENTS.md +40 -0
  105. package/resources/profiles/research/mercury-profile.yaml +15 -0
  106. package/resources/skills/config/SKILL.md +25 -0
  107. package/resources/skills/context/SKILL.md +33 -0
  108. package/resources/skills/conversation-recap/SKILL.md +19 -0
  109. package/resources/skills/media/SKILL.md +27 -0
  110. package/resources/skills/mutes/SKILL.md +31 -0
  111. package/resources/skills/permissions/SKILL.md +19 -0
  112. package/resources/skills/preferences/SKILL.md +31 -0
  113. package/resources/skills/recall/SKILL.md +24 -0
  114. package/resources/skills/roles/SKILL.md +18 -0
  115. package/resources/skills/spaces/SKILL.md +18 -0
  116. package/resources/skills/tasks/SKILL.md +45 -0
  117. package/resources/templates/AGENTS.md +157 -0
  118. package/resources/templates/env.template +34 -0
  119. package/resources/templates/mercury.example.yaml +75 -0
  120. package/src/adapters/discord-native.ts +534 -0
  121. package/src/adapters/discord.ts +38 -0
  122. package/src/adapters/setup.ts +89 -0
  123. package/src/adapters/slack.ts +9 -0
  124. package/src/adapters/whatsapp-media.ts +337 -0
  125. package/src/adapters/whatsapp.ts +629 -0
  126. package/src/agent/api-socket.ts +127 -0
  127. package/src/agent/container-entry.ts +967 -0
  128. package/src/agent/container-error.ts +49 -0
  129. package/src/agent/container-runner.ts +1272 -0
  130. package/src/agent/model-capabilities-core.ts +23 -0
  131. package/src/agent/model-capabilities.ts +231 -0
  132. package/src/agent/pi-failure-class.ts +83 -0
  133. package/src/agent/pi-jsonl-parser.ts +306 -0
  134. package/src/agent/preferences-prompt.ts +20 -0
  135. package/src/agent/user-error-messages.ts +78 -0
  136. package/src/bridges/discord.ts +171 -0
  137. package/src/bridges/slack.ts +177 -0
  138. package/src/bridges/teams.ts +160 -0
  139. package/src/bridges/telegram.ts +571 -0
  140. package/src/bridges/whatsapp.ts +290 -0
  141. package/src/chat-shim.ts +259 -0
  142. package/src/cli/mercury.ts +2508 -0
  143. package/src/cli/mrctl-http.ts +27 -0
  144. package/src/cli/mrctl.ts +611 -0
  145. package/src/cli/whatsapp-auth.ts +260 -0
  146. package/src/config-file.ts +397 -0
  147. package/src/config-model-chain.ts +30 -0
  148. package/src/config.ts +316 -0
  149. package/src/core/api-types.ts +58 -0
  150. package/src/core/api.ts +105 -0
  151. package/src/core/commands.ts +76 -0
  152. package/src/core/conversation.ts +47 -0
  153. package/src/core/handler.ts +206 -0
  154. package/src/core/media.ts +200 -0
  155. package/src/core/mute-duration.ts +22 -0
  156. package/src/core/outbox.ts +76 -0
  157. package/src/core/permissions.ts +192 -0
  158. package/src/core/profiles.ts +245 -0
  159. package/src/core/rate-limiter.ts +127 -0
  160. package/src/core/router.ts +191 -0
  161. package/src/core/routes/chat.ts +172 -0
  162. package/src/core/routes/config-builtin.ts +107 -0
  163. package/src/core/routes/config.ts +81 -0
  164. package/src/core/routes/connections.ts +190 -0
  165. package/src/core/routes/console.ts +668 -0
  166. package/src/core/routes/control.ts +46 -0
  167. package/src/core/routes/conversations.ts +66 -0
  168. package/src/core/routes/dashboard.ts +2491 -0
  169. package/src/core/routes/extensions.ts +37 -0
  170. package/src/core/routes/index.ts +14 -0
  171. package/src/core/routes/media.ts +72 -0
  172. package/src/core/routes/messages.ts +37 -0
  173. package/src/core/routes/mutes.ts +89 -0
  174. package/src/core/routes/prefs.ts +95 -0
  175. package/src/core/routes/roles.ts +125 -0
  176. package/src/core/routes/spaces.ts +60 -0
  177. package/src/core/routes/storage.ts +126 -0
  178. package/src/core/routes/tasks.ts +189 -0
  179. package/src/core/routes/tradestation.ts +268 -0
  180. package/src/core/routes/tts.ts +51 -0
  181. package/src/core/runtime.ts +1140 -0
  182. package/src/core/space-queue.ts +103 -0
  183. package/src/core/storage-cleanup.ts +140 -0
  184. package/src/core/storage-guard.ts +24 -0
  185. package/src/core/task-scheduler.ts +132 -0
  186. package/src/core/telegram-format.ts +178 -0
  187. package/src/core/trigger.ts +142 -0
  188. package/src/dashboard/index.html +729 -0
  189. package/src/dashboard/tokens.css +53 -0
  190. package/src/extensions/api.ts +252 -0
  191. package/src/extensions/catalog.ts +117 -0
  192. package/src/extensions/config-registry.ts +83 -0
  193. package/src/extensions/context.ts +36 -0
  194. package/src/extensions/hooks.ts +156 -0
  195. package/src/extensions/image-builder.ts +617 -0
  196. package/src/extensions/installer.ts +306 -0
  197. package/src/extensions/jobs.ts +122 -0
  198. package/src/extensions/loader.ts +271 -0
  199. package/src/extensions/permission-guard.ts +52 -0
  200. package/src/extensions/reserved.ts +28 -0
  201. package/src/extensions/skills.ts +123 -0
  202. package/src/extensions/types.ts +462 -0
  203. package/src/logger.ts +174 -0
  204. package/src/main.ts +586 -0
  205. package/src/server.ts +391 -0
  206. package/src/storage/db.ts +1624 -0
  207. package/src/storage/memory.ts +45 -0
  208. package/src/storage/pi-auth.ts +95 -0
  209. package/src/text/markdown.ts +117 -0
  210. package/src/text/rtl.ts +38 -0
  211. package/src/tradestation/host-api.ts +77 -0
  212. package/src/tradestation/pending-orders.ts +69 -0
  213. package/src/tts/azure.ts +52 -0
  214. package/src/tts/google.ts +128 -0
  215. package/src/tts/index.ts +8 -0
  216. package/src/tts/language.ts +20 -0
  217. package/src/tts/synthesize.ts +133 -0
  218. package/src/types.ts +295 -0
@@ -0,0 +1,206 @@
1
+ import type { Adapter, Message } from "chat";
2
+ import { telegramInboundLooksLikeMedia } from "../bridges/telegram.js";
3
+ import type { AppConfig } from "../config.js";
4
+ import { logger } from "../logger.js";
5
+ import type { NormalizeContext, PlatformBridge } from "../types.js";
6
+ import { inferConversationKind, resolveConversation } from "./conversation.js";
7
+ import type { MercuryCoreRuntime } from "./runtime.js";
8
+ import { loadTriggerConfig, matchTrigger } from "./trigger.js";
9
+
10
+ export interface MessageHandlerOptions {
11
+ bridge: PlatformBridge;
12
+ core: MercuryCoreRuntime;
13
+ config: AppConfig;
14
+ ctx: NormalizeContext;
15
+ }
16
+
17
+ export function createMessageHandler(opts: MessageHandlerOptions) {
18
+ const { bridge, core, config, ctx } = opts;
19
+ const defaultPatterns = config.triggerPatterns
20
+ .split(",")
21
+ .map((s: string) => s.trim())
22
+ .filter(Boolean);
23
+
24
+ return async (
25
+ adapter: Adapter,
26
+ threadId: string,
27
+ message: Message,
28
+ ): Promise<void> => {
29
+ try {
30
+ logger.debug("Incoming message", {
31
+ adapter: adapter.name,
32
+ threadId,
33
+ textPreview: String(message.text ?? "").slice(0, 80),
34
+ isMe: message.author.isMe,
35
+ });
36
+
37
+ if (message.author.isMe) return;
38
+
39
+ const text = message.text.trim();
40
+ const looksLikeMedia =
41
+ bridge.platform === "telegram"
42
+ ? telegramInboundLooksLikeMedia(message)
43
+ : (message.attachments?.length ?? 0) > 0;
44
+ if (!text && !looksLikeMedia) {
45
+ return;
46
+ }
47
+
48
+ const { externalId, isDM } = bridge.parseThread(threadId);
49
+ const kind = inferConversationKind(bridge.platform, externalId, isDM);
50
+ const resolution = resolveConversation(
51
+ core.db,
52
+ bridge.platform,
53
+ externalId,
54
+ kind,
55
+ );
56
+
57
+ if (!resolution) {
58
+ logger.debug("Message ignored: conversation not linked to a space", {
59
+ platform: bridge.platform,
60
+ externalId,
61
+ kind,
62
+ });
63
+ return;
64
+ }
65
+
66
+ const { spaceId } = resolution;
67
+
68
+ const triggerConfig = loadTriggerConfig(core.db, spaceId, {
69
+ patterns: defaultPatterns,
70
+ match: config.triggerMatch,
71
+ });
72
+ const hasAttachments = looksLikeMedia;
73
+ const triggerResult = matchTrigger(
74
+ text,
75
+ triggerConfig,
76
+ isDM,
77
+ hasAttachments,
78
+ );
79
+
80
+ if (triggerResult.matched) {
81
+ try {
82
+ await adapter.startTyping(threadId);
83
+ } catch {
84
+ // Best-effort typing indicator
85
+ }
86
+ }
87
+
88
+ const ingress = await bridge.normalize(threadId, message, ctx, spaceId);
89
+ if (!ingress) return;
90
+
91
+ logger.info(
92
+ `Message from: ${ingress.callerId}${ingress.authorName ? ` (${ingress.authorName})` : ""}`,
93
+ );
94
+
95
+ if (ingress.isReplyToBot && !isDM && !triggerResult.matched) {
96
+ try {
97
+ await adapter.startTyping(threadId);
98
+ } catch {
99
+ // Best-effort typing indicator
100
+ }
101
+ }
102
+
103
+ const startTime = Date.now();
104
+ let lastStatusMessageId: string | undefined;
105
+ let hadStatusMessage = false;
106
+
107
+ const heartbeat = setInterval(() => {
108
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
109
+ const statusText = `⏳ Processing… (${elapsed}s)`;
110
+ const currentId = lastStatusMessageId;
111
+
112
+ if (currentId && bridge.editMessage) {
113
+ bridge
114
+ .editMessage(threadId, currentId, statusText)
115
+ .then((ok) => {
116
+ if (!ok) {
117
+ // Edit failed — fall back to delete+send
118
+ bridge
119
+ .sendReply(threadId, statusText)
120
+ .then(async (id) => {
121
+ await bridge
122
+ .deleteMessages?.(threadId, [currentId])
123
+ .catch(() => {});
124
+ if (id) lastStatusMessageId = id;
125
+ })
126
+ .catch(() => {});
127
+ }
128
+ })
129
+ .catch(() => {});
130
+ } else {
131
+ const prevId = lastStatusMessageId;
132
+ bridge
133
+ .sendReply(threadId, statusText)
134
+ .then(async (id) => {
135
+ if (prevId) {
136
+ await bridge
137
+ .deleteMessages?.(threadId, [prevId])
138
+ .catch(() => {});
139
+ }
140
+ if (id) {
141
+ lastStatusMessageId = id;
142
+ hadStatusMessage = true;
143
+ }
144
+ })
145
+ .catch(() => {});
146
+ }
147
+ }, 30_000);
148
+
149
+ let result: Awaited<ReturnType<typeof core.handleRawInput>>;
150
+ try {
151
+ result = await core.handleRawInput(ingress, "chat-sdk");
152
+ } finally {
153
+ clearInterval(heartbeat);
154
+ }
155
+
156
+ if (lastStatusMessageId) {
157
+ await bridge
158
+ .deleteMessages?.(threadId, [lastStatusMessageId])
159
+ .catch(() => {});
160
+ }
161
+
162
+ if (result.type === "ignore") return;
163
+
164
+ if (result.type === "denied") {
165
+ await bridge.sendReply(threadId, result.reason);
166
+ return;
167
+ }
168
+
169
+ if (result.result) {
170
+ const { reply, files, assistantMessageId } = result.result;
171
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
172
+ const finalReply =
173
+ hadStatusMessage && reply
174
+ ? `${reply}\n\n_(responded in ${elapsed}s)_`
175
+ : reply;
176
+ if (finalReply || files.length > 0) {
177
+ const sentPlatformId = await bridge.sendReply(
178
+ threadId,
179
+ finalReply,
180
+ files.length > 0 ? files : undefined,
181
+ );
182
+
183
+ // Record the platform message ID mapping for the bot's outbound message
184
+ if (
185
+ sentPlatformId &&
186
+ assistantMessageId &&
187
+ ingress.conversationExternalId
188
+ ) {
189
+ core.recordOutboundPlatformId(
190
+ assistantMessageId,
191
+ bridge.platform,
192
+ ingress.conversationExternalId,
193
+ sentPlatformId,
194
+ );
195
+ }
196
+ }
197
+ }
198
+ } catch (err) {
199
+ logger.error("Message handler error", {
200
+ platform: bridge.platform,
201
+ threadId,
202
+ error: err instanceof Error ? err.message : String(err),
203
+ });
204
+ }
205
+ };
206
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Shared media utilities for ingress/egress pipeline.
3
+ *
4
+ * - MIME detection (filename → MIME, MIME → extension, MIME → MediaType)
5
+ * - Generic URL-based media downloader (for Discord, Slack attachments)
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { logger } from "../logger.js";
11
+ import type { MediaType, MessageAttachment } from "../types.js";
12
+
13
+ // ─── MIME Maps ──────────────────────────────────────────────────────────
14
+
15
+ /** Extension → MIME type */
16
+ const EXT_TO_MIME: Record<string, string> = {
17
+ // Images
18
+ jpg: "image/jpeg",
19
+ jpeg: "image/jpeg",
20
+ png: "image/png",
21
+ gif: "image/gif",
22
+ webp: "image/webp",
23
+ svg: "image/svg+xml",
24
+ // Audio
25
+ ogg: "audio/ogg",
26
+ mp3: "audio/mpeg",
27
+ m4a: "audio/mp4",
28
+ aac: "audio/aac",
29
+ wav: "audio/wav",
30
+ // Video
31
+ mp4: "video/mp4",
32
+ "3gp": "video/3gpp",
33
+ webm: "video/webm",
34
+ // Documents
35
+ pdf: "application/pdf",
36
+ txt: "text/plain",
37
+ csv: "text/csv",
38
+ json: "application/json",
39
+ html: "text/html",
40
+ md: "text/markdown",
41
+ doc: "application/msword",
42
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
43
+ xls: "application/vnd.ms-excel",
44
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
45
+ };
46
+
47
+ /** MIME type → extension */
48
+ const MIME_TO_EXT: Record<string, string> = {
49
+ "image/jpeg": "jpg",
50
+ "image/png": "png",
51
+ "image/gif": "gif",
52
+ "image/webp": "webp",
53
+ "image/svg+xml": "svg",
54
+ "audio/ogg": "ogg",
55
+ "audio/ogg; codecs=opus": "ogg",
56
+ "audio/mpeg": "mp3",
57
+ "audio/mp4": "m4a",
58
+ "audio/aac": "aac",
59
+ "audio/wav": "wav",
60
+ "video/mp4": "mp4",
61
+ "video/3gpp": "3gp",
62
+ "video/webm": "webm",
63
+ "application/pdf": "pdf",
64
+ "text/plain": "txt",
65
+ "text/csv": "csv",
66
+ "application/json": "json",
67
+ "text/html": "html",
68
+ "text/markdown": "md",
69
+ "application/msword": "doc",
70
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
71
+ "docx",
72
+ "application/vnd.ms-excel": "xls",
73
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
74
+ };
75
+
76
+ // ─── MIME Utilities ─────────────────────────────────────────────────────
77
+
78
+ /** Detect MIME type from filename extension. */
79
+ export function extToMime(filename: string): string {
80
+ const ext = filename.split(".").pop()?.toLowerCase() ?? "";
81
+ return EXT_TO_MIME[ext] ?? "application/octet-stream";
82
+ }
83
+
84
+ /** Get file extension from MIME type. Handles MIME params (e.g., "audio/ogg; codecs=opus"). */
85
+ export function mimeToExt(mimeType: string): string {
86
+ const baseMime = mimeType.split(";")[0].trim();
87
+ return MIME_TO_EXT[baseMime] ?? MIME_TO_EXT[mimeType] ?? "bin";
88
+ }
89
+
90
+ /** Classify MIME type into MediaType. */
91
+ export function mimeToMediaType(mimeType: string): MediaType {
92
+ const base = mimeType.split(";")[0].trim();
93
+ if (base.startsWith("image/")) return "image";
94
+ if (base.startsWith("video/")) return "video";
95
+ if (base.startsWith("audio/")) return "audio";
96
+ return "document";
97
+ }
98
+
99
+ // ─── URL-based Media Downloader ─────────────────────────────────────────
100
+
101
+ /**
102
+ * Download a file from a URL to a local directory.
103
+ *
104
+ * Used by Discord and Slack bridges to fetch attachments to workspace inbox/.
105
+ * Returns a MessageAttachment on success, null if skipped or failed.
106
+ */
107
+ export async function downloadMediaFromUrl(
108
+ url: string,
109
+ options: {
110
+ type: MediaType;
111
+ mimeType: string;
112
+ filename?: string;
113
+ expectedSizeBytes?: number;
114
+ maxSizeBytes: number;
115
+ outputDir: string;
116
+ headers?: Record<string, string>;
117
+ },
118
+ ): Promise<MessageAttachment | null> {
119
+ const { maxSizeBytes, outputDir, headers } = options;
120
+
121
+ // Check expected size before downloading
122
+ if (options.expectedSizeBytes && options.expectedSizeBytes > maxSizeBytes) {
123
+ logger.warn("Skipping large media file", {
124
+ url: url.slice(0, 100),
125
+ type: options.type,
126
+ sizeBytes: options.expectedSizeBytes,
127
+ maxBytes: maxSizeBytes,
128
+ });
129
+ return null;
130
+ }
131
+
132
+ try {
133
+ const response = await fetch(url, {
134
+ headers: headers ?? {},
135
+ });
136
+
137
+ if (!response.ok) {
138
+ logger.error("Media download failed", {
139
+ url: url.slice(0, 100),
140
+ status: response.status,
141
+ });
142
+ return null;
143
+ }
144
+
145
+ // Check Content-Length header before buffering
146
+ const contentLength = response.headers.get("content-length");
147
+ if (contentLength && Number.parseInt(contentLength, 10) > maxSizeBytes) {
148
+ logger.warn("Media download exceeds size limit", {
149
+ url: url.slice(0, 100),
150
+ sizeBytes: Number.parseInt(contentLength, 10),
151
+ maxBytes: maxSizeBytes,
152
+ });
153
+ return null;
154
+ }
155
+
156
+ const buffer = Buffer.from(await response.arrayBuffer());
157
+
158
+ // Check actual size after download
159
+ if (buffer.length > maxSizeBytes) {
160
+ logger.warn("Downloaded media exceeds size limit, discarding", {
161
+ sizeBytes: buffer.length,
162
+ maxBytes: maxSizeBytes,
163
+ });
164
+ return null;
165
+ }
166
+
167
+ // Ensure output directory exists
168
+ fs.mkdirSync(outputDir, { recursive: true });
169
+
170
+ // Generate filename: {timestamp}-{original} or {timestamp}-{type}.{ext}
171
+ const ext = mimeToExt(options.mimeType);
172
+ const filename = options.filename
173
+ ? `${Date.now()}-${options.filename}`
174
+ : `${Date.now()}-${options.type}.${ext}`;
175
+
176
+ const filePath = path.join(outputDir, filename);
177
+ fs.writeFileSync(filePath, buffer);
178
+
179
+ logger.info("Downloaded media", {
180
+ type: options.type,
181
+ mimeType: options.mimeType,
182
+ sizeBytes: buffer.length,
183
+ path: filePath,
184
+ });
185
+
186
+ return {
187
+ path: filePath,
188
+ type: options.type,
189
+ mimeType: options.mimeType,
190
+ filename: options.filename,
191
+ sizeBytes: buffer.length,
192
+ };
193
+ } catch (error) {
194
+ logger.error("Failed to download media", {
195
+ url: url.slice(0, 100),
196
+ error: error instanceof Error ? error.message : String(error),
197
+ });
198
+ return null;
199
+ }
200
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Parse a duration string like "10m", "1h", "24h", "7d" into milliseconds.
3
+ */
4
+ export function parseMuteDuration(input: string): number | null {
5
+ const match = input.match(/^(\d+)\s*(m|min|h|hr|d|day)s?$/i);
6
+ if (!match) return null;
7
+ const value = Number.parseInt(match[1], 10);
8
+ const unit = match[2].toLowerCase();
9
+ switch (unit) {
10
+ case "m":
11
+ case "min":
12
+ return value * 60 * 1000;
13
+ case "h":
14
+ case "hr":
15
+ return value * 60 * 60 * 1000;
16
+ case "d":
17
+ case "day":
18
+ return value * 24 * 60 * 60 * 1000;
19
+ default:
20
+ return null;
21
+ }
22
+ }
@@ -0,0 +1,76 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { logger } from "../logger.js";
4
+ import type { EgressFile } from "../types.js";
5
+ import { extToMime } from "./media.js";
6
+
7
+ /** Default max file size for outbox files (25 MB) */
8
+ const DEFAULT_MAX_FILE_SIZE = 25 * 1024 * 1024;
9
+
10
+ /**
11
+ * Scan outbox/ for files created or modified during a container run.
12
+ *
13
+ * Files with mtime >= startTimeMs are considered new or modified.
14
+ * Skips dotfiles, directories, and files exceeding maxSizeBytes.
15
+ * Non-recursive (one level only).
16
+ */
17
+ export function scanOutbox(
18
+ workspacePath: string,
19
+ startTimeMs: number,
20
+ maxSizeBytes = DEFAULT_MAX_FILE_SIZE,
21
+ ): EgressFile[] {
22
+ const outboxDir = path.join(workspacePath, "outbox");
23
+
24
+ if (!fs.existsSync(outboxDir)) return [];
25
+
26
+ const files: EgressFile[] = [];
27
+
28
+ let entries: fs.Dirent[];
29
+ try {
30
+ entries = fs.readdirSync(outboxDir, { withFileTypes: true });
31
+ } catch (error) {
32
+ logger.warn("Failed to read outbox directory", {
33
+ outboxDir,
34
+ error: error instanceof Error ? error.message : String(error),
35
+ });
36
+ return [];
37
+ }
38
+
39
+ for (const entry of entries) {
40
+ if (!entry.isFile()) continue;
41
+ if (entry.name.startsWith(".")) continue;
42
+
43
+ const filePath = path.join(outboxDir, entry.name);
44
+
45
+ let stat: fs.Stats;
46
+ try {
47
+ stat = fs.statSync(filePath);
48
+ } catch (error) {
49
+ logger.warn("Failed to stat outbox file, skipping", {
50
+ path: filePath,
51
+ error: error instanceof Error ? error.message : String(error),
52
+ });
53
+ continue;
54
+ }
55
+
56
+ if (stat.mtimeMs < startTimeMs) continue;
57
+
58
+ if (stat.size > maxSizeBytes) {
59
+ logger.warn("Outbox file exceeds max size, skipping", {
60
+ path: filePath,
61
+ sizeBytes: stat.size,
62
+ maxSizeBytes,
63
+ });
64
+ continue;
65
+ }
66
+
67
+ files.push({
68
+ path: filePath,
69
+ filename: entry.name,
70
+ mimeType: extToMime(entry.name),
71
+ sizeBytes: stat.size,
72
+ });
73
+ }
74
+
75
+ return files;
76
+ }
@@ -0,0 +1,192 @@
1
+ import type { Db } from "../storage/db.js";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Built-in permissions (static, cannot be overridden)
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const BUILT_IN_PERMISSIONS = new Set([
8
+ "prompt",
9
+ "stop",
10
+ "compact",
11
+ "clear",
12
+ "tasks.list",
13
+ "tasks.create",
14
+ "tasks.pause",
15
+ "tasks.resume",
16
+ "tasks.delete",
17
+ "config.get",
18
+ "config.set",
19
+ "prefs.get",
20
+ "prefs.set",
21
+ "roles.list",
22
+ "roles.grant",
23
+ "roles.revoke",
24
+ "permissions.get",
25
+ "permissions.set",
26
+ "spaces.list",
27
+ "spaces.rename",
28
+ "spaces.delete",
29
+ /** Purge inbox/outbox media files. */
30
+ "media.purge",
31
+ /** Host Text-to-Speech (/api/tts); admin-only by default. */
32
+ "tts.synthesize",
33
+ ]);
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Extension-registered permissions (dynamic, added at runtime)
37
+ // ---------------------------------------------------------------------------
38
+
39
+ const registeredPermissions = new Map<string, { defaultRoles: string[] }>();
40
+
41
+ /**
42
+ * Register a new permission from an extension.
43
+ * Throws if the name collides with a built-in permission.
44
+ */
45
+ export function registerPermission(
46
+ name: string,
47
+ opts: { defaultRoles: string[] },
48
+ ): void {
49
+ if (BUILT_IN_PERMISSIONS.has(name)) {
50
+ throw new Error(
51
+ `Permission "${name}" is a built-in and cannot be overridden`,
52
+ );
53
+ }
54
+ registeredPermissions.set(name, opts);
55
+ }
56
+
57
+ /**
58
+ * Get all valid permission names (built-in + extension-registered).
59
+ */
60
+ export function getAllPermissions(): string[] {
61
+ return [...BUILT_IN_PERMISSIONS, ...registeredPermissions.keys()];
62
+ }
63
+
64
+ /**
65
+ * Check if a permission name is valid (built-in or registered).
66
+ */
67
+ export function isValidPermission(name: string): boolean {
68
+ return BUILT_IN_PERMISSIONS.has(name) || registeredPermissions.has(name);
69
+ }
70
+
71
+ /**
72
+ * Clear all registered extension permissions. For test isolation only.
73
+ */
74
+ export function resetPermissions(): void {
75
+ registeredPermissions.clear();
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Seeded groups tracking
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Tracks which groups have had admins seeded to avoid redundant DB calls.
84
+ * Exported for test isolation (tests should clear this in beforeEach).
85
+ */
86
+ export const seededSpaces = new Set<string>();
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // System callers
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * System callers — these identities get full permissions without DB lookup.
94
+ * Used for scheduled tasks, internal system calls, etc.
95
+ */
96
+ const SYSTEM_CALLERS = new Set(["system"]);
97
+
98
+ export function isSystemCaller(callerId: string): boolean {
99
+ return SYSTEM_CALLERS.has(callerId);
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Default role permissions
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /** Built-in defaults for the member role */
107
+ const DEFAULT_MEMBER_PERMISSIONS = new Set(["prompt", "prefs.get"]);
108
+
109
+ /**
110
+ * Compute the default permission set for a role, merging built-in defaults
111
+ * with extension-registered defaults.
112
+ *
113
+ * - `admin` and `system` get all permissions (built-in + extension)
114
+ * - `member` gets `prompt`, `prefs.get`, plus any extension permissions that list "member" in defaultRoles
115
+ * - Other roles get extension permissions that list them in defaultRoles
116
+ */
117
+ function getDefaultPermissions(role: string): Set<string> {
118
+ if (role === "admin" || role === "system") {
119
+ return new Set(getAllPermissions());
120
+ }
121
+
122
+ const perms = new Set<string>(
123
+ role === "member" ? DEFAULT_MEMBER_PERMISSIONS : [],
124
+ );
125
+
126
+ for (const [name, opts] of registeredPermissions) {
127
+ if (opts.defaultRoles.includes(role)) {
128
+ perms.add(name);
129
+ }
130
+ }
131
+
132
+ return perms;
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Permission resolution
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /**
140
+ * Load the permission set for a role in a group.
141
+ * Checks group_config for "role.<name>.permissions" override,
142
+ * falls back to defaults (built-in + extension).
143
+ */
144
+ export function getRolePermissions(
145
+ db: Db,
146
+ spaceId: string,
147
+ role: string,
148
+ ): Set<string> {
149
+ if (role === "system") return getDefaultPermissions("system");
150
+
151
+ const key = `role.${role}.permissions`;
152
+ const stored = db.getSpaceConfig(spaceId, key);
153
+
154
+ if (stored !== null) {
155
+ const perms = stored
156
+ .split(",")
157
+ .map((s) => s.trim())
158
+ .filter((s) => isValidPermission(s));
159
+ return new Set(perms);
160
+ }
161
+
162
+ return getDefaultPermissions(role);
163
+ }
164
+
165
+ export function hasPermission(
166
+ db: Db,
167
+ spaceId: string,
168
+ role: string,
169
+ permission: string,
170
+ ): boolean {
171
+ return getRolePermissions(db, spaceId, role).has(permission);
172
+ }
173
+
174
+ export function resolveRole(
175
+ db: Db,
176
+ spaceId: string,
177
+ platformUserId: string,
178
+ seededAdmins: string[],
179
+ displayName?: string | null,
180
+ ): string {
181
+ // System callers bypass DB entirely
182
+ if (isSystemCaller(platformUserId)) return "system";
183
+
184
+ if (seededAdmins.length > 0 && !seededSpaces.has(spaceId)) {
185
+ db.seedAdmins(spaceId, seededAdmins);
186
+ seededSpaces.add(spaceId);
187
+ }
188
+
189
+ db.upsertMember(spaceId, platformUserId, displayName);
190
+
191
+ return db.getRole(spaceId, platformUserId) ?? "member";
192
+ }