@geminixiang/mama 0.2.0-beta.1 → 0.2.0-beta.11

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 (271) hide show
  1. package/README.md +168 -371
  2. package/dist/adapter.d.ts +36 -12
  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 +12 -7
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +358 -135
  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 +100 -36
  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 +30 -24
  18. package/dist/adapters/slack/bot.d.ts.map +1 -1
  19. package/dist/adapters/slack/bot.js +613 -224
  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 +127 -72
  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 +4 -2
  37. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  38. package/dist/adapters/telegram/bot.js +193 -147
  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 +58 -111
  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 -13
  48. package/dist/agent.d.ts.map +1 -1
  49. package/dist/agent.js +601 -567
  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 +49 -30
  92. package/dist/config.d.ts.map +1 -1
  93. package/dist/config.js +313 -75
  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 +14 -127
  98. package/dist/context.js.map +1 -1
  99. package/dist/events.d.ts +13 -6
  100. package/dist/events.d.ts.map +1 -1
  101. package/dist/events.js +118 -64
  102. package/dist/events.js.map +1 -1
  103. package/dist/execution-resolver.d.ts +9 -5
  104. package/dist/execution-resolver.d.ts.map +1 -1
  105. package/dist/execution-resolver.js +82 -18
  106. package/dist/execution-resolver.js.map +1 -1
  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 +4 -11
  121. package/dist/instrument.js.map +1 -1
  122. package/dist/log.d.ts +1 -5
  123. package/dist/log.d.ts.map +1 -1
  124. package/dist/log.js +13 -38
  125. package/dist/log.js.map +1 -1
  126. package/dist/{login.d.ts → login/index.d.ts} +16 -4
  127. package/dist/login/index.d.ts.map +1 -0
  128. package/dist/{login.js → login/index.js} +55 -17
  129. package/dist/login/index.js.map +1 -0
  130. package/dist/{link-server.d.ts → login/portal.d.ts} +7 -4
  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/{link-token.d.ts → login/session.d.ts} +4 -3
  135. package/dist/login/session.d.ts.map +1 -0
  136. package/dist/{link-token.js → login/session.js} +1 -1
  137. package/dist/login/session.js.map +1 -0
  138. package/dist/main.d.ts.map +1 -1
  139. package/dist/main.js +151 -373
  140. package/dist/main.js.map +1 -1
  141. package/dist/provisioner.d.ts +42 -52
  142. package/dist/provisioner.d.ts.map +1 -1
  143. package/dist/provisioner.js +256 -111
  144. package/dist/provisioner.js.map +1 -1
  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 +2 -1
  162. package/dist/sandbox/container.d.ts.map +1 -1
  163. package/dist/sandbox/container.js +5 -1
  164. package/dist/sandbox/container.js.map +1 -1
  165. package/dist/sandbox/firecracker.d.ts +2 -1
  166. package/dist/sandbox/firecracker.d.ts.map +1 -1
  167. package/dist/sandbox/firecracker.js +6 -0
  168. package/dist/sandbox/firecracker.js.map +1 -1
  169. package/dist/sandbox/host.d.ts +2 -3
  170. package/dist/sandbox/host.d.ts.map +1 -1
  171. package/dist/sandbox/host.js +5 -5
  172. package/dist/sandbox/host.js.map +1 -1
  173. package/dist/sandbox/index.d.ts +6 -4
  174. package/dist/sandbox/index.d.ts.map +1 -1
  175. package/dist/sandbox/index.js +9 -6
  176. package/dist/sandbox/index.js.map +1 -1
  177. package/dist/sandbox/path-context.d.ts +4 -0
  178. package/dist/sandbox/path-context.d.ts.map +1 -0
  179. package/dist/sandbox/path-context.js +20 -0
  180. package/dist/sandbox/path-context.js.map +1 -0
  181. package/dist/sandbox/types.d.ts +17 -1
  182. package/dist/sandbox/types.d.ts.map +1 -1
  183. package/dist/sandbox/types.js.map +1 -1
  184. package/dist/sentry.d.ts +1 -1
  185. package/dist/sentry.d.ts.map +1 -1
  186. package/dist/sentry.js +4 -2
  187. package/dist/sentry.js.map +1 -1
  188. package/dist/session-policy.d.ts +13 -0
  189. package/dist/session-policy.d.ts.map +1 -0
  190. package/dist/session-policy.js +23 -0
  191. package/dist/session-policy.js.map +1 -0
  192. package/dist/session-store.d.ts +34 -3
  193. package/dist/session-store.d.ts.map +1 -1
  194. package/dist/session-store.js +184 -22
  195. package/dist/session-store.js.map +1 -1
  196. package/dist/session-view/command.d.ts +5 -0
  197. package/dist/session-view/command.d.ts.map +1 -0
  198. package/dist/session-view/command.js +11 -0
  199. package/dist/session-view/command.js.map +1 -0
  200. package/dist/session-view/portal.d.ts +16 -0
  201. package/dist/session-view/portal.d.ts.map +1 -0
  202. package/dist/session-view/portal.js +1742 -0
  203. package/dist/session-view/portal.js.map +1 -0
  204. package/dist/session-view/service.d.ts +34 -0
  205. package/dist/session-view/service.d.ts.map +1 -0
  206. package/dist/session-view/service.js +427 -0
  207. package/dist/session-view/service.js.map +1 -0
  208. package/dist/session-view/store.d.ts +18 -0
  209. package/dist/session-view/store.d.ts.map +1 -0
  210. package/dist/session-view/store.js +39 -0
  211. package/dist/session-view/store.js.map +1 -0
  212. package/dist/store.d.ts +3 -6
  213. package/dist/store.d.ts.map +1 -1
  214. package/dist/store.js +22 -48
  215. package/dist/store.js.map +1 -1
  216. package/dist/tool-diagnostics.d.ts +2 -0
  217. package/dist/tool-diagnostics.d.ts.map +1 -0
  218. package/dist/tool-diagnostics.js +7 -0
  219. package/dist/tool-diagnostics.js.map +1 -0
  220. package/dist/tools/bash.d.ts +1 -1
  221. package/dist/tools/bash.d.ts.map +1 -1
  222. package/dist/tools/bash.js.map +1 -1
  223. package/dist/tools/edit.d.ts +1 -1
  224. package/dist/tools/edit.d.ts.map +1 -1
  225. package/dist/tools/edit.js.map +1 -1
  226. package/dist/tools/event.d.ts +43 -2
  227. package/dist/tools/event.d.ts.map +1 -1
  228. package/dist/tools/event.js +48 -13
  229. package/dist/tools/event.js.map +1 -1
  230. package/dist/tools/index.d.ts +2 -1
  231. package/dist/tools/index.d.ts.map +1 -1
  232. package/dist/tools/index.js +3 -3
  233. package/dist/tools/index.js.map +1 -1
  234. package/dist/tools/read.d.ts +1 -1
  235. package/dist/tools/read.d.ts.map +1 -1
  236. package/dist/tools/read.js.map +1 -1
  237. package/dist/tools/write.d.ts +1 -1
  238. package/dist/tools/write.d.ts.map +1 -1
  239. package/dist/tools/write.js.map +1 -1
  240. package/dist/trigger.d.ts +31 -0
  241. package/dist/trigger.d.ts.map +1 -0
  242. package/dist/trigger.js +98 -0
  243. package/dist/trigger.js.map +1 -0
  244. package/dist/ui-copy.d.ts +1 -0
  245. package/dist/ui-copy.d.ts.map +1 -1
  246. package/dist/ui-copy.js +3 -0
  247. package/dist/ui-copy.js.map +1 -1
  248. package/dist/vault-routing.d.ts +1 -7
  249. package/dist/vault-routing.d.ts.map +1 -1
  250. package/dist/vault-routing.js +6 -48
  251. package/dist/vault-routing.js.map +1 -1
  252. package/dist/vault.d.ts +21 -55
  253. package/dist/vault.d.ts.map +1 -1
  254. package/dist/vault.js +144 -263
  255. package/dist/vault.js.map +1 -1
  256. package/package.json +12 -10
  257. package/dist/bindings.d.ts +0 -63
  258. package/dist/bindings.d.ts.map +0 -1
  259. package/dist/bindings.js +0 -94
  260. package/dist/bindings.js.map +0 -1
  261. package/dist/link-server.d.ts.map +0 -1
  262. package/dist/link-server.js +0 -839
  263. package/dist/link-server.js.map +0 -1
  264. package/dist/link-token.d.ts.map +0 -1
  265. package/dist/link-token.js.map +0 -1
  266. package/dist/login.d.ts.map +0 -1
  267. package/dist/login.js.map +0 -1
  268. package/dist/vault.test.d.ts +0 -2
  269. package/dist/vault.test.d.ts.map +0 -1
  270. package/dist/vault.test.js +0 -67
  271. package/dist/vault.test.js.map +0 -1
