@geminixiang/mama 0.2.0-beta.0 → 0.2.0-beta.10

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 (273) hide show
  1. package/README.md +171 -334
  2. package/dist/adapter.d.ts +36 -10
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapter.js.map +1 -1
  5. package/dist/adapters/discord/bot.d.ts +10 -5
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +349 -114
  8. package/dist/adapters/discord/bot.js.map +1 -1
  9. package/dist/adapters/discord/context.d.ts +1 -1
  10. package/dist/adapters/discord/context.d.ts.map +1 -1
  11. package/dist/adapters/discord/context.js +102 -31
  12. package/dist/adapters/discord/context.js.map +1 -1
  13. package/dist/adapters/shared.d.ts +71 -0
  14. package/dist/adapters/shared.d.ts.map +1 -0
  15. package/dist/adapters/shared.js +168 -0
  16. package/dist/adapters/shared.js.map +1 -0
  17. package/dist/adapters/slack/bot.d.ts +29 -22
  18. package/dist/adapters/slack/bot.d.ts.map +1 -1
  19. package/dist/adapters/slack/bot.js +620 -186
  20. package/dist/adapters/slack/bot.js.map +1 -1
  21. package/dist/adapters/slack/branch-manager.d.ts +22 -0
  22. package/dist/adapters/slack/branch-manager.d.ts.map +1 -0
  23. package/dist/adapters/slack/branch-manager.js +97 -0
  24. package/dist/adapters/slack/branch-manager.js.map +1 -0
  25. package/dist/adapters/slack/context.d.ts +1 -1
  26. package/dist/adapters/slack/context.d.ts.map +1 -1
  27. package/dist/adapters/slack/context.js +136 -71
  28. package/dist/adapters/slack/context.js.map +1 -1
  29. package/dist/adapters/slack/session.d.ts +3 -0
  30. package/dist/adapters/slack/session.d.ts.map +1 -0
  31. package/dist/adapters/slack/session.js +16 -0
  32. package/dist/adapters/slack/session.js.map +1 -0
  33. package/dist/adapters/slack/tools/attach.d.ts +1 -1
  34. package/dist/adapters/slack/tools/attach.d.ts.map +1 -1
  35. package/dist/adapters/slack/tools/attach.js.map +1 -1
  36. package/dist/adapters/telegram/bot.d.ts +2 -0
  37. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  38. package/dist/adapters/telegram/bot.js +190 -123
  39. package/dist/adapters/telegram/bot.js.map +1 -1
  40. package/dist/adapters/telegram/context.d.ts.map +1 -1
  41. package/dist/adapters/telegram/context.js +57 -59
  42. package/dist/adapters/telegram/context.js.map +1 -1
  43. package/dist/adapters/telegram/html.d.ts +3 -0
  44. package/dist/adapters/telegram/html.d.ts.map +1 -0
  45. package/dist/adapters/telegram/html.js +98 -0
  46. package/dist/adapters/telegram/html.js.map +1 -0
  47. package/dist/agent.d.ts +9 -10
  48. package/dist/agent.d.ts.map +1 -1
  49. package/dist/agent.js +645 -555
  50. package/dist/agent.js.map +1 -1
  51. package/dist/commands/auto-reply.d.ts +16 -0
  52. package/dist/commands/auto-reply.d.ts.map +1 -0
  53. package/dist/commands/auto-reply.js +69 -0
  54. package/dist/commands/auto-reply.js.map +1 -0
  55. package/dist/commands/index.d.ts +5 -0
  56. package/dist/commands/index.d.ts.map +1 -0
  57. package/dist/commands/index.js +19 -0
  58. package/dist/commands/index.js.map +1 -0
  59. package/dist/commands/login.d.ts +5 -0
  60. package/dist/commands/login.d.ts.map +1 -0
  61. package/dist/commands/login.js +76 -0
  62. package/dist/commands/login.js.map +1 -0
  63. package/dist/commands/model.d.ts +14 -0
  64. package/dist/commands/model.d.ts.map +1 -0
  65. package/dist/commands/model.js +112 -0
  66. package/dist/commands/model.js.map +1 -0
  67. package/dist/commands/new.d.ts +9 -0
  68. package/dist/commands/new.d.ts.map +1 -0
  69. package/dist/commands/new.js +28 -0
  70. package/dist/commands/new.js.map +1 -0
  71. package/dist/commands/registry.d.ts +7 -0
  72. package/dist/commands/registry.d.ts.map +1 -0
  73. package/dist/commands/registry.js +14 -0
  74. package/dist/commands/registry.js.map +1 -0
  75. package/dist/commands/sandbox.d.ts +10 -0
  76. package/dist/commands/sandbox.d.ts.map +1 -0
  77. package/dist/commands/sandbox.js +88 -0
  78. package/dist/commands/sandbox.js.map +1 -0
  79. package/dist/commands/session-view.d.ts +5 -0
  80. package/dist/commands/session-view.d.ts.map +1 -0
  81. package/dist/commands/session-view.js +62 -0
  82. package/dist/commands/session-view.js.map +1 -0
  83. package/dist/commands/types.d.ts +41 -0
  84. package/dist/commands/types.d.ts.map +1 -0
  85. package/dist/commands/types.js +2 -0
  86. package/dist/commands/types.js.map +1 -0
  87. package/dist/commands/utils.d.ts +8 -0
  88. package/dist/commands/utils.d.ts.map +1 -0
  89. package/dist/commands/utils.js +14 -0
  90. package/dist/commands/utils.js.map +1 -0
  91. package/dist/config.d.ts +53 -7
  92. package/dist/config.d.ts.map +1 -1
  93. package/dist/config.js +320 -55
  94. package/dist/config.js.map +1 -1
  95. package/dist/context.d.ts +10 -42
  96. package/dist/context.d.ts.map +1 -1
  97. package/dist/context.js +15 -128
  98. package/dist/context.js.map +1 -1
  99. package/dist/events.d.ts +16 -5
  100. package/dist/events.d.ts.map +1 -1
  101. package/dist/events.js +127 -58
  102. package/dist/events.js.map +1 -1
  103. package/dist/execution-resolver.d.ts +24 -0
  104. package/dist/execution-resolver.d.ts.map +1 -0
  105. package/dist/execution-resolver.js +115 -0
  106. package/dist/execution-resolver.js.map +1 -0
  107. package/dist/file-guards.d.ts +6 -0
  108. package/dist/file-guards.d.ts.map +1 -0
  109. package/dist/file-guards.js +48 -0
  110. package/dist/file-guards.js.map +1 -0
  111. package/dist/fs-atomic.d.ts +10 -0
  112. package/dist/fs-atomic.d.ts.map +1 -0
  113. package/dist/fs-atomic.js +45 -0
  114. package/dist/fs-atomic.js.map +1 -0
  115. package/dist/index.d.ts +7 -0
  116. package/dist/index.d.ts.map +1 -0
  117. package/dist/index.js +4 -0
  118. package/dist/index.js.map +1 -0
  119. package/dist/instrument.d.ts.map +1 -1
  120. package/dist/instrument.js +3 -3
  121. package/dist/instrument.js.map +1 -1
  122. package/dist/log.d.ts +3 -7
  123. package/dist/log.d.ts.map +1 -1
  124. package/dist/log.js +20 -45
  125. package/dist/log.js.map +1 -1
  126. package/dist/login/index.d.ts +41 -0
  127. package/dist/login/index.d.ts.map +1 -0
  128. package/dist/login/index.js +202 -0
  129. package/dist/login/index.js.map +1 -0
  130. package/dist/login/portal.d.ts +19 -0
  131. package/dist/login/portal.d.ts.map +1 -0
  132. package/dist/login/portal.js +1453 -0
  133. package/dist/login/portal.js.map +1 -0
  134. package/dist/login/session.d.ts +33 -0
  135. package/dist/login/session.d.ts.map +1 -0
  136. package/dist/login/session.js +68 -0
  137. package/dist/login/session.js.map +1 -0
  138. package/dist/main.d.ts.map +1 -1
  139. package/dist/main.js +229 -264
  140. package/dist/main.js.map +1 -1
  141. package/dist/provisioner.d.ts +79 -0
  142. package/dist/provisioner.d.ts.map +1 -0
  143. package/dist/provisioner.js +437 -0
  144. package/dist/provisioner.js.map +1 -0
  145. package/dist/runtime/conversation-orchestrator.d.ts +42 -0
  146. package/dist/runtime/conversation-orchestrator.d.ts.map +1 -0
  147. package/dist/runtime/conversation-orchestrator.js +150 -0
  148. package/dist/runtime/conversation-orchestrator.js.map +1 -0
  149. package/dist/runtime/index.d.ts +2 -0
  150. package/dist/runtime/index.d.ts.map +1 -0
  151. package/dist/runtime/index.js +2 -0
  152. package/dist/runtime/index.js.map +1 -0
  153. package/dist/runtime/session-runtime.d.ts +27 -0
  154. package/dist/runtime/session-runtime.d.ts.map +1 -0
  155. package/dist/runtime/session-runtime.js +211 -0
  156. package/dist/runtime/session-runtime.js.map +1 -0
  157. package/dist/sandbox/cloudflare.d.ts +15 -0
  158. package/dist/sandbox/cloudflare.d.ts.map +1 -0
  159. package/dist/sandbox/cloudflare.js +137 -0
  160. package/dist/sandbox/cloudflare.js.map +1 -0
  161. package/dist/sandbox/container.d.ts +16 -0
  162. package/dist/sandbox/container.d.ts.map +1 -0
  163. package/dist/sandbox/container.js +126 -0
  164. package/dist/sandbox/container.js.map +1 -0
  165. package/dist/sandbox/errors.d.ts +6 -0
  166. package/dist/sandbox/errors.d.ts.map +1 -0
  167. package/dist/sandbox/errors.js +11 -0
  168. package/dist/sandbox/errors.js.map +1 -0
  169. package/dist/sandbox/firecracker.d.ts +17 -0
  170. package/dist/sandbox/firecracker.d.ts.map +1 -0
  171. package/dist/sandbox/firecracker.js +212 -0
  172. package/dist/sandbox/firecracker.js.map +1 -0
  173. package/dist/sandbox/host.d.ts +11 -0
  174. package/dist/sandbox/host.d.ts.map +1 -0
  175. package/dist/sandbox/host.js +89 -0
  176. package/dist/sandbox/host.js.map +1 -0
  177. package/dist/sandbox/image.d.ts +5 -0
  178. package/dist/sandbox/image.d.ts.map +1 -0
  179. package/dist/sandbox/image.js +30 -0
  180. package/dist/sandbox/image.js.map +1 -0
  181. package/dist/sandbox/index.d.ts +22 -0
  182. package/dist/sandbox/index.d.ts.map +1 -0
  183. package/dist/sandbox/index.js +54 -0
  184. package/dist/sandbox/index.js.map +1 -0
  185. package/dist/sandbox/path-context.d.ts +4 -0
  186. package/dist/sandbox/path-context.d.ts.map +1 -0
  187. package/dist/sandbox/path-context.js +20 -0
  188. package/dist/sandbox/path-context.js.map +1 -0
  189. package/dist/sandbox/types.d.ts +67 -0
  190. package/dist/sandbox/types.d.ts.map +1 -0
  191. package/dist/sandbox/types.js +2 -0
  192. package/dist/sandbox/types.js.map +1 -0
  193. package/dist/sandbox/utils.d.ts +4 -0
  194. package/dist/sandbox/utils.d.ts.map +1 -0
  195. package/dist/sandbox/utils.js +51 -0
  196. package/dist/sandbox/utils.js.map +1 -0
  197. package/dist/sandbox.d.ts +1 -39
  198. package/dist/sandbox.d.ts.map +1 -1
  199. package/dist/sandbox.js +1 -286
  200. package/dist/sandbox.js.map +1 -1
  201. package/dist/sentry.d.ts +2 -2
  202. package/dist/sentry.d.ts.map +1 -1
  203. package/dist/sentry.js +6 -4
  204. package/dist/sentry.js.map +1 -1
  205. package/dist/session-policy.d.ts +13 -0
  206. package/dist/session-policy.d.ts.map +1 -0
  207. package/dist/session-policy.js +23 -0
  208. package/dist/session-policy.js.map +1 -0
  209. package/dist/session-store.d.ts +35 -8
  210. package/dist/session-store.d.ts.map +1 -1
  211. package/dist/session-store.js +182 -23
  212. package/dist/session-store.js.map +1 -1
  213. package/dist/session-view/command.d.ts +5 -0
  214. package/dist/session-view/command.d.ts.map +1 -0
  215. package/dist/session-view/command.js +11 -0
  216. package/dist/session-view/command.js.map +1 -0
  217. package/dist/session-view/portal.d.ts +16 -0
  218. package/dist/session-view/portal.d.ts.map +1 -0
  219. package/dist/session-view/portal.js +1742 -0
  220. package/dist/session-view/portal.js.map +1 -0
  221. package/dist/session-view/service.d.ts +34 -0
  222. package/dist/session-view/service.d.ts.map +1 -0
  223. package/dist/session-view/service.js +427 -0
  224. package/dist/session-view/service.js.map +1 -0
  225. package/dist/session-view/store.d.ts +18 -0
  226. package/dist/session-view/store.d.ts.map +1 -0
  227. package/dist/session-view/store.js +39 -0
  228. package/dist/session-view/store.js.map +1 -0
  229. package/dist/store.d.ts +4 -7
  230. package/dist/store.d.ts.map +1 -1
  231. package/dist/store.js +26 -52
  232. package/dist/store.js.map +1 -1
  233. package/dist/tool-diagnostics.d.ts +2 -0
  234. package/dist/tool-diagnostics.d.ts.map +1 -0
  235. package/dist/tool-diagnostics.js +7 -0
  236. package/dist/tool-diagnostics.js.map +1 -0
  237. package/dist/tools/bash.d.ts +1 -1
  238. package/dist/tools/bash.d.ts.map +1 -1
  239. package/dist/tools/bash.js.map +1 -1
  240. package/dist/tools/edit.d.ts +1 -1
  241. package/dist/tools/edit.d.ts.map +1 -1
  242. package/dist/tools/edit.js.map +1 -1
  243. package/dist/tools/event.d.ts +62 -0
  244. package/dist/tools/event.d.ts.map +1 -0
  245. package/dist/tools/event.js +138 -0
  246. package/dist/tools/event.js.map +1 -0
  247. package/dist/tools/index.d.ts +8 -2
  248. package/dist/tools/index.d.ts.map +1 -1
  249. package/dist/tools/index.js +5 -1
  250. package/dist/tools/index.js.map +1 -1
  251. package/dist/tools/read.d.ts +1 -1
  252. package/dist/tools/read.d.ts.map +1 -1
  253. package/dist/tools/read.js.map +1 -1
  254. package/dist/tools/write.d.ts +1 -1
  255. package/dist/tools/write.d.ts.map +1 -1
  256. package/dist/tools/write.js.map +1 -1
  257. package/dist/trigger.d.ts +31 -0
  258. package/dist/trigger.d.ts.map +1 -0
  259. package/dist/trigger.js +98 -0
  260. package/dist/trigger.js.map +1 -0
  261. package/dist/ui-copy.d.ts +12 -0
  262. package/dist/ui-copy.d.ts.map +1 -0
  263. package/dist/ui-copy.js +36 -0
  264. package/dist/ui-copy.js.map +1 -0
  265. package/dist/vault-routing.d.ts +4 -0
  266. package/dist/vault-routing.d.ts.map +1 -0
  267. package/dist/vault-routing.js +16 -0
  268. package/dist/vault-routing.js.map +1 -0
  269. package/dist/vault.d.ts +72 -0
  270. package/dist/vault.d.ts.map +1 -0
  271. package/dist/vault.js +264 -0
  272. package/dist/vault.js.map +1 -0
  273. package/package.json +16 -13
