@geminixiang/mama 0.2.0-beta.2 → 0.2.0-beta.20

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