@@ -1,37 +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
- import { parseLoginCommand } from "../../login.js";
5
4
  import * as log from "../../log.js";
6
- import { formatAlreadyWorking, formatNothingRunning } from "../../ui-copy.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";
7
9
  import { createDiscordAdapters } from "./context.js";
8
- class ChannelQueue {
9
- constructor() {
10
- this.queue = [];
11
- this.processing = false;
12
- }
13
- enqueue(work) {
14
- this.queue.push(work);
15
- this.processNext();
16
- }
17
- size() {
18
- return this.queue.length;
19
- }
20
- async processNext() {
21
- if (this.processing || this.queue.length === 0)
22
- return;
23
- this.processing = true;
24
- const work = this.queue.shift();
25
- try {
26
- await work();
27
- }
28
- catch (err) {
29
- log.logWarning("Discord queue error", err instanceof Error ? err.message : String(err));
30
- }
31
- this.processing = false;
32
- this.processNext();
33
- }
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;
34
20
  }
21
+ const discordRetry = (fn) => withRetry(fn, { isRateLimited: discordIsRateLimited });
35
22
  // ============================================================================
36
23
  // DiscordBot
37
24
  // ============================================================================
@@ -43,6 +30,7 @@ export class DiscordBot {
43
30
  this.channels = new Map();
44
31
  this.users = new Map();
45
32
  this.handler = handler;
33
+ this.token = config.token;
46
34
  this.workingDir = config.workingDir;
47
35
  this.client = new Client({
48
36
  intents: [
@@ -59,41 +47,86 @@ export class DiscordBot {
59
47
  // ==========================================================================
60
48
  async start() {
61
49
  await new Promise((resolve, reject) => {
62
- this.client.once(Events.ClientReady, (readyClient) => {
50
+ this.client.once(Events.ClientReady, async (readyClient) => {
63
51
  this.botUserId = readyClient.user.id;
64
52
  this.startupTime = Date.now();
65
- log.logConnected();
53
+ log.logConnected("Discord");
66
54
  log.logInfo(`Discord bot started as ${readyClient.user.tag}`);
67
55
  this.loadCachedGuildData();
68
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
+ }
69
104
  resolve();
70
105
  });
71
106
  this.client.once(Events.Error, reject);
72
- this.client.login(process.env.MOM_DISCORD_BOT_TOKEN).catch(reject);
107
+ this.client.login(this.token).catch(reject);
73
108
  });
74
109
  }
75
- async postMessage(conversationId, text) {
76
- const ch = await this.fetchTextChannel(conversationId);
77
- const msg = await ch.send(text);
78
- return msg.id;
110
+ async postMessage(channel, text) {
111
+ return discordRetry(async () => {
112
+ const ch = await this.fetchTextChannel(channel);
113
+ const msg = await ch.send(text);
114
+ return msg.id;
115
+ });
79
116
  }
80
- async updateMessage(conversationId, ts, text) {
81
- await this.updateMessageRaw(conversationId, ts, text);
117
+ async updateMessage(channel, ts, text) {
118
+ await this.updateMessageRaw(channel, ts, text);
82
119
  }
83
120
  enqueueEvent(event) {
84
- const queue = this.getQueue(event.conversationId);
121
+ const conversationId = event.conversationId;
122
+ const queue = this.getQueue(conversationId);
85
123
  if (queue.size() >= 5) {
86
- log.logWarning(`Event queue full for ${event.conversationId}, discarding: ${event.text.substring(0, 50)}`);
124
+ log.logWarning(`Event queue full for ${conversationId}, discarding: ${event.text.substring(0, 50)}`);
87
125
  return false;
88
126
  }
89
- log.logInfo(`Enqueueing event for ${event.conversationId}: ${event.text.substring(0, 50)}`);
127
+ log.logInfo(`Enqueueing event for ${conversationId}: ${event.text.substring(0, 50)}`);
90
128
  queue.enqueue(() => {
91
- const discordEvent = {
92
- ...event,
93
- type: "mention",
94
- conversationId: event.conversationId,
95
- };
96
- const adapters = createDiscordAdapters(discordEvent, this, true);
129
+ const adapters = createDiscordAdapters(event, this, true);
97
130
  return this.handler.handleEvent(event, this, adapters, true);
98
131
  });
99
132
  return true;
@@ -104,29 +137,38 @@ export class DiscordBot {
104
137
  formattingGuide: "## Discord Formatting (Markdown)\nBold: **text**, Italic: *text*, Code: `code`, Block: ```language\ncode```\nLinks: [text](url)",
105
138
  channels: this.getAllChannels(),
106
139
  users: this.getAllUsers(),
140
+ diagnostics: {
141
+ showUsageSummary: false,
142
+ },
107
143
  };
108
144
  }
109
145
  // ==========================================================================
110
146
  // Internal helpers (used by context.ts)
111
147
  // ==========================================================================
112
148
  async updateMessageRaw(channelId, messageId, text) {
113
- const ch = await this.fetchTextChannel(channelId);
114
- const msg = await ch.messages.fetch(messageId);
115
- 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
+ });
116
154
  }
117
155
  async postReply(channelId, replyToId, text) {
118
- const ch = await this.fetchTextChannel(channelId);
119
- const replyTarget = await ch.messages.fetch(replyToId);
120
- const sent = await replyTarget.reply(text);
121
- 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
+ });
122
162
  }
123
163
  async postInThread(channelId, threadOrMessageId, text) {
124
164
  // Try as a thread channel first, then fall back to posting in the channel
125
165
  try {
126
166
  const thread = await this.client.channels.fetch(threadOrMessageId);
127
167
  if (thread && (thread.isThread() || thread.isTextBased())) {
128
- const msg = await thread.send(text);
129
- return msg.id;
168
+ return discordRetry(async () => {
169
+ const msg = await thread.send(text);
170
+ return msg.id;
171
+ });
130
172
  }
131
173
  }
132
174
  catch {
@@ -154,10 +196,22 @@ export class DiscordBot {
154
196
  }
155
197
  }
156
198
  async uploadFile(channelId, filePath, title) {
157
- const ch = await this.fetchTextChannel(channelId);
158
- const fileName = title ?? basename(filePath);
159
- const fileContent = readFileSync(filePath);
160
- 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);
161
215
  }
162
216
  getAllChannels() {
163
217
  return Array.from(this.channels.values());
@@ -166,67 +220,60 @@ export class DiscordBot {
166
220
  return Array.from(this.users.values());
167
221
  }
168
222
  logToFile(channelId, entry) {
169
- const channelDir = join(this.workingDir, channelId);
170
- if (!existsSync(channelDir))
171
- mkdirSync(channelDir, { recursive: true });
172
- appendFileSync(join(channelDir, "log.jsonl"), `${JSON.stringify(entry)}\n`);
223
+ appendChannelLog(this.workingDir, channelId, entry);
173
224
  }
174
225
  logBotResponse(channelId, text, ts) {
175
- this.logToFile(channelId, {
176
- date: new Date().toISOString(),
177
- ts,
178
- user: "bot",
179
- text,
180
- attachments: [],
181
- isBot: true,
182
- });
226
+ appendBotResponseLog(this.workingDir, channelId, text, ts);
183
227
  }
184
228
  /**
185
- * Process attachments from a Discord message
186
- * Downloads files in background and returns metadata
187
- * 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.
188
231
  */
189
- processAttachments(channelId, attachments, _messageId) {
190
- const result = [];
232
+ async processAttachments(channelId, attachments, _messageId) {
233
+ const downloads = [];
191
234
  // Discord attachments Collection - iterate over values
192
235
  for (const attachment of attachments.values()) {
193
236
  if (!attachment.name) {
194
237
  log.logWarning("Discord attachment missing name, skipping", attachment.url);
195
238
  continue;
196
239
  }
197
- // Generate local filename
198
240
  const ts = Date.now();
199
241
  const sanitizedName = attachment.name.replace(/[^a-zA-Z0-9._-]/g, "_");
200
242
  const filename = `${ts}_${sanitizedName}`;
201
243
  const localPath = `${channelId}/attachments/${filename}`;
202
- const attachmentsDir = join(this.workingDir, channelId, "attachments");
203
- result.push({
244
+ const fullDir = join(this.workingDir, channelId, "attachments");
245
+ const result = {
204
246
  name: attachment.name,
205
- localPath: localPath,
206
- });
207
- // Download in background (fire and forget)
208
- this.downloadAttachment(attachmentsDir, filename, attachment.url).catch((err) => {
247
+ localPath,
248
+ };
249
+ downloads.push(this.downloadAttachment(fullDir, filename, attachment.url)
250
+ .then(() => result)
251
+ .catch((err) => {
209
252
  log.logWarning(`Failed to download Discord attachment`, `${filename}: ${err}`);
210
- });
253
+ return null;
254
+ }));
211
255
  }
212
- return result;
256
+ const results = await Promise.all(downloads);
257
+ return results.filter((attachment) => attachment !== null);
213
258
  }
214
259
  /**
215
260
  * Download an attachment from URL to local file
216
261
  */
217
- async downloadAttachment(attachmentsDir, filename, url) {
218
- if (!existsSync(attachmentsDir))
219
- mkdirSync(attachmentsDir, { recursive: true });
262
+ async downloadAttachment(dir, filename, url) {
263
+ if (!existsSync(dir))
264
+ mkdirSync(dir, { recursive: true });
220
265
  try {
221
266
  const response = await fetch(url);
222
267
  if (!response.ok) {
223
268
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
224
269
  }
225
270
  const buffer = await response.arrayBuffer();
226
- writeFileSync(join(attachmentsDir, filename), Buffer.from(buffer));
271
+ writeFileSync(join(dir, filename), Buffer.from(buffer));
227
272
  }
228
273
  catch (err) {
229
- 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
+ });
230
277
  }
231
278
  }
232
279
  // ==========================================================================
@@ -235,11 +282,23 @@ export class DiscordBot {
235
282
  getQueue(channelId) {
236
283
  let queue = this.queues.get(channelId);
237
284
  if (!queue) {
238
- queue = new ChannelQueue();
285
+ queue = new ChannelQueue("Discord");
239
286
  this.queues.set(channelId, queue);
240
287
  }
241
288
  return queue;
242
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
+ }
243
302
  loadCachedGuildData() {
244
303
  for (const guild of this.client.guilds.cache.values()) {
245
304
  for (const channel of guild.channels.cache.values()) {
@@ -261,7 +320,162 @@ export class DiscordBot {
261
320
  return text;
262
321
  return text.replace(new RegExp(`<@!?${this.botUserId}>`, "g"), "").trim();
263
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
+ }
264
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
+ });
265
479
  this.client.on(Events.MessageCreate, async (msg) => {
266
480
  // Skip messages from before startup
267
481
  if (msg.createdTimestamp < this.startupTime)
@@ -269,12 +483,19 @@ export class DiscordBot {
269
483
  // Skip bot messages
270
484
  if (msg.author.bot)
271
485
  return;
272
- // Skip if bot isn't mentioned and it's not a DM
273
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;
274
490
  const isMentioned = msg.mentions.users.has(this.botUserId ?? "");
275
- if (!isDM && !isMentioned)
276
- return;
277
- 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
+ });
278
499
  const userId = msg.author.id;
279
500
  const userName = msg.author.username;
280
501
  const msgId = msg.id;
@@ -285,62 +506,64 @@ export class DiscordBot {
285
506
  displayName: msg.member?.displayName ?? userName,
286
507
  });
287
508
  // Track channel
288
- if (!this.channels.has(channelId) && "name" in msg.channel) {
509
+ if (!this.channels.has(conversationId) && "name" in msg.channel) {
289
510
  const ch = msg.channel;
290
- this.channels.set(channelId, { id: channelId, name: ch.name });
511
+ this.channels.set(conversationId, { id: conversationId, name: ch.name });
291
512
  }
292
- // Thread: if this message is in a thread (has parentId) or is a reply
293
- const isInThread = msg.channel.isThread();
294
- const referencedMsgId = msg.reference?.messageId;
295
- const threadTs = isInThread ? msg.channelId : referencedMsgId;
296
- 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
+ });
297
521
  const cleanedText = this.stripBotMention(msg.content);
298
- // Process attachments (download in background)
299
- const processedAttachments = this.processAttachments(channelId, msg.attachments, msgId);
300
- const event = {
522
+ const eventBase = {
301
523
  type: isDM ? "dm" : "mention",
302
- conversationId: channelId,
524
+ conversationId,
525
+ conversationKind,
303
526
  ts: msgId,
304
527
  thread_ts: threadTs,
528
+ sessionKey,
305
529
  user: userId,
306
530
  userName,
307
531
  text: cleanedText,
308
- attachments: processedAttachments,
309
532
  };
310
- // Log message
311
- 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 = {
312
548
  date: msg.createdAt.toISOString(),
313
549
  ts: msgId,
550
+ ...(!isDM && threadTs ? { threadTs } : {}),
314
551
  user: userId,
315
552
  userName,
316
553
  text: cleanedText,
317
- attachments: processedAttachments,
318
554
  isBot: false,
319
- });
320
- // Handle stop command
321
- if (cleanedText.toLowerCase() === "stop" || cleanedText.toLowerCase() === "/stop") {
322
- if (this.handler.isRunning(sessionKey)) {
323
- this.handler.handleStop(sessionKey, channelId, this);
324
- }
325
- else {
326
- await this.postMessage(channelId, formatNothingRunning("discord"));
327
- }
328
- return;
329
- }
330
- // Handle login command
331
- if (parseLoginCommand(cleanedText)) {
332
- await this.handler.handleLogin("discord", userId, channelId, this, cleanedText, isDM);
555
+ };
556
+ if (!triggerResult.trigger) {
557
+ this.logToFile(conversationId, { ...logEntryBase, attachments: [] });
333
558
  return;
334
559
  }
335
- if (this.handler.isRunning(sessionKey)) {
336
- await this.postMessage(channelId, formatAlreadyWorking("discord", "stop"));
337
- }
338
- else {
339
- this.getQueue(sessionKey).enqueue(() => {
340
- const adapters = createDiscordAdapters(event, this, false);
341
- return this.handler.handleEvent(event, this, adapters, false);
342
- });
343
- }
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
+ });
344
567
  });
345
568
  }
346
569
  async fetchTextChannel(channelId) {