@geminixiang/mama 0.2.0-beta.1 → 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 (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 +620 -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 +38 -52
  142. package/dist/provisioner.d.ts.map +1 -1
  143. package/dist/provisioner.js +212 -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 +138 -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,78 +1,56 @@
1
1
  import { SocketModeClient } from "@slack/socket-mode";
2
2
  import { WebClient } from "@slack/web-api";
3
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
3
+ import { existsSync, linkSync, readFileSync } from "fs";
4
4
  import { readFile } from "fs/promises";
5
5
  import { basename, join } from "path";
6
- import { parseLoginCommand } from "../../login.js";
7
6
  import * as log from "../../log.js";
8
- import { PRODUCT_NAME, formatAlreadyWorking, formatForceStopped, formatNothingRunning, } from "../../ui-copy.js";
7
+ import { PRODUCT_NAME, formatForceStopped, formatNothingRunning } from "../../ui-copy.js";
8
+ import { appendBotResponseLog, appendChannelLog, ChannelQueue, resolveOnlyScopedStopTarget, resolveStopTarget, withRetry, } from "../shared.js";
9
+ import { getThreadSessionFile } from "../../session-store.js";
10
+ import { evaluateAutoReplyPolicy } from "../../trigger.js";
9
11
  import { createSlackAdapters } from "./context.js";
10
- // ============================================================================
11
- // Exponential backoff utility for Slack API calls
12
- // ============================================================================
13
- /**
14
- * Retry a function with exponential backoff on rate limit errors.
15
- */
16
- async function withRetry(fn, maxRetries = 3, baseDelayMs = 1000) {
17
- let lastError;
18
- for (let attempt = 0; attempt < maxRetries; attempt++) {
19
- try {
20
- return await fn();
21
- }
22
- catch (err) {
23
- lastError = err instanceof Error ? err : new Error(String(err));
24
- // Check for rate limit errors
25
- let isRateLimited = false;
26
- // Check for rate_limited error code (Slack SDK)
27
- if ("code" in lastError && lastError.code === "rate_limited") {
28
- isRateLimited = true;
29
- }
30
- // Check for rate_limited in error response
31
- if ("data" in lastError) {
32
- const data = lastError
33
- .data;
34
- if (data?.error === "rate_limited" || data?.response?.status === 429) {
35
- isRateLimited = true;
36
- }
37
- }
38
- if (isRateLimited) {
39
- const delay = baseDelayMs * Math.pow(2, attempt);
40
- log.logWarning(`Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
41
- await new Promise((resolve) => setTimeout(resolve, delay));
42
- continue;
43
- }
44
- // Non-retryable error
45
- throw lastError;
46
- }
47
- }
48
- throw lastError;
12
+ import { hasMaterializedSlackBranchSession } from "./branch-manager.js";
13
+ import { resolveSlackSessionKey } from "./session.js";
14
+ // Slack WebClient errors carry either `code: "rate_limited"` (retry-after) or
15
+ // the legacy `data.error === "rate_limited"` / 429 status shape.
16
+ function slackIsRateLimited(err) {
17
+ if (err.code === "rate_limited")
18
+ return true;
19
+ const data = err.data;
20
+ return data?.error === "rate_limited" || data?.response?.status === 429;
49
21
  }
50
- class ChannelQueue {
51
- constructor() {
52
- this.queue = [];
53
- this.processing = false;
54
- }
55
- enqueue(work) {
56
- this.queue.push(work);
57
- this.processNext();
22
+ const slackRetry = (fn) => withRetry(fn, { isRateLimited: slackIsRateLimited });
23
+ function collectSlackText(value, parts) {
24
+ if (value === null || value === undefined)
25
+ return;
26
+ if (typeof value === "string") {
27
+ const trimmed = value.trim();
28
+ if (trimmed)
29
+ parts.push(trimmed);
30
+ return;
58
31
  }
59
- size() {
60
- return this.queue.length;
32
+ if (Array.isArray(value)) {
33
+ for (const item of value)
34
+ collectSlackText(item, parts);
35
+ return;
61
36
  }
62
- async processNext() {
63
- if (this.processing || this.queue.length === 0)
64
- return;
65
- this.processing = true;
66
- const work = this.queue.shift();
67
- try {
68
- await work();
69
- }
70
- catch (err) {
71
- log.logWarning("Queue error", err instanceof Error ? err.message : String(err));
72
- }
73
- this.processing = false;
74
- this.processNext();
37
+ if (typeof value !== "object")
38
+ return;
39
+ const obj = value;
40
+ for (const key of ["text", "fallback", "title", "value"]) {
41
+ collectSlackText(obj[key], parts);
75
42
  }
43
+ collectSlackText(obj.fields, parts);
44
+ collectSlackText(obj.elements, parts);
45
+ collectSlackText(obj.blocks, parts);
46
+ }
47
+ function buildSlackAppMessageText(event) {
48
+ const parts = [];
49
+ collectSlackText(event.text, parts);
50
+ collectSlackText(event.blocks, parts);
51
+ collectSlackText(event.attachments, parts);
52
+ const deduped = parts.filter((part, index) => parts.indexOf(part) === index);
53
+ return deduped.join("\n");
76
54
  }
77
55
  // ============================================================================
78
56
  // SlackBot
@@ -80,6 +58,8 @@ class ChannelQueue {
80
58
  export class SlackBot {
81
59
  constructor(handler, config) {
82
60
  this.botUserId = null;
61
+ this.botId = null;
62
+ this.ownMentionRegex = null;
83
63
  this.startupTs = null; // Messages older than this are just logged, not processed
84
64
  this.users = new Map();
85
65
  this.channels = new Map();
@@ -88,33 +68,24 @@ export class SlackBot {
88
68
  this.handler = handler;
89
69
  this.workingDir = config.workingDir;
90
70
  this.store = config.store;
91
- this.socketClient = new SocketModeClient({ appToken: config.appToken });
71
+ this.socketClient = new SocketModeClient({
72
+ appToken: config.appToken,
73
+ // Default 5s is too tight: brief event-loop stalls (e.g. backfill, sync fs)
74
+ // cause false pong timeouts; 4 in a row makes Slack drop the socket.
75
+ clientPingTimeout: 12_000,
76
+ });
92
77
  this.webClient = new WebClient(config.botToken);
93
78
  }
94
79
  setEventsWatcher(watcher) {
95
80
  this.eventsWatcher = watcher;
96
81
  }
97
- toBotEvent(event) {
98
- return {
99
- type: event.type,
100
- conversationId: event.channel,
101
- ts: event.ts,
102
- thread_ts: event.thread_ts,
103
- user: event.user,
104
- text: event.text,
105
- attachments: event.attachments?.map((attachment) => ({
106
- name: attachment.original,
107
- localPath: attachment.localPath,
108
- })),
109
- sessionKey: event.sessionKey,
110
- };
111
- }
112
82
  // ==========================================================================
113
83
  // Public API
114
84
  // ==========================================================================
115
85
  async start() {
116
86
  const auth = await this.webClient.auth.test();
117
87
  this.botUserId = auth.user_id;
88
+ this.botId = typeof auth.bot_id === "string" ? auth.bot_id : null;
118
89
  await Promise.all([this.fetchUsers(), this.fetchChannels()]);
119
90
  log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);
120
91
  await this.backfillAllChannels();
@@ -122,7 +93,7 @@ export class SlackBot {
122
93
  await this.socketClient.start();
123
94
  // Record startup time - messages older than this are just logged, not processed
124
95
  this.startupTs = (Date.now() / 1000).toFixed(6);
125
- log.logConnected();
96
+ log.logConnected("Slack");
126
97
  }
127
98
  getUser(userId) {
128
99
  return this.users.get(userId);
@@ -136,19 +107,74 @@ export class SlackBot {
136
107
  getAllChannels() {
137
108
  return Array.from(this.channels.values());
138
109
  }
139
- async postMessage(conversationId, text) {
140
- return withRetry(async () => {
141
- const result = await this.webClient.chat.postMessage({ channel: conversationId, text });
110
+ stripOwnMention(text) {
111
+ const source = text ?? "";
112
+ if (!this.botUserId)
113
+ return source.trim();
114
+ if (!this.ownMentionRegex || !this.ownMentionRegex.source.includes(this.botUserId)) {
115
+ this.ownMentionRegex = new RegExp(`<@${this.botUserId}>`, "gi");
116
+ }
117
+ return source.replace(this.ownMentionRegex, "").trim();
118
+ }
119
+ async postMessage(channel, text) {
120
+ return slackRetry(async () => {
121
+ const result = await this.webClient.chat.postMessage({ channel, text });
122
+ return result.ts;
123
+ });
124
+ }
125
+ async postEphemeral(channel, user, text) {
126
+ return slackRetry(async () => {
127
+ await this.webClient.chat.postEphemeral({ channel, user, text });
128
+ });
129
+ }
130
+ async postEphemeralBlocks(channel, user, text, blocks) {
131
+ return slackRetry(async () => {
132
+ await this.webClient.chat.postEphemeral({ channel, user, text, blocks: blocks });
133
+ });
134
+ }
135
+ async postMessageBlocks(channel, text, blocks) {
136
+ return slackRetry(async () => {
137
+ const result = await this.webClient.chat.postMessage({
138
+ channel,
139
+ text,
140
+ blocks: blocks,
141
+ });
142
142
  return result.ts;
143
143
  });
144
144
  }
145
- async updateMessage(conversationId, ts, text) {
146
- return withRetry(async () => {
147
- await this.webClient.chat.update({ channel: conversationId, ts, text });
145
+ async postPrivate(conversationId, userId, text) {
146
+ await this.postEphemeral(conversationId, userId, text);
147
+ }
148
+ async postPrivateDiagnostic(conversationId, userId, text, options) {
149
+ if (options?.style !== "muted") {
150
+ await this.postPrivate(conversationId, userId, options?.style === "error" ? `_${text}_` : text);
151
+ return;
152
+ }
153
+ const CONTEXT_TEXT_LIMIT = 3000;
154
+ const blockText = text.length > CONTEXT_TEXT_LIMIT
155
+ ? text.substring(0, CONTEXT_TEXT_LIMIT - 20) + "\n_(truncated)_"
156
+ : text;
157
+ await this.postEphemeralBlocks(conversationId, userId, text, [
158
+ { type: "context", elements: [{ type: "mrkdwn", text: blockText }] },
159
+ ]);
160
+ }
161
+ async openDirectMessage(userId) {
162
+ return slackRetry(async () => {
163
+ const result = await this.webClient.conversations.open({ users: userId });
164
+ const channelId = result.channel?.id;
165
+ if (!channelId) {
166
+ throw new Error(`Failed to open DM for user ${userId}`);
167
+ }
168
+ return channelId;
169
+ });
170
+ }
171
+ async updateMessage(channel, ts, text) {
172
+ return slackRetry(async () => {
173
+ await this.webClient.chat.update({ channel, ts, text });
148
174
  });
149
175
  }
150
176
  async deleteMessage(channel, ts) {
151
- return withRetry(async () => {
177
+ return slackRetry(async () => {
152
178
  await this.webClient.chat.delete({ channel, ts });
153
179
  });
154
180
  }
@@ -157,7 +183,7 @@ export class SlackBot {
157
183
  // ==========================================================================
158
184
  /** Set the status for an assistant thread (shows "thinking" state) */
159
185
  async setAssistantStatus(channel, threadTs, status) {
160
- return withRetry(async () => {
186
+ return slackRetry(async () => {
161
187
  await this.webClient.assistant.threads.setStatus({
162
188
  channel_id: channel,
163
189
  thread_ts: threadTs,
@@ -166,7 +192,7 @@ export class SlackBot {
166
192
  });
167
193
  }
168
194
  async postInThread(channel, threadTs, text) {
169
- return withRetry(async () => {
195
+ return slackRetry(async () => {
170
196
  // Use Block Kit section for long messages to trigger Slack's "Show more" collapsing (~700 chars)
171
197
  const SECTION_TEXT_LIMIT = 3000;
172
198
  if (text.length > 500) {
@@ -186,7 +212,7 @@ export class SlackBot {
186
212
  });
187
213
  }
188
214
  async postInThreadBlocks(channel, threadTs, text, blocks) {
189
- return withRetry(async () => {
215
+ return slackRetry(async () => {
190
216
  const result = await this.webClient.chat.postMessage({
191
217
  channel,
192
218
  thread_ts: threadTs,
@@ -197,7 +223,7 @@ export class SlackBot {
197
223
  });
198
224
  }
199
225
  async uploadFile(channel, filePath, title, threadTs) {
200
- return withRetry(async () => {
226
+ return slackRetry(async () => {
201
227
  const fileName = title || basename(filePath);
202
228
  const fileContent = readFileSync(filePath);
203
229
  await this.webClient.files.uploadV2({
@@ -209,29 +235,32 @@ export class SlackBot {
209
235
  });
210
236
  });
211
237
  }
212
- /**
213
- * Log a message to log.jsonl (SYNC)
214
- * This is the ONLY place messages are written to log.jsonl
215
- */
216
238
  logToFile(channel, entry) {
217
- const channelDir = join(this.workingDir, channel);
218
- if (!existsSync(channelDir))
219
- mkdirSync(channelDir, { recursive: true });
220
- appendFileSync(join(channelDir, "log.jsonl"), `${JSON.stringify(entry)}\n`);
239
+ appendChannelLog(this.workingDir, channel, entry);
221
240
  }
222
- /**
223
- * Log a bot response to log.jsonl
224
- */
225
241
  logBotResponse(channel, text, ts, threadTs) {
226
- this.logToFile(channel, {
227
- date: new Date().toISOString(),
228
- ts,
229
- threadTs,
230
- user: "bot",
231
- text,
232
- attachments: [],
233
- isBot: true,
234
- });
242
+ appendBotResponseLog(this.workingDir, channel, text, ts, threadTs);
243
+ }
244
+ aliasSyntheticEventThread(channel, threadTs, eventTs) {
245
+ const conversationDir = join(this.workingDir, channel);
246
+ const source = getThreadSessionFile(conversationDir, `${channel}:${eventTs}`);
247
+ const target = getThreadSessionFile(conversationDir, `${channel}:${threadTs}`);
248
+ if (source === target)
249
+ return;
250
+ try {
251
+ linkSync(source, target);
252
+ log.logInfo(`Aliased synthetic event session ${source} -> ${target}`);
253
+ }
254
+ catch (err) {
255
+ const code = err.code;
256
+ if (code === "EEXIST")
257
+ return;
258
+ if (code === "ENOENT") {
259
+ log.logWarning(`Cannot alias synthetic event session; source missing: ${source}`);
260
+ return;
261
+ }
262
+ log.logWarning(`Failed to alias synthetic event session ${source} -> ${target}`, err instanceof Error ? err.message : String(err));
263
+ }
235
264
  }
236
265
  getPlatformInfo() {
237
266
  return {
@@ -243,6 +272,9 @@ export class SlackBot {
243
272
  userName: u.userName,
244
273
  displayName: u.displayName,
245
274
  })),
275
+ diagnostics: {
276
+ showUsageSummary: true,
277
+ },
246
278
  };
247
279
  }
248
280
  // ==========================================================================
@@ -253,20 +285,27 @@ export class SlackBot {
253
285
  * Returns true if enqueued, false if queue is full (max 5).
254
286
  */
255
287
  enqueueEvent(event) {
256
- const queue = this.getQueue(event.conversationId);
288
+ const conversationId = event.conversationId;
289
+ const queue = this.getQueue(conversationId);
257
290
  if (queue.size() >= 5) {
258
- log.logWarning(`Event queue full for ${event.conversationId}, discarding: ${event.text.substring(0, 50)}`);
291
+ log.logWarning(`Event queue full for ${conversationId}, discarding: ${event.text.substring(0, 50)}`);
259
292
  return false;
260
293
  }
261
- log.logInfo(`Enqueueing event for ${event.conversationId}: ${event.text.substring(0, 50)}`);
294
+ log.logInfo(`Enqueueing event for ${conversationId}: ${event.text.substring(0, 50)}`);
262
295
  queue.enqueue(() => {
263
296
  const slackEvent = {
264
- type: "mention",
265
- channel: event.conversationId,
297
+ type: event.type,
298
+ conversationId,
299
+ conversationKind: event.conversationKind,
300
+ channel: conversationId,
266
301
  ts: event.ts,
267
302
  thread_ts: event.thread_ts,
268
303
  user: event.user,
269
304
  text: event.text,
305
+ attachments: event.attachments?.map((attachment) => ({
306
+ original: attachment.name,
307
+ localPath: attachment.localPath,
308
+ })),
270
309
  sessionKey: event.sessionKey,
271
310
  };
272
311
  const adapters = createSlackAdapters(slackEvent, this, true);
@@ -280,11 +319,28 @@ export class SlackBot {
280
319
  getQueue(channelId) {
281
320
  let queue = this.queues.get(channelId);
282
321
  if (!queue) {
283
- queue = new ChannelQueue();
322
+ queue = new ChannelQueue("Slack");
284
323
  this.queues.set(channelId, queue);
285
324
  }
286
325
  return queue;
287
326
  }
327
+ resolveQueueKey(conversationId, sessionKey) {
328
+ if (!sessionKey.includes(":"))
329
+ return sessionKey;
330
+ if (sessionKey.includes(":event:"))
331
+ return sessionKey;
332
+ return hasMaterializedSlackBranchSession(join(this.workingDir, conversationId), sessionKey)
333
+ ? sessionKey
334
+ : conversationId;
335
+ }
336
+ shouldTriggerSharedThreadReply(channelId, threadTs) {
337
+ if (!threadTs)
338
+ return false;
339
+ const sessionKey = resolveSlackSessionKey(channelId, threadTs);
340
+ if (this.handler.isRunning(sessionKey))
341
+ return true;
342
+ return hasMaterializedSlackBranchSession(join(this.workingDir, channelId), sessionKey);
343
+ }
288
344
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
289
345
  buildHomeView() {
290
346
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -387,21 +443,22 @@ export class SlackBot {
387
443
  });
388
444
  }
389
445
  else {
390
- const timestampFormatter = new Intl.DateTimeFormat(undefined, {
391
- month: "short",
392
- day: "numeric",
393
- hour: "2-digit",
394
- minute: "2-digit",
395
- });
396
446
  for (const ev of periodicEvents) {
397
447
  const channelLabel = ev.platform === "slack"
398
448
  ? (() => {
399
- const channel = this.channels.get(ev.channelId);
400
- const channelName = channel ? `#${channel.name}` : ev.channelId;
449
+ const channel = this.channels.get(ev.conversationId);
450
+ const channelName = channel ? `#${channel.name}` : ev.conversationId;
401
451
  return `${ev.platform}:${channelName}`;
402
452
  })()
403
- : `${ev.platform}:${ev.channelId}`;
404
- const nextStr = ev.nextRun ? timestampFormatter.format(new Date(ev.nextRun)) : "—";
453
+ : `${ev.platform}:${ev.conversationId}`;
454
+ const nextStr = ev.nextRun
455
+ ? new Date(ev.nextRun).toLocaleString("en-US", {
456
+ month: "short",
457
+ day: "numeric",
458
+ hour: "2-digit",
459
+ minute: "2-digit",
460
+ })
461
+ : "—";
405
462
  blocks.push({
406
463
  type: "section",
407
464
  text: {
@@ -420,25 +477,231 @@ export class SlackBot {
420
477
  });
421
478
  return { type: "home", blocks };
422
479
  }
423
- /**
424
- * Resolve which session key to stop.
425
- * When stop is called from a thread, the thread session (channelId:thread_ts) might
426
- * not be running — but the channel session (channelId) might be, because the bot's
427
- * reply to a top-level mention creates a thread. Check both, prefer thread first.
428
- */
429
480
  resolveStopTarget(channelId, threadTs) {
430
- if (threadTs) {
431
- const threadKey = `${channelId}:${threadTs}`;
432
- if (this.handler.isRunning(threadKey))
433
- return threadKey;
434
- // Fall back to channel session — the thread may have been spawned by a top-level run
435
- if (this.handler.isRunning(channelId))
436
- return channelId;
481
+ const directTarget = resolveStopTarget({
482
+ handler: this.handler,
483
+ conversationId: channelId,
484
+ sessionKey: resolveSlackSessionKey(channelId, threadTs),
485
+ });
486
+ if (directTarget)
487
+ return directTarget;
488
+ if (threadTs)
437
489
  return null;
490
+ return resolveOnlyScopedStopTarget(this.handler, channelId);
491
+ }
492
+ isStopText(text) {
493
+ const normalized = text.trim().toLowerCase();
494
+ return normalized === "stop" || normalized === "/stop";
495
+ }
496
+ createCommandAdapters(conversationId, userId, userName, text, ts, options = {}) {
497
+ const message = {
498
+ id: ts,
499
+ sessionKey: conversationId,
500
+ conversationKind: options.ephemeralChannelId ? "shared" : "direct",
501
+ userId,
502
+ userName,
503
+ text,
504
+ attachments: [],
505
+ };
506
+ const respond = async (responseText) => {
507
+ if (options.ephemeralChannelId) {
508
+ await this.postEphemeral(options.ephemeralChannelId, userId, responseText);
509
+ return;
510
+ }
511
+ const messageTs = await this.postMessage(conversationId, responseText);
512
+ this.logBotResponse(conversationId, responseText, messageTs);
513
+ };
514
+ const respondMuted = async (responseText) => {
515
+ const CONTEXT_TEXT_LIMIT = 3000;
516
+ const blockText = responseText.length > CONTEXT_TEXT_LIMIT
517
+ ? responseText.substring(0, CONTEXT_TEXT_LIMIT - 20) + "\n_(truncated)_"
518
+ : responseText;
519
+ const blocks = [{ type: "context", elements: [{ type: "mrkdwn", text: blockText }] }];
520
+ if (options.ephemeralChannelId) {
521
+ await this.postEphemeralBlocks(options.ephemeralChannelId, userId, responseText, blocks);
522
+ return;
523
+ }
524
+ const messageTs = await this.postMessageBlocks(conversationId, responseText, blocks);
525
+ this.logBotResponse(conversationId, responseText, messageTs);
526
+ };
527
+ const responseCtx = {
528
+ respond,
529
+ replaceResponse: respond,
530
+ respondDiagnostic: async (responseText, responseOptions) => {
531
+ if (responseOptions?.style === "muted") {
532
+ await respondMuted(responseText);
533
+ return;
534
+ }
535
+ await respond(responseOptions?.style === "error" ? `_${responseText}_` : responseText);
536
+ },
537
+ respondToolResult: async (result) => {
538
+ const duration = (result.durationMs / 1000).toFixed(1);
539
+ await respond(`${result.isError ? "Error" : "Done"} ${result.toolName} (${duration}s)\n${result.result}`);
540
+ },
541
+ setTyping: async () => { },
542
+ setWorking: async () => { },
543
+ uploadFile: async (filePath, title) => {
544
+ await this.uploadFile(conversationId, filePath, title);
545
+ },
546
+ deleteResponse: async () => { },
547
+ };
548
+ return {
549
+ message,
550
+ responseCtx,
551
+ platform: this.getPlatformInfo(),
552
+ };
553
+ }
554
+ createSlashCommandBot(conversationId, threadTs) {
555
+ return {
556
+ start: async () => { },
557
+ postMessage: async (_channel, text) => {
558
+ if (threadTs) {
559
+ return this.postInThread(conversationId, threadTs, text);
560
+ }
561
+ return this.postMessage(conversationId, text);
562
+ },
563
+ updateMessage: async (channel, ts, text) => {
564
+ await this.updateMessage(channel, ts, text);
565
+ },
566
+ enqueueEvent: (event) => this.enqueueEvent(event),
567
+ getPlatformInfo: () => this.getPlatformInfo(),
568
+ };
569
+ }
570
+ async routeSlashLoginCommand(payload) {
571
+ const commandSuffix = payload.text?.trim();
572
+ const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;
573
+ const createdAt = new Date();
574
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
575
+ const sourceChannelId = payload.channel_id;
576
+ const isDirectMessage = sourceChannelId.startsWith("D");
577
+ const targetChannelId = isDirectMessage
578
+ ? sourceChannelId
579
+ : await this.openDirectMessage(payload.user_id);
580
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
581
+ this.logToFile(targetChannelId, {
582
+ date: createdAt.toISOString(),
583
+ ts: eventTs,
584
+ user: payload.user_id,
585
+ userName,
586
+ text: commandText,
587
+ attachments: [],
588
+ isBot: false,
589
+ });
590
+ if (!isDirectMessage) {
591
+ await this.postEphemeral(sourceChannelId, payload.user_id, `我已私訊你 ${PRODUCT_NAME} 的登入連結,請到私訊完成設定。`);
438
592
  }
439
- return this.handler.isRunning(channelId) ? channelId : null;
593
+ const event = {
594
+ type: "dm",
595
+ conversationId: targetChannelId,
596
+ ...(isDirectMessage ? {} : { vaultConversationId: sourceChannelId }),
597
+ conversationKind: "direct",
598
+ ts: eventTs,
599
+ user: payload.user_id,
600
+ text: commandText,
601
+ attachments: [],
602
+ sessionKey: targetChannelId,
603
+ };
604
+ const adapters = this.createCommandAdapters(targetChannelId, payload.user_id, userName, commandText, eventTs);
605
+ await this.handler.handleEvent(event, this, adapters, false);
606
+ }
607
+ async routeSlashNewCommand(payload) {
608
+ const conversationId = payload.channel_id;
609
+ if (!conversationId.startsWith("D")) {
610
+ await this.postEphemeral(conversationId, payload.user_id, `為了避免誤清除共享上下文,${payload.command} 目前只能在與 ${PRODUCT_NAME} 的私訊中使用。`);
611
+ return;
612
+ }
613
+ const createdAt = new Date();
614
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
615
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
616
+ this.logToFile(conversationId, {
617
+ date: createdAt.toISOString(),
618
+ ts: eventTs,
619
+ user: payload.user_id,
620
+ userName,
621
+ text: payload.command,
622
+ attachments: [],
623
+ isBot: false,
624
+ });
625
+ const commandBot = this.createSlashCommandBot(conversationId);
626
+ await this.handler.handleNewCommand(conversationId, conversationId, commandBot);
627
+ }
628
+ async routeSlashModelCommand(payload) {
629
+ const conversationId = payload.channel_id;
630
+ const isDirectMessage = conversationId.startsWith("D");
631
+ const createdAt = new Date();
632
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
633
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
634
+ const commandSuffix = payload.text?.trim();
635
+ const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;
636
+ this.logToFile(conversationId, {
637
+ date: createdAt.toISOString(),
638
+ ts: eventTs,
639
+ user: payload.user_id,
640
+ userName,
641
+ text: commandText,
642
+ attachments: [],
643
+ isBot: false,
644
+ });
645
+ const sessionKey = conversationId;
646
+ const event = {
647
+ type: isDirectMessage ? "dm" : "mention",
648
+ conversationId,
649
+ conversationKind: isDirectMessage ? "direct" : "shared",
650
+ ts: eventTs,
651
+ user: payload.user_id,
652
+ text: commandText,
653
+ attachments: [],
654
+ sessionKey,
655
+ };
656
+ const adapters = this.createCommandAdapters(conversationId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: conversationId });
657
+ await this.handler.handleEvent(event, this, adapters, false);
658
+ }
659
+ async routeSlashSandboxCommand(payload) {
660
+ await this.routeSlashModelCommand(payload);
661
+ }
662
+ async routeSlashAutoReplyCommand(payload) {
663
+ await this.routeSlashModelCommand(payload);
664
+ }
665
+ async routeSlashSessionCommand(payload) {
666
+ const conversationId = payload.channel_id;
667
+ const isDirectMessage = conversationId.startsWith("D");
668
+ const createdAt = new Date();
669
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
670
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
671
+ const commandText = payload.command;
672
+ this.logToFile(conversationId, {
673
+ date: createdAt.toISOString(),
674
+ ts: eventTs,
675
+ user: payload.user_id,
676
+ userName,
677
+ text: commandText,
678
+ attachments: [],
679
+ isBot: false,
680
+ });
681
+ const sessionKey = conversationId;
682
+ const event = {
683
+ type: isDirectMessage ? "dm" : "mention",
684
+ conversationId,
685
+ conversationKind: isDirectMessage ? "direct" : "shared",
686
+ ts: eventTs,
687
+ user: payload.user_id,
688
+ text: commandText,
689
+ attachments: [],
690
+ sessionKey,
691
+ };
692
+ const adapters = this.createCommandAdapters(conversationId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: conversationId });
693
+ await this.handler.handleEvent(event, this, adapters, false);
440
694
  }
441
695
  setupEventHandlers() {
696
+ this.socketClient.on("disconnect", (err) => {
697
+ log.logWarning("Slack socket disconnect", err ? String(err) : "");
698
+ });
699
+ this.socketClient.on("error", (err) => {
700
+ log.logWarning("Slack socket error", err ? String(err) : "");
701
+ });
702
+ this.socketClient.on("unable_to_socket_mode_start", (err) => {
703
+ log.logWarning("Slack socket unable_to_start", err ? String(err) : "");
704
+ });
442
705
  // Channel @mentions
443
706
  this.socketClient.on("app_mention", ({ event, ack }) => {
444
707
  const e = event;
@@ -449,28 +712,31 @@ export class SlackBot {
449
712
  }
450
713
  // Top-level mentions use a persistent channel session.
451
714
  // Thread replies get their own isolated session (channelId:thread_ts).
452
- const sessionKey = e.thread_ts ? `${e.channel}:${e.thread_ts}` : e.channel;
715
+ const sessionKey = resolveSlackSessionKey(e.channel, e.thread_ts);
453
716
  const slackEvent = {
454
717
  type: "mention",
718
+ conversationId: e.channel,
719
+ conversationKind: "shared",
455
720
  channel: e.channel,
456
721
  ts: e.ts,
457
722
  thread_ts: e.thread_ts,
458
723
  user: e.user,
459
- text: e.text.replace(/<@[A-Z0-9]+>/gi, "").trim(),
724
+ text: this.stripOwnMention(e.text),
460
725
  files: e.files,
461
726
  sessionKey,
462
727
  };
463
- // SYNC: Log to log.jsonl (ALWAYS, even for old messages)
464
- // Also downloads attachments in background and stores local paths
465
- slackEvent.attachments = this.logUserMessage(slackEvent);
728
+ const attachmentsPromise = this.logUserMessage(slackEvent);
466
729
  // Only trigger processing for messages AFTER startup (not replayed old messages)
467
730
  if (this.startupTs && e.ts < this.startupTs) {
468
731
  log.logInfo(`[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`);
732
+ void attachmentsPromise.catch((err) => {
733
+ log.logWarning("Failed to log Slack message", String(err));
734
+ });
469
735
  ack();
470
736
  return;
471
737
  }
472
738
  // Check for stop command - execute immediately, don't queue!
473
- if (slackEvent.text.toLowerCase().trim() === "stop") {
739
+ if (this.isStopText(slackEvent.text)) {
474
740
  const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);
475
741
  if (stopTarget) {
476
742
  this.handler.handleStop(stopTarget, e.channel, this);
@@ -478,32 +744,46 @@ export class SlackBot {
478
744
  else {
479
745
  this.postMessage(e.channel, formatNothingRunning("slack"));
480
746
  }
747
+ void attachmentsPromise.catch((err) => {
748
+ log.logWarning("Failed to log Slack message", String(err));
749
+ });
481
750
  ack();
482
751
  return;
483
752
  }
484
- // Check for login command
485
- if (parseLoginCommand(slackEvent.text)) {
486
- void this.handler.handleLogin("slack", e.user, e.channel, this, slackEvent.text, false);
487
- ack();
488
- return;
489
- }
490
- // SYNC: Check if busy (per-thread)
491
- if (this.handler.isRunning(sessionKey)) {
492
- this.postMessage(e.channel, formatAlreadyWorking("slack", "@mama stop", { scope: "thread" }));
493
- }
494
- else {
495
- this.getQueue(sessionKey).enqueue(() => {
496
- const adapters = createSlackAdapters(slackEvent, this, false);
497
- return this.handler.handleEvent(this.toBotEvent(slackEvent), this, adapters, false);
498
- });
499
- }
753
+ this.getQueue(this.resolveQueueKey(e.channel, sessionKey)).enqueue(async () => {
754
+ slackEvent.attachments = await attachmentsPromise;
755
+ const adapters = createSlackAdapters(slackEvent, this, false);
756
+ return this.handler.handleEvent(slackEvent, this, adapters, false);
757
+ });
500
758
  ack();
501
759
  });
502
760
  // All messages (for logging) + DMs (for triggering)
503
761
  this.socketClient.on("message", ({ event, ack }) => {
504
762
  const e = event;
505
- // Skip bot messages, edits, etc.
506
- if (e.bot_id || !e.user || e.user === this.botUserId) {
763
+ const hasFiles = !!e.files && e.files.length > 0;
764
+ const hasSlackContent = !!e.text || hasFiles || !!e.blocks?.length || !!e.attachments?.length;
765
+ const isOwnBotMessage = (!!e.user && e.user === this.botUserId) || (!!this.botId && e.bot_id === this.botId);
766
+ if (isOwnBotMessage) {
767
+ ack();
768
+ return;
769
+ }
770
+ const isExternalBotMessage = !!e.bot_id || e.subtype === "bot_message";
771
+ if (isExternalBotMessage) {
772
+ if (e.subtype !== undefined && e.subtype !== "bot_message" && e.subtype !== "file_share") {
773
+ ack();
774
+ return;
775
+ }
776
+ if (!hasSlackContent) {
777
+ ack();
778
+ return;
779
+ }
780
+ void this.logExternalBotMessage(e).catch((err) => {
781
+ log.logWarning("Failed to log Slack bot message", String(err));
782
+ });
783
+ ack();
784
+ return;
785
+ }
786
+ if (!e.user) {
507
787
  ack();
508
788
  return;
509
789
  }
@@ -511,39 +791,44 @@ export class SlackBot {
511
791
  ack();
512
792
  return;
513
793
  }
514
- if (!e.text && (!e.files || e.files.length === 0)) {
794
+ if (!hasSlackContent) {
515
795
  ack();
516
796
  return;
517
797
  }
518
798
  const isDM = e.channel_type === "im";
799
+ const conversationKind = isDM ? "direct" : "shared";
519
800
  const isBotMention = e.text?.includes(`<@${this.botUserId}>`);
520
801
  // Skip channel @mentions - already handled by app_mention event
521
802
  if (!isDM && isBotMention) {
522
803
  ack();
523
804
  return;
524
805
  }
806
+ const isSharedThreadReply = !isDM && this.shouldTriggerSharedThreadReply(e.channel, e.thread_ts);
807
+ const sessionKey = isDM || isSharedThreadReply ? resolveSlackSessionKey(e.channel, e.thread_ts) : undefined;
525
808
  const slackEvent = {
526
809
  type: isDM ? "dm" : "mention",
810
+ conversationId: e.channel,
811
+ conversationKind,
527
812
  channel: e.channel,
528
813
  ts: e.ts,
529
814
  thread_ts: e.thread_ts,
530
815
  user: e.user,
531
- text: (e.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim(),
816
+ text: this.stripOwnMention(e.text),
532
817
  files: e.files,
533
- sessionKey: isDM ? e.channel : undefined,
818
+ sessionKey,
534
819
  };
535
- // SYNC: Log to log.jsonl (ALL messages - channel chatter and DMs)
536
- // Also downloads attachments in background and stores local paths
537
- slackEvent.attachments = this.logUserMessage(slackEvent);
820
+ const attachmentsPromise = this.logUserMessage(slackEvent);
538
821
  // Only trigger processing for messages AFTER startup (not replayed old messages)
539
822
  if (this.startupTs && e.ts < this.startupTs) {
540
823
  log.logInfo(`[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`);
824
+ void attachmentsPromise.catch((err) => {
825
+ log.logWarning("Failed to log Slack message", String(err));
826
+ });
541
827
  ack();
542
828
  return;
543
829
  }
544
- // Check for stop command in channel threads (without @mention)
545
- // app_mention handles "@mama stop", but bare "stop" in a thread comes here
546
- if (!isDM && e.thread_ts && slackEvent.text.toLowerCase().trim() === "stop") {
830
+ // Stop command for DM or shared-channel thread reply (app_mention handles "@mama stop").
831
+ if ((isDM || (!isDM && e.thread_ts)) && this.isStopText(slackEvent.text)) {
547
832
  const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);
548
833
  if (stopTarget) {
549
834
  this.handler.handleStop(stopTarget, e.channel, this);
@@ -551,41 +836,105 @@ export class SlackBot {
551
836
  else {
552
837
  this.postMessage(e.channel, formatNothingRunning("slack"));
553
838
  }
839
+ void attachmentsPromise.catch((err) => {
840
+ log.logWarning("Failed to log Slack message", String(err));
841
+ });
554
842
  ack();
555
843
  return;
556
844
  }
557
- // Only trigger handler for DMs
558
- if (isDM) {
559
- const dmSessionKey = slackEvent.sessionKey;
560
- // Check for stop command - execute immediately, don't queue!
561
- if (slackEvent.text.toLowerCase().trim() === "stop") {
562
- if (this.handler.isRunning(dmSessionKey)) {
563
- this.handler.handleStop(dmSessionKey, e.channel, this); // Don't await, don't queue
564
- }
565
- else {
566
- this.postMessage(e.channel, formatNothingRunning("slack"));
567
- }
568
- ack();
569
- return;
570
- }
571
- // Check for login command
572
- if (parseLoginCommand(slackEvent.text)) {
573
- void this.handler.handleLogin("slack", e.user, e.channel, this, slackEvent.text, true);
574
- ack();
575
- return;
576
- }
577
- if (this.handler.isRunning(dmSessionKey)) {
578
- this.postMessage(e.channel, formatAlreadyWorking("slack", "stop"));
579
- }
580
- else {
581
- this.getQueue(dmSessionKey).enqueue(() => {
582
- const adapters = createSlackAdapters(slackEvent, this, false);
583
- return this.handler.handleEvent(this.toBotEvent(slackEvent), this, adapters, false);
584
- });
585
- }
845
+ const enqueueTriggered = () => {
846
+ const activeSessionKey = slackEvent.sessionKey ?? resolveSlackSessionKey(e.channel, e.thread_ts);
847
+ this.getQueue(this.resolveQueueKey(e.channel, activeSessionKey)).enqueue(async () => {
848
+ slackEvent.attachments = await attachmentsPromise;
849
+ const adapters = createSlackAdapters(slackEvent, this, false);
850
+ return this.handler.handleEvent(slackEvent, this, adapters, false);
851
+ });
852
+ };
853
+ const logOnly = () => {
854
+ void attachmentsPromise.catch((err) => {
855
+ log.logWarning("Failed to log Slack message", String(err));
856
+ });
857
+ };
858
+ if (isDM || isSharedThreadReply) {
859
+ enqueueTriggered();
860
+ ack();
861
+ return;
586
862
  }
863
+ // Shared-channel non-mention, non-thread: gate via auto-reply policy.
864
+ // evaluateAutoReplyPolicy never throws — judge errors/timeouts surface as
865
+ // trigger:false with a distinct reason, and the user message has already
866
+ // been queued for logging via logUserMessage above.
867
+ evaluateAutoReplyPolicy({
868
+ event: slackEvent,
869
+ workingDir: this.workingDir,
870
+ }).then((triggerResult) => {
871
+ if (triggerResult.trigger)
872
+ enqueueTriggered();
873
+ else
874
+ logOnly();
875
+ });
587
876
  ack();
588
877
  });
878
+ this.socketClient.on("slash_commands", async ({ body, ack }) => {
879
+ const payload = body;
880
+ await ack();
881
+ if (!payload.command || !payload.channel_id || !payload.user_id) {
882
+ return;
883
+ }
884
+ const handlerPromise = payload.command === "/pi-login"
885
+ ? this.routeSlashLoginCommand({
886
+ command: payload.command,
887
+ text: payload.text,
888
+ channel_id: payload.channel_id,
889
+ user_id: payload.user_id,
890
+ user_name: payload.user_name,
891
+ })
892
+ : payload.command === "/pi-new"
893
+ ? this.routeSlashNewCommand({
894
+ command: payload.command,
895
+ channel_id: payload.channel_id,
896
+ user_id: payload.user_id,
897
+ user_name: payload.user_name,
898
+ })
899
+ : payload.command === "/pi-session"
900
+ ? this.routeSlashSessionCommand({
901
+ command: payload.command,
902
+ channel_id: payload.channel_id,
903
+ user_id: payload.user_id,
904
+ user_name: payload.user_name,
905
+ })
906
+ : payload.command === "/pi-model"
907
+ ? this.routeSlashModelCommand({
908
+ command: payload.command,
909
+ text: payload.text,
910
+ channel_id: payload.channel_id,
911
+ user_id: payload.user_id,
912
+ user_name: payload.user_name,
913
+ })
914
+ : payload.command === "/pi-sandbox"
915
+ ? this.routeSlashSandboxCommand({
916
+ command: payload.command,
917
+ text: payload.text,
918
+ channel_id: payload.channel_id,
919
+ user_id: payload.user_id,
920
+ user_name: payload.user_name,
921
+ })
922
+ : payload.command === "/pi-auto-reply"
923
+ ? this.routeSlashAutoReplyCommand({
924
+ command: payload.command,
925
+ text: payload.text,
926
+ channel_id: payload.channel_id,
927
+ user_id: payload.user_id,
928
+ user_name: payload.user_name,
929
+ })
930
+ : null;
931
+ if (!handlerPromise) {
932
+ return;
933
+ }
934
+ handlerPromise.catch((err) => {
935
+ log.logWarning("Slack slash command error", err instanceof Error ? err.message : String(err));
936
+ });
937
+ });
589
938
  // App Home tab
590
939
  this.socketClient.on("app_home_opened", ({ event, ack }) => {
591
940
  const e = event;
@@ -616,8 +965,7 @@ export class SlackBot {
616
965
  // Use handler's forceStop method
617
966
  this.handler.forceStop(sessionKey);
618
967
  // Notify in channel
619
- const actorLabel = userId ? `<@${userId}>` : "someone";
620
- await this.postMessage(channelId, formatForceStopped("slack", actorLabel));
968
+ await this.postMessage(channelId, formatForceStopped("slack", userId ?? "unknown"));
621
969
  // Refresh home tab
622
970
  if (userId) {
623
971
  this.webClient.views
@@ -632,15 +980,22 @@ export class SlackBot {
632
980
  });
633
981
  }
634
982
  /**
635
- * Log a user message to log.jsonl (SYNC)
636
- * Downloads attachments in background via store
983
+ * Log a user message to log.jsonl after attachments are ready.
637
984
  */
638
- logUserMessage(event) {
985
+ async logUserMessage(event) {
639
986
  const user = this.users.get(event.user);
640
- // Process attachments - queues downloads in background
641
- const attachments = event.files
642
- ? this.store.processAttachments(event.channel, event.files, event.ts)
643
- : [];
987
+ let attachments = [];
988
+ let attachmentError;
989
+ if (event.files) {
990
+ try {
991
+ attachments = await this.store.processAttachments(event.channel, event.files, event.ts);
992
+ }
993
+ catch (err) {
994
+ attachmentError = err;
995
+ }
996
+ }
997
+ // Always write the text log, even if attachment processing failed — we want
998
+ // a record of the user message regardless of file-handling errors.
644
999
  this.logToFile(event.channel, {
645
1000
  date: new Date(parseFloat(event.ts) * 1000).toISOString(),
646
1001
  ts: event.ts,
@@ -652,6 +1007,29 @@ export class SlackBot {
652
1007
  attachments,
653
1008
  isBot: false,
654
1009
  });
1010
+ if (attachmentError)
1011
+ throw attachmentError;
1012
+ return attachments;
1013
+ }
1014
+ async logExternalBotMessage(event) {
1015
+ const attachments = event.files
1016
+ ? await this.store.processAttachments(event.channel, event.files, event.ts)
1017
+ : [];
1018
+ const botName = event.username ?? event.bot_profile?.name ?? event.bot_profile?.real_name ?? event.bot_id;
1019
+ this.logToFile(event.channel, {
1020
+ date: new Date(parseFloat(event.ts) * 1000).toISOString(),
1021
+ ts: event.ts,
1022
+ threadTs: event.thread_ts,
1023
+ user: event.bot_id ? `bot:${event.bot_id}` : "external-bot",
1024
+ userName: botName,
1025
+ displayName: botName,
1026
+ text: buildSlackAppMessageText(event),
1027
+ attachments,
1028
+ isBot: true,
1029
+ botId: event.bot_id,
1030
+ appId: event.app_id ?? event.bot_profile?.app_id,
1031
+ subtype: event.subtype,
1032
+ });
655
1033
  return attachments;
656
1034
  }
657
1035
  // ==========================================================================
@@ -664,13 +1042,15 @@ export class SlackBot {
664
1042
  return timestamps;
665
1043
  const content = await readFile(logPath, "utf-8");
666
1044
  const lines = content.trim().split("\n").filter(Boolean);
667
- for (const line of lines) {
1045
+ for (let i = 0; i < lines.length; i++) {
668
1046
  try {
669
- const entry = JSON.parse(line);
1047
+ const entry = JSON.parse(lines[i]);
670
1048
  if (entry.ts)
671
1049
  timestamps.add(entry.ts);
672
1050
  }
673
- catch { }
1051
+ catch (err) {
1052
+ log.logWarning(`Skipping malformed log entry at ${logPath}:${i + 1}`, err instanceof Error ? err.message : String(err));
1053
+ }
674
1054
  }
675
1055
  return timestamps;
676
1056
  }
@@ -700,14 +1080,26 @@ export class SlackBot {
700
1080
  cursor = result.response_metadata?.next_cursor;
701
1081
  pageCount++;
702
1082
  } while (cursor && pageCount < maxPages);
703
- // Filter: include mama's messages, exclude other bots, skip already logged
1083
+ // Filter: include mama's messages, external app/bot messages, and user messages.
704
1084
  const relevantMessages = allMessages.filter((msg) => {
705
1085
  if (!msg.ts || existingTs.has(msg.ts))
706
1086
  return false; // Skip duplicates
707
1087
  if (msg.user === this.botUserId)
708
1088
  return true;
709
- if (msg.bot_id)
710
- return false;
1089
+ const isExternalBotMessage = !!msg.bot_id || msg.subtype === "bot_message";
1090
+ if (isExternalBotMessage) {
1091
+ if (this.botId && msg.bot_id === this.botId)
1092
+ return false;
1093
+ if (msg.subtype !== undefined &&
1094
+ msg.subtype !== "bot_message" &&
1095
+ msg.subtype !== "file_share") {
1096
+ return false;
1097
+ }
1098
+ return (!!msg.text ||
1099
+ !!(msg.files && msg.files.length > 0) ||
1100
+ !!msg.blocks?.length ||
1101
+ !!msg.attachments?.length);
1102
+ }
711
1103
  if (msg.subtype !== undefined && msg.subtype !== "file_share")
712
1104
  return false;
713
1105
  if (!msg.user)
@@ -721,16 +1113,20 @@ export class SlackBot {
721
1113
  // Log each message to log.jsonl
722
1114
  for (const msg of relevantMessages) {
723
1115
  const isMamaMessage = msg.user === this.botUserId;
1116
+ const isExternalBotMessage = !isMamaMessage && (!!msg.bot_id || msg.subtype === "bot_message");
1117
+ if (isExternalBotMessage) {
1118
+ await this.logExternalBotMessage({ ...msg, channel: channelId, ts: msg.ts });
1119
+ continue;
1120
+ }
724
1121
  const user = this.users.get(msg.user);
725
- // Strip @mentions from text (same as live messages)
726
- const text = (msg.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim();
727
- // Process attachments - queues downloads in background
1122
+ const text = this.stripOwnMention(msg.text);
728
1123
  const attachments = msg.files
729
- ? this.store.processAttachments(channelId, msg.files, msg.ts)
1124
+ ? await this.store.processAttachments(channelId, msg.files, msg.ts)
730
1125
  : [];
731
1126
  this.logToFile(channelId, {
732
1127
  date: new Date(parseFloat(msg.ts) * 1000).toISOString(),
733
1128
  ts: msg.ts,
1129
+ threadTs: msg.thread_ts,
734
1130
  user: isMamaMessage ? "bot" : msg.user,
735
1131
  userName: isMamaMessage ? undefined : user?.userName,
736
1132
  displayName: isMamaMessage ? undefined : user?.displayName,