mercury-agent 0.4.23 → 0.4.24

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 (59) hide show
  1. package/docs/ARCHITECTURE.md +30 -0
  2. package/docs/DESIGN.md +42 -0
  3. package/docs/ROADMAP.md +41 -0
  4. package/docs/VISION.md +32 -0
  5. package/docs/archive/.gitkeep +0 -0
  6. package/docs/backlog/.gitkeep +0 -0
  7. package/docs/bugs/.gitkeep +0 -0
  8. package/docs/bugs/bwrap-privileged-linux-docker.md +140 -0
  9. package/docs/debug/major/.gitkeep +0 -0
  10. package/docs/debug/minor/.gitkeep +0 -0
  11. package/docs/debug/moderate/.gitkeep +0 -0
  12. package/docs/html-slides/.gitkeep +0 -0
  13. package/docs/ideas/.gitkeep +0 -0
  14. package/docs/in-progress/.gitkeep +0 -0
  15. package/docs/notes/.gitkeep +0 -0
  16. package/docs/pending-updates/.gitkeep +0 -0
  17. package/docs/runbooks/publish-checklist.md +8 -0
  18. package/docs/templates/TEMPLATE-AUTOPILOT-CONFIG.yaml +35 -0
  19. package/docs/templates/TEMPLATE-BUG.md +71 -0
  20. package/docs/templates/TEMPLATE-CI.yml +25 -0
  21. package/docs/templates/TEMPLATE-DECISIONS.md +11 -0
  22. package/docs/templates/TEMPLATE-FEATURE.md +127 -0
  23. package/docs/templates/TEMPLATE-GOAL.md +31 -0
  24. package/docs/templates/TEMPLATE-IDEA.md +31 -0
  25. package/docs/templates/TEMPLATE-NOTE.md +27 -0
  26. package/docs/templates/TEMPLATE-ROADMAP.md +21 -0
  27. package/examples/extensions/pinchtab/index.ts +1 -1
  28. package/package.json +1 -1
  29. package/src/adapters/whatsapp.ts +635 -635
  30. package/src/agent/container-entry.ts +1021 -1021
  31. package/src/agent/container-runner.ts +18 -12
  32. package/src/agent/model-capabilities.ts +231 -231
  33. package/src/bridges/discord.ts +178 -178
  34. package/src/bridges/slack.ts +179 -179
  35. package/src/bridges/teams.ts +162 -162
  36. package/src/bridges/telegram.ts +579 -579
  37. package/src/cli/mercury.ts +2587 -2585
  38. package/src/cli/whatsapp-auth.ts +263 -263
  39. package/src/config.ts +316 -316
  40. package/src/core/commands.ts +110 -110
  41. package/src/core/permissions.ts +196 -196
  42. package/src/core/router.ts +204 -204
  43. package/src/core/routes/chat.ts +175 -175
  44. package/src/core/routes/dashboard.ts +2493 -2493
  45. package/src/core/routes/messages.ts +37 -37
  46. package/src/core/routes/mutes.ts +95 -95
  47. package/src/core/routes/roles.ts +135 -135
  48. package/src/core/runtime.ts +1508 -1508
  49. package/src/core/task-scheduler.ts +139 -139
  50. package/src/extensions/catalog.ts +117 -117
  51. package/src/extensions/hooks.ts +161 -161
  52. package/src/extensions/installer.ts +306 -306
  53. package/src/extensions/loader.ts +271 -271
  54. package/src/extensions/permission-guard.ts +52 -52
  55. package/src/server.ts +391 -391
  56. package/src/storage/db.ts +1684 -1687
  57. package/src/storage/pi-auth.ts +95 -95
  58. package/src/tts/azure.ts +52 -52
  59. package/src/tts/synthesize.ts +133 -133