@@ -1,35 +1,24 @@
1
- import { Client, Events, GatewayIntentBits, Partials, } from "discord.js";
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
1
+ import { ApplicationCommandOptionType, Client, Events, GatewayIntentBits, Partials, } from "discord.js";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
3
  import { basename, join } from "path";
4
4
  import * as log from "../../log.js";
5
+ import { resolveChatSessionKey } from "../../session-policy.js";
6
+ import { evaluateAutoReplyPolicy } from "../../trigger.js";
7
+ import { formatNothingRunning } from "../../ui-copy.js";
8
+ import { appendBotResponseLog, appendChannelLog, ChannelQueue, resolveOnlyScopedStopTarget, resolveStopTarget, withRetry, } from "../shared.js";
5
9
  import { createDiscordAdapters } from "./context.js";
6
- class ChannelQueue {
7
- constructor() {
8
- this.queue = [];
9
- this.processing = false;
10
- }
11
- enqueue(work) {
12
- this.queue.push(work);
13
- this.processNext();
14
- }
15
- size() {
16
- return this.queue.length;
17
- }
18
- async processNext() {
19
- if (this.processing || this.queue.length === 0)
20
- return;
21
- this.processing = true;
22
- const work = this.queue.shift();
23
- try {
24
- await work();
25
- }
26
- catch (err) {
27
- log.logWarning("Discord queue error", err instanceof Error ? err.message : String(err));
28
- }
29
- this.processing = false;
30
- this.processNext();
31
- }
10
+ // discord.js: DiscordAPIError exposes `.status` (HTTP status) and a `.code`.
11
+ // RateLimitError fires when the internal queue gives up. Both should retry.
12
+ function discordIsRateLimited(err) {
13
+ if (err.status === 429)
14
+ return true;
15
+ if (err.httpStatus === 429)
16
+ return true;
17
+ if (err.name === "RateLimitError")
18
+ return true;
19
+ return false;
32
20
  }
21
+ const discordRetry = (fn) => withRetry(fn, { isRateLimited: discordIsRateLimited });
33
22
  // ============================================================================
34
23
  // DiscordBot
35
24
  // ============================================================================
@@ -41,6 +30,7 @@ export class DiscordBot {
41
30
  this.channels = new Map();
42
31
  this.users = new Map();
43
32
  this.handler = handler;
33
+ this.token = config.token;
44
34
  this.workingDir = config.workingDir;
45
35
  this.client = new Client({
46
36
  intents: [
@@ -57,34 +47,84 @@ export class DiscordBot {
57
47
  // ==========================================================================
58
48
  async start() {
59
49
  await new Promise((resolve, reject) => {
60
- this.client.once(Events.ClientReady, (readyClient) => {
50
+ this.client.once(Events.ClientReady, async (readyClient) => {
61
51
  this.botUserId = readyClient.user.id;
62
52
  this.startupTime = Date.now();
63
- log.logConnected();
53
+ log.logConnected("Discord");
64
54
  log.logInfo(`Discord bot started as ${readyClient.user.tag}`);
65
55
  this.loadCachedGuildData();
66
56
  this.setupEventHandlers();
57
+ try {
58
+ await readyClient.application.commands.set([
59
+ {
60
+ name: "login",
61
+ description: "Store credentials in your private vault",
62
+ },
63
+ {
64
+ name: "session",
65
+ description: "Open the current session in the web viewer",
66
+ },
67
+ {
68
+ name: "new",
69
+ description: "Reset conversation history and start fresh",
70
+ },
71
+ {
72
+ name: "stop",
73
+ description: "Stop the current conversation",
74
+ },
75
+ {
76
+ name: "model",
77
+ description: "Switch this conversation's LLM model",
78
+ options: [
79
+ {
80
+ name: "model",
81
+ description: "provider/model[:thinking], e.g. anthropic/claude-sonnet-4-6:off",
82
+ type: ApplicationCommandOptionType.String,
83
+ required: false,
84
+ },
85
+ ],
86
+ },
87
+ {
88
+ name: "sandbox",
89
+ description: "Show or temporarily boost this conversation's sandbox limits",
90
+ options: [
91
+ {
92
+ name: "action",
93
+ description: "Use 'boost' to temporarily apply the configured boost limits",
94
+ type: ApplicationCommandOptionType.String,
95
+ required: false,
96
+ },
97
+ ],
98
+ },
99
+ ]);
100
+ }
101
+ catch (err) {
102
+ log.logWarning("Failed to register Discord slash commands", err instanceof Error ? err.message : String(err));
103
+ }
67
104
  resolve();
68
105
  });
69
106
  this.client.once(Events.Error, reject);
70
- this.client.login(process.env.MOM_DISCORD_BOT_TOKEN).catch(reject);
107
+ this.client.login(this.token).catch(reject);
71
108
  });
72
109
  }
73
110
  async postMessage(channel, text) {
74
- const ch = await this.fetchTextChannel(channel);
75
- const msg = await ch.send(text);
76
- return msg.id;
111
+ return discordRetry(async () => {
112
+ const ch = await this.fetchTextChannel(channel);
113
+ const msg = await ch.send(text);
114
+ return msg.id;
115
+ });
77
116
  }
78
117
  async updateMessage(channel, ts, text) {
79
118
  await this.updateMessageRaw(channel, ts, text);
80
119
  }
81
120
  enqueueEvent(event) {
82
- const queue = this.getQueue(event.channel);
121
+ const conversationId = event.conversationId;
122
+ const queue = this.getQueue(conversationId);
83
123
  if (queue.size() >= 5) {
84
- log.logWarning(`Event queue full for ${event.channel}, discarding: ${event.text.substring(0, 50)}`);
124
+ log.logWarning(`Event queue full for ${conversationId}, discarding: ${event.text.substring(0, 50)}`);
85
125
  return false;
86
126
  }
87
- log.logInfo(`Enqueueing event for ${event.channel}: ${event.text.substring(0, 50)}`);
127
+ log.logInfo(`Enqueueing event for ${conversationId}: ${event.text.substring(0, 50)}`);
88
128
  queue.enqueue(() => {
89
129
  const adapters = createDiscordAdapters(event, this, true);
90
130
  return this.handler.handleEvent(event, this, adapters, true);
@@ -97,29 +137,38 @@ export class DiscordBot {
97
137
  formattingGuide: "## Discord Formatting (Markdown)\nBold: **text**, Italic: *text*, Code: `code`, Block: ```language\ncode```\nLinks: [text](url)",
98
138
  channels: this.getAllChannels(),
99
139
  users: this.getAllUsers(),
140
+ diagnostics: {
141
+ showUsageSummary: false,
142
+ },
100
143
  };
101
144
  }
102
145
  // ==========================================================================
103
146
  // Internal helpers (used by context.ts)
104
147
  // ==========================================================================
105
148
  async updateMessageRaw(channelId, messageId, text) {
106
- const ch = await this.fetchTextChannel(channelId);
107
- const msg = await ch.messages.fetch(messageId);
108
- await msg.edit(text);
149
+ return discordRetry(async () => {
150
+ const ch = await this.fetchTextChannel(channelId);
151
+ const msg = await ch.messages.fetch(messageId);
152
+ await msg.edit(text);
153
+ });
109
154
  }
110
155
  async postReply(channelId, replyToId, text) {
111
- const ch = await this.fetchTextChannel(channelId);
112
- const replyTarget = await ch.messages.fetch(replyToId);
113
- const sent = await replyTarget.reply(text);
114
- return sent.id;
156
+ return discordRetry(async () => {
157
+ const ch = await this.fetchTextChannel(channelId);
158
+ const replyTarget = await ch.messages.fetch(replyToId);
159
+ const sent = await replyTarget.reply(text);
160
+ return sent.id;
161
+ });
115
162
  }
116
163
  async postInThread(channelId, threadOrMessageId, text) {
117
164
  // Try as a thread channel first, then fall back to posting in the channel
118
165
  try {
119
166
  const thread = await this.client.channels.fetch(threadOrMessageId);
120
167
  if (thread && (thread.isThread() || thread.isTextBased())) {
121
- const msg = await thread.send(text);
122
- return msg.id;
168
+ return discordRetry(async () => {
169
+ const msg = await thread.send(text);
170
+ return msg.id;
171
+ });
123
172
  }
124
173
  }
125
174
  catch {
@@ -147,10 +196,22 @@ export class DiscordBot {
147
196
  }
148
197
  }
149
198
  async uploadFile(channelId, filePath, title) {
150
- const ch = await this.fetchTextChannel(channelId);
151
- const fileName = title ?? basename(filePath);
152
- const fileContent = readFileSync(filePath);
153
- await ch.send({ files: [{ attachment: fileContent, name: fileName }] });
199
+ return discordRetry(async () => {
200
+ const ch = await this.fetchTextChannel(channelId);
201
+ const fileName = title ?? basename(filePath);
202
+ const fileContent = readFileSync(filePath);
203
+ await ch.send({ files: [{ attachment: fileContent, name: fileName }] });
204
+ });
205
+ }
206
+ async sendDirectMessage(userId, text) {
207
+ return discordRetry(async () => {
208
+ const user = await this.client.users.fetch(userId);
209
+ const msg = await user.send(text);
210
+ return msg.id;
211
+ });
212
+ }
213
+ async postPrivate(_conversationId, userId, text) {
214
+ await this.sendDirectMessage(userId, text);
154
215
  }
155
216
  getAllChannels() {
156
217
  return Array.from(this.channels.values());
@@ -159,50 +220,41 @@ export class DiscordBot {
159
220
  return Array.from(this.users.values());
160
221
  }
161
222
  logToFile(channelId, entry) {
162
- const dir = join(this.workingDir, channelId);
163
- if (!existsSync(dir))
164
- mkdirSync(dir, { recursive: true });
165
- appendFileSync(join(dir, "log.jsonl"), `${JSON.stringify(entry)}\n`);
223
+ appendChannelLog(this.workingDir, channelId, entry);
166
224
  }
167
225
  logBotResponse(channelId, text, ts) {
168
- this.logToFile(channelId, {
169
- date: new Date().toISOString(),
170
- ts,
171
- user: "bot",
172
- text,
173
- attachments: [],
174
- isBot: true,
175
- });
226
+ appendBotResponseLog(this.workingDir, channelId, text, ts);
176
227
  }
177
228
  /**
178
- * Process attachments from a Discord message
179
- * Downloads files in background and returns metadata
180
- * Returns format compatible with ChatMessage: { name: string, localPath: string }[]
229
+ * Process attachments from a Discord message.
230
+ * Downloads files before returning so the agent can read them immediately.
181
231
  */
182
- processAttachments(channelId, attachments, _messageId) {
183
- const result = [];
232
+ async processAttachments(channelId, attachments, _messageId) {
233
+ const downloads = [];
184
234
  // Discord attachments Collection - iterate over values
185
235
  for (const attachment of attachments.values()) {
186
236
  if (!attachment.name) {
187
237
  log.logWarning("Discord attachment missing name, skipping", attachment.url);
188
238
  continue;
189
239
  }
190
- // Generate local filename
191
240
  const ts = Date.now();
192
241
  const sanitizedName = attachment.name.replace(/[^a-zA-Z0-9._-]/g, "_");
193
242
  const filename = `${ts}_${sanitizedName}`;
194
243
  const localPath = `${channelId}/attachments/${filename}`;
195
244
  const fullDir = join(this.workingDir, channelId, "attachments");
196
- result.push({
245
+ const result = {
197
246
  name: attachment.name,
198
- localPath: localPath,
199
- });
200
- // Download in background (fire and forget)
201
- this.downloadAttachment(fullDir, filename, attachment.url).catch((err) => {
247
+ localPath,
248
+ };
249
+ downloads.push(this.downloadAttachment(fullDir, filename, attachment.url)
250
+ .then(() => result)
251
+ .catch((err) => {
202
252
  log.logWarning(`Failed to download Discord attachment`, `${filename}: ${err}`);
203
- });
253
+ return null;
254
+ }));
204
255
  }
205
- return result;
256
+ const results = await Promise.all(downloads);
257
+ return results.filter((attachment) => attachment !== null);
206
258
  }
207
259
  /**
208
260
  * Download an attachment from URL to local file
@@ -219,7 +271,9 @@ export class DiscordBot {
219
271
  writeFileSync(join(dir, filename), Buffer.from(buffer));
220
272
  }
221
273
  catch (err) {
222
- throw new Error(`Download failed: ${err instanceof Error ? err.message : String(err)}`);
274
+ throw new Error(`Download failed: ${err instanceof Error ? err.message : String(err)}`, {
275
+ cause: err,
276
+ });
223
277
  }
224
278
  }
225
279
  // ==========================================================================
@@ -228,11 +282,23 @@ export class DiscordBot {
228
282
  getQueue(channelId) {
229
283
  let queue = this.queues.get(channelId);
230
284
  if (!queue) {
231
- queue = new ChannelQueue();
285
+ queue = new ChannelQueue("Discord");
232
286
  this.queues.set(channelId, queue);
233
287
  }
234
288
  return queue;
235
289
  }
290
+ resolveStopTarget(channelId, sessionKey) {
291
+ const directTarget = resolveStopTarget({
292
+ handler: this.handler,
293
+ conversationId: channelId,
294
+ sessionKey,
295
+ });
296
+ if (directTarget)
297
+ return directTarget;
298
+ if (sessionKey !== channelId)
299
+ return null;
300
+ return resolveOnlyScopedStopTarget(this.handler, channelId);
301
+ }
236
302
  loadCachedGuildData() {
237
303
  for (const guild of this.client.guilds.cache.values()) {
238
304
  for (const channel of guild.channels.cache.values()) {
@@ -254,7 +320,162 @@ export class DiscordBot {
254
320
  return text;
255
321
  return text.replace(new RegExp(`<@!?${this.botUserId}>`, "g"), "").trim();
256
322
  }
323
+ resolveConversationContext(input) {
324
+ if (!input.inGuild) {
325
+ return {
326
+ conversationId: input.channelId,
327
+ threadTs: input.referencedMsgId,
328
+ };
329
+ }
330
+ if (input.isThread) {
331
+ return {
332
+ conversationId: input.parentChannelId ?? input.channelId,
333
+ threadTs: input.channelId,
334
+ };
335
+ }
336
+ return {
337
+ conversationId: input.channelId,
338
+ threadTs: input.referencedMsgId,
339
+ };
340
+ }
341
+ createSlashCommandAdapters(interaction, commandText, sessionKey, conversationId) {
342
+ const isDM = !interaction.inGuild();
343
+ const userId = interaction.user.id;
344
+ const userName = interaction.user.username;
345
+ const platform = this.getPlatformInfo();
346
+ const shouldUseEphemeral = !isDM;
347
+ const message = {
348
+ id: interaction.id,
349
+ sessionKey,
350
+ conversationKind: isDM ? "direct" : "shared",
351
+ userId,
352
+ userName,
353
+ text: commandText,
354
+ attachments: [],
355
+ };
356
+ const respondPrivately = async (text, replace = false) => {
357
+ if (interaction.replied || interaction.deferred) {
358
+ if (replace) {
359
+ await interaction.editReply({ content: text });
360
+ }
361
+ else {
362
+ await interaction.followUp({ content: text, ephemeral: shouldUseEphemeral });
363
+ }
364
+ return;
365
+ }
366
+ await interaction.reply({ content: text, ephemeral: shouldUseEphemeral });
367
+ };
368
+ const responseCtx = {
369
+ respond: async (text) => {
370
+ await respondPrivately(text);
371
+ },
372
+ replaceResponse: async (text) => {
373
+ await respondPrivately(text, true);
374
+ },
375
+ respondDiagnostic: async (text) => {
376
+ await respondPrivately(text);
377
+ },
378
+ respondToolResult: async (result) => {
379
+ const duration = (result.durationMs / 1000).toFixed(1);
380
+ const formatted = `${result.isError ? "Error" : "Done"} ${result.toolName} (${duration}s)\n${result.result}`;
381
+ await respondPrivately(formatted);
382
+ },
383
+ setTyping: async () => { },
384
+ setWorking: async () => { },
385
+ uploadFile: async (filePath, title) => {
386
+ await this.uploadFile(conversationId, filePath, title);
387
+ },
388
+ deleteResponse: async () => { },
389
+ };
390
+ return { message, responseCtx, platform };
391
+ }
257
392
  setupEventHandlers() {
393
+ this.client.on(Events.InteractionCreate, async (interaction) => {
394
+ if (!interaction.isChatInputCommand())
395
+ return;
396
+ if (interaction.commandName !== "login" &&
397
+ interaction.commandName !== "session" &&
398
+ interaction.commandName !== "new" &&
399
+ interaction.commandName !== "stop" &&
400
+ interaction.commandName !== "model" &&
401
+ interaction.commandName !== "sandbox") {
402
+ return;
403
+ }
404
+ const isDM = !interaction.inGuild();
405
+ const { conversationId, threadTs } = this.resolveConversationContext({
406
+ channelId: interaction.channelId,
407
+ inGuild: interaction.inGuild(),
408
+ isThread: interaction.channel?.isThread() ?? false,
409
+ parentChannelId: interaction.channel && "parentId" in interaction.channel
410
+ ? interaction.channel.parentId
411
+ : null,
412
+ });
413
+ const sessionKey = resolveChatSessionKey({
414
+ conversationId,
415
+ conversationKind: isDM ? "direct" : "shared",
416
+ messageId: interaction.id,
417
+ persistentTopLevel: true,
418
+ threadTs,
419
+ });
420
+ const modelOption = interaction.commandName === "model"
421
+ ? interaction.options.getString("model")?.trim()
422
+ : undefined;
423
+ const sandboxAction = interaction.commandName === "sandbox"
424
+ ? interaction.options.getString("action")?.trim()
425
+ : undefined;
426
+ const commandArg = modelOption ?? sandboxAction;
427
+ const commandText = commandArg
428
+ ? `/${interaction.commandName} ${commandArg}`
429
+ : `/${interaction.commandName}`;
430
+ this.logToFile(conversationId, {
431
+ date: new Date(interaction.createdTimestamp).toISOString(),
432
+ ts: interaction.id,
433
+ ...(threadTs ? { threadTs } : {}),
434
+ user: interaction.user.id,
435
+ userName: interaction.user.username,
436
+ text: commandText,
437
+ attachments: [],
438
+ isBot: false,
439
+ });
440
+ const adapters = this.createSlashCommandAdapters(interaction, commandText, sessionKey, conversationId);
441
+ try {
442
+ if (interaction.commandName === "new") {
443
+ await this.handler.handleNewCommand(sessionKey, conversationId, this);
444
+ return;
445
+ }
446
+ if (interaction.commandName === "stop") {
447
+ const stopTarget = this.resolveStopTarget(conversationId, sessionKey);
448
+ if (stopTarget) {
449
+ await this.handler.handleStop(stopTarget, conversationId, this);
450
+ }
451
+ else {
452
+ await adapters.responseCtx.respond(formatNothingRunning("discord"));
453
+ }
454
+ return;
455
+ }
456
+ const event = {
457
+ type: "dm",
458
+ conversationId,
459
+ conversationKind: isDM ? "direct" : "shared",
460
+ ts: interaction.id,
461
+ thread_ts: threadTs,
462
+ sessionKey,
463
+ user: interaction.user.id,
464
+ text: commandText,
465
+ attachments: [],
466
+ };
467
+ await this.handler.handleEvent(event, this, adapters, false);
468
+ }
469
+ catch (err) {
470
+ log.logWarning("Discord slash command error", err instanceof Error ? err.message : String(err));
471
+ if (!interaction.replied && !interaction.deferred) {
472
+ await interaction.reply({
473
+ content: `${interaction.commandName} command failed. Please try again later.`,
474
+ ephemeral: !isDM,
475
+ });
476
+ }
477
+ }
478
+ });
258
479
  this.client.on(Events.MessageCreate, async (msg) => {
259
480
  // Skip messages from before startup
260
481
  if (msg.createdTimestamp < this.startupTime)
@@ -262,12 +483,19 @@ export class DiscordBot {
262
483
  // Skip bot messages
263
484
  if (msg.author.bot)
264
485
  return;
265
- // Skip if bot isn't mentioned and it's not a DM
266
486
  const isDM = msg.channel.type === 1; // ChannelType.DM = 1
487
+ const isInThread = msg.channel.isThread();
488
+ const referencedMsgId = msg.reference?.messageId;
489
+ const isThreadReply = isInThread || !!referencedMsgId;
267
490
  const isMentioned = msg.mentions.users.has(this.botUserId ?? "");
268
- if (!isDM && !isMentioned)
269
- return;
270
- const channelId = msg.channelId;
491
+ const isAutoReplyCandidate = !isDM && !isMentioned && !isThreadReply;
492
+ const { conversationId, threadTs } = this.resolveConversationContext({
493
+ channelId: msg.channelId,
494
+ inGuild: !isDM,
495
+ isThread: isInThread,
496
+ parentChannelId: "parentId" in msg.channel ? msg.channel.parentId : null,
497
+ referencedMsgId,
498
+ });
271
499
  const userId = msg.author.id;
272
500
  const userName = msg.author.username;
273
501
  const msgId = msg.id;
@@ -278,57 +506,64 @@ export class DiscordBot {
278
506
  displayName: msg.member?.displayName ?? userName,
279
507
  });
280
508
  // Track channel
281
- if (!this.channels.has(channelId) && "name" in msg.channel) {
509
+ if (!this.channels.has(conversationId) && "name" in msg.channel) {
282
510
  const ch = msg.channel;
283
- this.channels.set(channelId, { id: channelId, name: ch.name });
511
+ this.channels.set(conversationId, { id: conversationId, name: ch.name });
284
512
  }
285
- // Thread: if this message is in a thread (has parentId) or is a reply
286
- const isInThread = msg.channel.isThread();
287
- const referencedMsgId = msg.reference?.messageId;
288
- const threadTs = isInThread ? msg.channelId : referencedMsgId;
289
- const sessionKey = `${channelId}:${threadTs ?? msgId}`;
513
+ const conversationKind = isDM ? "direct" : "shared";
514
+ const sessionKey = resolveChatSessionKey({
515
+ conversationId,
516
+ conversationKind,
517
+ messageId: msgId,
518
+ persistentTopLevel: true,
519
+ threadTs,
520
+ });
290
521
  const cleanedText = this.stripBotMention(msg.content);
291
- // Process attachments (download in background)
292
- const processedAttachments = this.processAttachments(channelId, msg.attachments, msgId);
293
- const event = {
522
+ const eventBase = {
294
523
  type: isDM ? "dm" : "mention",
295
- channel: channelId,
524
+ conversationId,
525
+ conversationKind,
296
526
  ts: msgId,
297
527
  thread_ts: threadTs,
528
+ sessionKey,
298
529
  user: userId,
299
530
  userName,
300
531
  text: cleanedText,
301
- attachments: processedAttachments,
302
532
  };
303
- // Log message
304
- this.logToFile(channelId, {
533
+ // Handle stop before trigger gate — "stop" should never be auto-reply judged.
534
+ if (cleanedText.toLowerCase() === "stop" || cleanedText.toLowerCase() === "/stop") {
535
+ const stopTarget = this.resolveStopTarget(conversationId, sessionKey);
536
+ if (stopTarget) {
537
+ this.handler.handleStop(stopTarget, conversationId, this);
538
+ }
539
+ else if (!isAutoReplyCandidate) {
540
+ await this.postMessage(conversationId, formatNothingRunning("discord"));
541
+ }
542
+ return;
543
+ }
544
+ const triggerResult = isAutoReplyCandidate
545
+ ? await evaluateAutoReplyPolicy({ event: eventBase, workingDir: this.workingDir })
546
+ : { trigger: true, reason: "addressed" };
547
+ const logEntryBase = {
305
548
  date: msg.createdAt.toISOString(),
306
549
  ts: msgId,
550
+ ...(!isDM && threadTs ? { threadTs } : {}),
307
551
  user: userId,
308
552
  userName,
309
553
  text: cleanedText,
310
- attachments: processedAttachments,
311
554
  isBot: false,
312
- });
313
- // Handle stop command
314
- if (cleanedText.toLowerCase() === "stop" || cleanedText.toLowerCase() === "/stop") {
315
- if (this.handler.isRunning(sessionKey)) {
316
- this.handler.handleStop(sessionKey, channelId, this);
317
- }
318
- else {
319
- await this.postMessage(channelId, "_Nothing running_");
320
- }
555
+ };
556
+ if (!triggerResult.trigger) {
557
+ this.logToFile(conversationId, { ...logEntryBase, attachments: [] });
321
558
  return;
322
559
  }
323
- if (this.handler.isRunning(sessionKey)) {
324
- await this.postMessage(channelId, "_Already working. Say `stop` to cancel._");
325
- }
326
- else {
327
- this.getQueue(sessionKey).enqueue(() => {
328
- const adapters = createDiscordAdapters(event, this, false);
329
- return this.handler.handleEvent(event, this, adapters, false);
330
- });
331
- }
560
+ const processedAttachments = await this.processAttachments(conversationId, msg.attachments, msgId);
561
+ const event = { ...eventBase, attachments: processedAttachments };
562
+ this.logToFile(conversationId, { ...logEntryBase, attachments: processedAttachments });
563
+ this.getQueue(sessionKey).enqueue(() => {
564
+ const adapters = createDiscordAdapters(event, this, false);
565
+ return this.handler.handleEvent(event, this, adapters, false);
566
+ });
332
567
  });
333
568
  }
334
569
  async fetchTextChannel(channelId) {