@@ -1,179 +1,179 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import type { Adapter, Message } from "chat";
4
- import { downloadMediaFromUrl, mimeToMediaType } from "../core/media.js";
5
- import { logger } from "../logger.js";
6
- import type {
7
- EgressFile,
8
- IngressMessage,
9
- MessageAttachment,
10
- NormalizeContext,
11
- PlatformBridge,
12
- } from "../types.js";
13
-
14
- export class SlackBridge implements PlatformBridge {
15
- readonly platform = "slack";
16
-
17
- constructor(
18
- private readonly adapter: Adapter,
19
- private readonly botToken: string,
20
- ) {}
21
-
22
- parseThread(threadId: string): { externalId: string; isDM: boolean } {
23
- const parts = threadId.split(":");
24
- const externalId = parts.slice(1).join(":");
25
- const ch = parts[1] || "";
26
- const isDM = ch.startsWith("D") || ch.startsWith("G");
27
- return { externalId, isDM };
28
- }
29
-
30
- async normalize(
31
- threadId: string,
32
- message: unknown,
33
- ctx: NormalizeContext,
34
- spaceId: string,
35
- ): Promise<IngressMessage | null> {
36
- const msg = message as Message;
37
- if (msg.author.isMe) return null;
38
-
39
- const text = msg.text.trim();
40
- const rawAttachments = msg.attachments ?? [];
41
- if (!text && rawAttachments.length === 0) return null;
42
-
43
- const attachments: MessageAttachment[] = [];
44
- if (ctx.media.enabled && rawAttachments.length > 0) {
45
- if (await ctx.isOverQuota()) {
46
- logger.warn("Skipping media download — storage quota exceeded", {
47
- spaceId,
48
- });
49
- } else {
50
- const workspace = ctx.getWorkspace(spaceId);
51
- const inboxDir = path.join(workspace, "inbox");
52
- for (const att of rawAttachments) {
53
- const url = att.url || (att as { url_private?: string }).url_private;
54
- if (!url) continue;
55
- const type = mimeToMediaType(
56
- att.mimeType || "application/octet-stream",
57
- );
58
- const result = await downloadMediaFromUrl(url, {
59
- type,
60
- mimeType: att.mimeType || "application/octet-stream",
61
- filename: att.name,
62
- expectedSizeBytes: att.size,
63
- maxSizeBytes: ctx.media.maxSizeBytes,
64
- outputDir: inboxDir,
65
- headers: { Authorization: `Bearer ${this.botToken}` },
66
- });
67
- if (result) attachments.push(result);
68
- }
69
- }
70
- }
71
-
72
- const { externalId, isDM } = this.parseThread(threadId);
73
-
74
- // Extract Slack-specific fields from raw event for reply chain tracking
75
- const raw = msg.raw as
76
- | {
77
- ts?: string;
78
- thread_ts?: string;
79
- }
80
- | undefined;
81
- const slackTs = raw?.ts;
82
- const slackThreadTs = raw?.thread_ts;
83
- // In Slack, a threaded reply has thread_ts pointing to the parent message.
84
- // isReplyToBot: we can't determine this without knowing the parent author;
85
- // will be resolved via platform ID lookup in the runtime.
86
- const isReplyToBot =
87
- (msg.metadata as { isReplyToBot?: boolean })?.isReplyToBot ?? false;
88
-
89
- return {
90
- platform: "slack",
91
- spaceId,
92
- conversationExternalId: externalId,
93
- callerId: `slack:${msg.author.userId || "unknown"}`,
94
- authorName: msg.author.userName,
95
- text,
96
- isDM,
97
- isReplyToBot,
98
- attachments,
99
- replyToPlatformMessageId:
100
- slackThreadTs && slackThreadTs !== slackTs ? slackThreadTs : undefined,
101
- platformMessageId: slackTs,
102
- };
103
- }
104
-
105
- async sendReply(
106
- threadId: string,
107
- text: string,
108
- files?: EgressFile[],
109
- ): Promise<string | undefined> {
110
- let sentPlatformId: string | undefined;
111
- if (text) {
112
- const sent = await this.adapter.postMessage(threadId, text);
113
- sentPlatformId = sent.id;
114
- }
115
-
116
- if (files && files.length > 0) {
117
- await this.uploadFiles(threadId, files);
118
- }
119
-
120
- return sentPlatformId;
121
- }
122
-
123
- async deleteMessages(
124
- _threadId: string,
125
- _messageIds: string[],
126
- ): Promise<void> {
127
- // Slack delete not implemented — silent no-op for v1
128
- }
129
-
130
- private async uploadFiles(
131
- threadId: string,
132
- files: EgressFile[],
133
- ): Promise<void> {
134
- const parts = threadId.split(":");
135
- const channelId = parts.length >= 2 ? parts[1] : threadId;
136
- const threadTs = parts.length >= 3 ? parts[2] : undefined;
137
-
138
- for (const file of files) {
139
- try {
140
- const buffer = fs.readFileSync(file.path);
141
- const form = new FormData();
142
- form.append("channel_id", channelId);
143
- if (threadTs) form.append("thread_ts", threadTs);
144
- form.append("filename", file.filename);
145
- form.append(
146
- "file",
147
- new Blob([buffer], { type: file.mimeType }),
148
- file.filename,
149
- );
150
-
151
- const resp = await fetch("https://slack.com/api/files.uploadV2", {
152
- method: "POST",
153
- headers: { Authorization: `Bearer ${this.botToken}` },
154
- body: form,
155
- });
156
-
157
- if (!resp.ok) {
158
- logger.error("Slack file upload HTTP error", {
159
- filename: file.filename,
160
- status: resp.status,
161
- });
162
- } else {
163
- const body = (await resp.json()) as { ok?: boolean; error?: string };
164
- if (!body.ok) {
165
- logger.error("Slack file upload API error", {
166
- filename: file.filename,
167
- error: body.error,
168
- });
169
- }
170
- }
171
- } catch (err) {
172
- logger.error("Slack file upload failed", {
173
- filename: file.filename,
174
- error: err instanceof Error ? err.message : String(err),
175
- });
176
- }
177
- }
178
- }
179
- }
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { Adapter, Message } from "chat";
4
+ import { downloadMediaFromUrl, mimeToMediaType } from "../core/media.js";
5
+ import { logger } from "../logger.js";
6
+ import type {
7
+ EgressFile,
8
+ IngressMessage,
9
+ MessageAttachment,
10
+ NormalizeContext,
11
+ PlatformBridge,
12
+ } from "../types.js";
13
+
14
+ export class SlackBridge implements PlatformBridge {
15
+ readonly platform = "slack";
16
+
17
+ constructor(
18
+ private readonly adapter: Adapter,
19
+ private readonly botToken: string,
20
+ ) {}
21
+
22
+ parseThread(threadId: string): { externalId: string; isDM: boolean } {
23
+ const parts = threadId.split(":");
24
+ const externalId = parts.slice(1).join(":");
25
+ const ch = parts[1] || "";
26
+ const isDM = ch.startsWith("D") || ch.startsWith("G");
27
+ return { externalId, isDM };
28
+ }
29
+
30
+ async normalize(
31
+ threadId: string,
32
+ message: unknown,
33
+ ctx: NormalizeContext,
34
+ spaceId: string,
35
+ ): Promise<IngressMessage | null> {
36
+ const msg = message as Message;
37
+ if (msg.author.isMe) return null;
38
+
39
+ const text = msg.text.trim();
40
+ const rawAttachments = msg.attachments ?? [];
41
+ if (!text && rawAttachments.length === 0) return null;
42
+
43
+ const attachments: MessageAttachment[] = [];
44
+ if (ctx.media.enabled && rawAttachments.length > 0) {
45
+ if (await ctx.isOverQuota()) {
46
+ logger.warn("Skipping media download — storage quota exceeded", {
47
+ spaceId,
48
+ });
49
+ } else {
50
+ const workspace = ctx.getWorkspace(spaceId);
51
+ const inboxDir = path.join(workspace, "inbox");
52
+ for (const att of rawAttachments) {
53
+ const url = att.url || (att as { url_private?: string }).url_private;
54
+ if (!url) continue;
55
+ const type = mimeToMediaType(
56
+ att.mimeType || "application/octet-stream",
57
+ );
58
+ const result = await downloadMediaFromUrl(url, {
59
+ type,
60
+ mimeType: att.mimeType || "application/octet-stream",
61
+ filename: att.name,
62
+ expectedSizeBytes: att.size,
63
+ maxSizeBytes: ctx.media.maxSizeBytes,
64
+ outputDir: inboxDir,
65
+ headers: { Authorization: `Bearer ${this.botToken}` },
66
+ });
67
+ if (result) attachments.push(result);
68
+ }
69
+ }
70
+ }
71
+
72
+ const { externalId, isDM } = this.parseThread(threadId);
73
+
74
+ // Extract Slack-specific fields from raw event for reply chain tracking
75
+ const raw = msg.raw as
76
+ | {
77
+ ts?: string;
78
+ thread_ts?: string;
79
+ }
80
+ | undefined;
81
+ const slackTs = raw?.ts;
82
+ const slackThreadTs = raw?.thread_ts;
83
+ // In Slack, a threaded reply has thread_ts pointing to the parent message.
84
+ // isReplyToBot: we can't determine this without knowing the parent author;
85
+ // will be resolved via platform ID lookup in the runtime.
86
+ const isReplyToBot =
87
+ (msg.metadata as { isReplyToBot?: boolean })?.isReplyToBot ?? false;
88
+
89
+ return {
90
+ platform: "slack",
91
+ spaceId,
92
+ conversationExternalId: externalId,
93
+ callerId: `slack:${msg.author.userId || "unknown"}`,
94
+ authorName: msg.author.userName,
95
+ text,
96
+ isDM,
97
+ isReplyToBot,
98
+ attachments,
99
+ replyToPlatformMessageId:
100
+ slackThreadTs && slackThreadTs !== slackTs ? slackThreadTs : undefined,
101
+ platformMessageId: slackTs,
102
+ };
103
+ }
104
+
105
+ async sendReply(
106
+ threadId: string,
107
+ text: string,
108
+ files?: EgressFile[],
109
+ ): Promise<string | undefined> {
110
+ let sentPlatformId: string | undefined;
111
+ if (text) {
112
+ const sent = await this.adapter.postMessage(threadId, text);
113
+ sentPlatformId = sent.id;
114
+ }
115
+
116
+ if (files && files.length > 0) {
117
+ await this.uploadFiles(threadId, files);
118
+ }
119
+
120
+ return sentPlatformId;
121
+ }
122
+
123
+ async deleteMessages(
124
+ _threadId: string,
125
+ _messageIds: string[],
126
+ ): Promise<void> {
127
+ // Slack delete not implemented — silent no-op for v1
128
+ }
129
+
130
+ private async uploadFiles(
131
+ threadId: string,
132
+ files: EgressFile[],
133
+ ): Promise<void> {
134
+ const parts = threadId.split(":");
135
+ const channelId = parts.length >= 2 ? parts[1] : threadId;
136
+ const threadTs = parts.length >= 3 ? parts[2] : undefined;
137
+
138
+ for (const file of files) {
139
+ try {
140
+ const buffer = fs.readFileSync(file.path);
141
+ const form = new FormData();
142
+ form.append("channel_id", channelId);
143
+ if (threadTs) form.append("thread_ts", threadTs);
144
+ form.append("filename", file.filename);
145
+ form.append(
146
+ "file",
147
+ new Blob([buffer], { type: file.mimeType }),
148
+ file.filename,
149
+ );
150
+
151
+ const resp = await fetch("https://slack.com/api/files.uploadV2", {
152
+ method: "POST",
153
+ headers: { Authorization: `Bearer ${this.botToken}` },
154
+ body: form,
155
+ });
156
+
157
+ if (!resp.ok) {
158
+ logger.error("Slack file upload HTTP error", {
159
+ filename: file.filename,
160
+ status: resp.status,
161
+ });
162
+ } else {
163
+ const body = (await resp.json()) as { ok?: boolean; error?: string };
164
+ if (!body.ok) {
165
+ logger.error("Slack file upload API error", {
166
+ filename: file.filename,
167
+ error: body.error,
168
+ });
169
+ }
170
+ }
171
+ } catch (err) {
172
+ logger.error("Slack file upload failed", {
173
+ filename: file.filename,
174
+ error: err instanceof Error ? err.message : String(err),
175
+ });
176
+ }
177
+ }
178
+ }
179
+ }