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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/README.md +171 -334
  2. package/dist/adapter.d.ts +36 -10
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapter.js.map +1 -1
  5. package/dist/adapters/discord/bot.d.ts +10 -5
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +349 -114
  8. package/dist/adapters/discord/bot.js.map +1 -1
  9. package/dist/adapters/discord/context.d.ts +1 -1
  10. package/dist/adapters/discord/context.d.ts.map +1 -1
  11. package/dist/adapters/discord/context.js +102 -31
  12. package/dist/adapters/discord/context.js.map +1 -1
  13. package/dist/adapters/shared.d.ts +71 -0
  14. package/dist/adapters/shared.d.ts.map +1 -0
  15. package/dist/adapters/shared.js +168 -0
  16. package/dist/adapters/shared.js.map +1 -0
  17. package/dist/adapters/slack/bot.d.ts +29 -22
  18. package/dist/adapters/slack/bot.d.ts.map +1 -1
  19. package/dist/adapters/slack/bot.js +620 -186
  20. package/dist/adapters/slack/bot.js.map +1 -1
  21. package/dist/adapters/slack/branch-manager.d.ts +22 -0
  22. package/dist/adapters/slack/branch-manager.d.ts.map +1 -0
  23. package/dist/adapters/slack/branch-manager.js +97 -0
  24. package/dist/adapters/slack/branch-manager.js.map +1 -0
  25. package/dist/adapters/slack/context.d.ts +1 -1
  26. package/dist/adapters/slack/context.d.ts.map +1 -1
  27. package/dist/adapters/slack/context.js +136 -71
  28. package/dist/adapters/slack/context.js.map +1 -1
  29. package/dist/adapters/slack/session.d.ts +3 -0
  30. package/dist/adapters/slack/session.d.ts.map +1 -0
  31. package/dist/adapters/slack/session.js +16 -0
  32. package/dist/adapters/slack/session.js.map +1 -0
  33. package/dist/adapters/slack/tools/attach.d.ts +1 -1
  34. package/dist/adapters/slack/tools/attach.d.ts.map +1 -1
  35. package/dist/adapters/slack/tools/attach.js.map +1 -1
  36. package/dist/adapters/telegram/bot.d.ts +2 -0
  37. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  38. package/dist/adapters/telegram/bot.js +190 -123
  39. package/dist/adapters/telegram/bot.js.map +1 -1
  40. package/dist/adapters/telegram/context.d.ts.map +1 -1
  41. package/dist/adapters/telegram/context.js +57 -59
  42. package/dist/adapters/telegram/context.js.map +1 -1
  43. package/dist/adapters/telegram/html.d.ts +3 -0
  44. package/dist/adapters/telegram/html.d.ts.map +1 -0
  45. package/dist/adapters/telegram/html.js +98 -0
  46. package/dist/adapters/telegram/html.js.map +1 -0
  47. package/dist/agent.d.ts +9 -10
  48. package/dist/agent.d.ts.map +1 -1
  49. package/dist/agent.js +645 -555
  50. package/dist/agent.js.map +1 -1
  51. package/dist/commands/auto-reply.d.ts +16 -0
  52. package/dist/commands/auto-reply.d.ts.map +1 -0
  53. package/dist/commands/auto-reply.js +69 -0
  54. package/dist/commands/auto-reply.js.map +1 -0
  55. package/dist/commands/index.d.ts +5 -0
  56. package/dist/commands/index.d.ts.map +1 -0
  57. package/dist/commands/index.js +19 -0
  58. package/dist/commands/index.js.map +1 -0
  59. package/dist/commands/login.d.ts +5 -0
  60. package/dist/commands/login.d.ts.map +1 -0
  61. package/dist/commands/login.js +76 -0
  62. package/dist/commands/login.js.map +1 -0
  63. package/dist/commands/model.d.ts +14 -0
  64. package/dist/commands/model.d.ts.map +1 -0
  65. package/dist/commands/model.js +112 -0
  66. package/dist/commands/model.js.map +1 -0
  67. package/dist/commands/new.d.ts +9 -0
  68. package/dist/commands/new.d.ts.map +1 -0
  69. package/dist/commands/new.js +28 -0
  70. package/dist/commands/new.js.map +1 -0
  71. package/dist/commands/registry.d.ts +7 -0
  72. package/dist/commands/registry.d.ts.map +1 -0
  73. package/dist/commands/registry.js +14 -0
  74. package/dist/commands/registry.js.map +1 -0
  75. package/dist/commands/sandbox.d.ts +10 -0
  76. package/dist/commands/sandbox.d.ts.map +1 -0
  77. package/dist/commands/sandbox.js +88 -0
  78. package/dist/commands/sandbox.js.map +1 -0
  79. package/dist/commands/session-view.d.ts +5 -0
  80. package/dist/commands/session-view.d.ts.map +1 -0
  81. package/dist/commands/session-view.js +62 -0
  82. package/dist/commands/session-view.js.map +1 -0
  83. package/dist/commands/types.d.ts +41 -0
  84. package/dist/commands/types.d.ts.map +1 -0
  85. package/dist/commands/types.js +2 -0
  86. package/dist/commands/types.js.map +1 -0
  87. package/dist/commands/utils.d.ts +8 -0
  88. package/dist/commands/utils.d.ts.map +1 -0
  89. package/dist/commands/utils.js +14 -0
  90. package/dist/commands/utils.js.map +1 -0
  91. package/dist/config.d.ts +53 -7
  92. package/dist/config.d.ts.map +1 -1
  93. package/dist/config.js +320 -55
  94. package/dist/config.js.map +1 -1
  95. package/dist/context.d.ts +10 -42
  96. package/dist/context.d.ts.map +1 -1
  97. package/dist/context.js +15 -128
  98. package/dist/context.js.map +1 -1
  99. package/dist/events.d.ts +16 -5
  100. package/dist/events.d.ts.map +1 -1
  101. package/dist/events.js +127 -58
  102. package/dist/events.js.map +1 -1
  103. package/dist/execution-resolver.d.ts +24 -0
  104. package/dist/execution-resolver.d.ts.map +1 -0
  105. package/dist/execution-resolver.js +115 -0
  106. package/dist/execution-resolver.js.map +1 -0
  107. package/dist/file-guards.d.ts +6 -0
  108. package/dist/file-guards.d.ts.map +1 -0
  109. package/dist/file-guards.js +48 -0
  110. package/dist/file-guards.js.map +1 -0
  111. package/dist/fs-atomic.d.ts +10 -0
  112. package/dist/fs-atomic.d.ts.map +1 -0
  113. package/dist/fs-atomic.js +45 -0
  114. package/dist/fs-atomic.js.map +1 -0
  115. package/dist/index.d.ts +7 -0
  116. package/dist/index.d.ts.map +1 -0
  117. package/dist/index.js +4 -0
  118. package/dist/index.js.map +1 -0
  119. package/dist/instrument.d.ts.map +1 -1
  120. package/dist/instrument.js +3 -3
  121. package/dist/instrument.js.map +1 -1
  122. package/dist/log.d.ts +3 -7
  123. package/dist/log.d.ts.map +1 -1
  124. package/dist/log.js +20 -45
  125. package/dist/log.js.map +1 -1
  126. package/dist/login/index.d.ts +41 -0
  127. package/dist/login/index.d.ts.map +1 -0
  128. package/dist/login/index.js +202 -0
  129. package/dist/login/index.js.map +1 -0
  130. package/dist/login/portal.d.ts +19 -0
  131. package/dist/login/portal.d.ts.map +1 -0
  132. package/dist/login/portal.js +1453 -0
  133. package/dist/login/portal.js.map +1 -0
  134. package/dist/login/session.d.ts +33 -0
  135. package/dist/login/session.d.ts.map +1 -0
  136. package/dist/login/session.js +68 -0
  137. package/dist/login/session.js.map +1 -0
  138. package/dist/main.d.ts.map +1 -1
  139. package/dist/main.js +229 -264
  140. package/dist/main.js.map +1 -1
  141. package/dist/provisioner.d.ts +79 -0
  142. package/dist/provisioner.d.ts.map +1 -0
  143. package/dist/provisioner.js +437 -0
  144. package/dist/provisioner.js.map +1 -0
  145. package/dist/runtime/conversation-orchestrator.d.ts +42 -0
  146. package/dist/runtime/conversation-orchestrator.d.ts.map +1 -0
  147. package/dist/runtime/conversation-orchestrator.js +150 -0
  148. package/dist/runtime/conversation-orchestrator.js.map +1 -0
  149. package/dist/runtime/index.d.ts +2 -0
  150. package/dist/runtime/index.d.ts.map +1 -0
  151. package/dist/runtime/index.js +2 -0
  152. package/dist/runtime/index.js.map +1 -0
  153. package/dist/runtime/session-runtime.d.ts +27 -0
  154. package/dist/runtime/session-runtime.d.ts.map +1 -0
  155. package/dist/runtime/session-runtime.js +211 -0
  156. package/dist/runtime/session-runtime.js.map +1 -0
  157. package/dist/sandbox/cloudflare.d.ts +15 -0
  158. package/dist/sandbox/cloudflare.d.ts.map +1 -0
  159. package/dist/sandbox/cloudflare.js +137 -0
  160. package/dist/sandbox/cloudflare.js.map +1 -0
  161. package/dist/sandbox/container.d.ts +16 -0
  162. package/dist/sandbox/container.d.ts.map +1 -0
  163. package/dist/sandbox/container.js +126 -0
  164. package/dist/sandbox/container.js.map +1 -0
  165. package/dist/sandbox/errors.d.ts +6 -0
  166. package/dist/sandbox/errors.d.ts.map +1 -0
  167. package/dist/sandbox/errors.js +11 -0
  168. package/dist/sandbox/errors.js.map +1 -0
  169. package/dist/sandbox/firecracker.d.ts +17 -0
  170. package/dist/sandbox/firecracker.d.ts.map +1 -0
  171. package/dist/sandbox/firecracker.js +212 -0
  172. package/dist/sandbox/firecracker.js.map +1 -0
  173. package/dist/sandbox/host.d.ts +11 -0
  174. package/dist/sandbox/host.d.ts.map +1 -0
  175. package/dist/sandbox/host.js +89 -0
  176. package/dist/sandbox/host.js.map +1 -0
  177. package/dist/sandbox/image.d.ts +5 -0
  178. package/dist/sandbox/image.d.ts.map +1 -0
  179. package/dist/sandbox/image.js +30 -0
  180. package/dist/sandbox/image.js.map +1 -0
  181. package/dist/sandbox/index.d.ts +22 -0
  182. package/dist/sandbox/index.d.ts.map +1 -0
  183. package/dist/sandbox/index.js +54 -0
  184. package/dist/sandbox/index.js.map +1 -0
  185. package/dist/sandbox/path-context.d.ts +4 -0
  186. package/dist/sandbox/path-context.d.ts.map +1 -0
  187. package/dist/sandbox/path-context.js +20 -0
  188. package/dist/sandbox/path-context.js.map +1 -0
  189. package/dist/sandbox/types.d.ts +67 -0
  190. package/dist/sandbox/types.d.ts.map +1 -0
  191. package/dist/sandbox/types.js +2 -0
  192. package/dist/sandbox/types.js.map +1 -0
  193. package/dist/sandbox/utils.d.ts +4 -0
  194. package/dist/sandbox/utils.d.ts.map +1 -0
  195. package/dist/sandbox/utils.js +51 -0
  196. package/dist/sandbox/utils.js.map +1 -0
  197. package/dist/sandbox.d.ts +1 -39
  198. package/dist/sandbox.d.ts.map +1 -1
  199. package/dist/sandbox.js +1 -286
  200. package/dist/sandbox.js.map +1 -1
  201. package/dist/sentry.d.ts +2 -2
  202. package/dist/sentry.d.ts.map +1 -1
  203. package/dist/sentry.js +6 -4
  204. package/dist/sentry.js.map +1 -1
  205. package/dist/session-policy.d.ts +13 -0
  206. package/dist/session-policy.d.ts.map +1 -0
  207. package/dist/session-policy.js +23 -0
  208. package/dist/session-policy.js.map +1 -0
  209. package/dist/session-store.d.ts +35 -8
  210. package/dist/session-store.d.ts.map +1 -1
  211. package/dist/session-store.js +182 -23
  212. package/dist/session-store.js.map +1 -1
  213. package/dist/session-view/command.d.ts +5 -0
  214. package/dist/session-view/command.d.ts.map +1 -0
  215. package/dist/session-view/command.js +11 -0
  216. package/dist/session-view/command.js.map +1 -0
  217. package/dist/session-view/portal.d.ts +16 -0
  218. package/dist/session-view/portal.d.ts.map +1 -0
  219. package/dist/session-view/portal.js +1742 -0
  220. package/dist/session-view/portal.js.map +1 -0
  221. package/dist/session-view/service.d.ts +34 -0
  222. package/dist/session-view/service.d.ts.map +1 -0
  223. package/dist/session-view/service.js +427 -0
  224. package/dist/session-view/service.js.map +1 -0
  225. package/dist/session-view/store.d.ts +18 -0
  226. package/dist/session-view/store.d.ts.map +1 -0
  227. package/dist/session-view/store.js +39 -0
  228. package/dist/session-view/store.js.map +1 -0
  229. package/dist/store.d.ts +4 -7
  230. package/dist/store.d.ts.map +1 -1
  231. package/dist/store.js +26 -52
  232. package/dist/store.js.map +1 -1
  233. package/dist/tool-diagnostics.d.ts +2 -0
  234. package/dist/tool-diagnostics.d.ts.map +1 -0
  235. package/dist/tool-diagnostics.js +7 -0
  236. package/dist/tool-diagnostics.js.map +1 -0
  237. package/dist/tools/bash.d.ts +1 -1
  238. package/dist/tools/bash.d.ts.map +1 -1
  239. package/dist/tools/bash.js.map +1 -1
  240. package/dist/tools/edit.d.ts +1 -1
  241. package/dist/tools/edit.d.ts.map +1 -1
  242. package/dist/tools/edit.js.map +1 -1
  243. package/dist/tools/event.d.ts +62 -0
  244. package/dist/tools/event.d.ts.map +1 -0
  245. package/dist/tools/event.js +138 -0
  246. package/dist/tools/event.js.map +1 -0
  247. package/dist/tools/index.d.ts +8 -2
  248. package/dist/tools/index.d.ts.map +1 -1
  249. package/dist/tools/index.js +5 -1
  250. package/dist/tools/index.js.map +1 -1
  251. package/dist/tools/read.d.ts +1 -1
  252. package/dist/tools/read.d.ts.map +1 -1
  253. package/dist/tools/read.js.map +1 -1
  254. package/dist/tools/write.d.ts +1 -1
  255. package/dist/tools/write.d.ts.map +1 -1
  256. package/dist/tools/write.js.map +1 -1
  257. package/dist/trigger.d.ts +31 -0
  258. package/dist/trigger.d.ts.map +1 -0
  259. package/dist/trigger.js +98 -0
  260. package/dist/trigger.js.map +1 -0
  261. package/dist/ui-copy.d.ts +12 -0
  262. package/dist/ui-copy.d.ts.map +1 -0
  263. package/dist/ui-copy.js +36 -0
  264. package/dist/ui-copy.js.map +1 -0
  265. package/dist/vault-routing.d.ts +4 -0
  266. package/dist/vault-routing.d.ts.map +1 -0
  267. package/dist/vault-routing.js +16 -0
  268. package/dist/vault-routing.js.map +1 -0
  269. package/dist/vault.d.ts +72 -0
  270. package/dist/vault.d.ts.map +1 -0
  271. package/dist/vault.js +264 -0
  272. package/dist/vault.js.map +1 -0
  273. package/package.json +16 -13
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Helpers shared across platform adapters.
3
+ *
4
+ * The agent runner is platform-agnostic: it hands strings and structured tool
5
+ * results to each adapter, which decides how to split, format, and route them.
6
+ * The split/normalize logic itself doesn't differ across platforms — only the
7
+ * markup wrappers — so it lives here once.
8
+ */
9
+ import { appendFileSync, existsSync, mkdirSync } from "fs";
10
+ import { join } from "path";
11
+ import * as log from "../log.js";
12
+ // ============================================================================
13
+ // Per-channel queue for sequential processing
14
+ // ============================================================================
15
+ export class ChannelQueue {
16
+ constructor(name = "") {
17
+ this.name = name;
18
+ this.queue = [];
19
+ this.processing = false;
20
+ }
21
+ enqueue(work) {
22
+ this.queue.push(work);
23
+ this.processNext();
24
+ }
25
+ size() {
26
+ return this.queue.length;
27
+ }
28
+ async processNext() {
29
+ if (this.processing || this.queue.length === 0)
30
+ return;
31
+ this.processing = true;
32
+ const work = this.queue.shift();
33
+ try {
34
+ await work();
35
+ }
36
+ catch (err) {
37
+ log.logWarning(`${this.name ? this.name + " " : ""}queue error`, err instanceof Error ? err.message : String(err));
38
+ }
39
+ this.processing = false;
40
+ this.processNext();
41
+ }
42
+ }
43
+ /**
44
+ * Run `fn` and retry with exponential backoff when its error matches
45
+ * `isRateLimited`. Other errors propagate immediately. Each platform supplies
46
+ * its own predicate so we don't have to know every SDK's error shape here.
47
+ */
48
+ export async function withRetry(fn, opts) {
49
+ const maxRetries = opts.maxRetries ?? 3;
50
+ const baseDelayMs = opts.baseDelayMs ?? 1000;
51
+ let lastError;
52
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
53
+ try {
54
+ return await fn();
55
+ }
56
+ catch (err) {
57
+ lastError = err instanceof Error ? err : new Error(String(err));
58
+ if (opts.isRateLimited(lastError)) {
59
+ const delay = baseDelayMs * Math.pow(2, attempt);
60
+ log.logWarning(`Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
61
+ await new Promise((resolve) => setTimeout(resolve, delay));
62
+ continue;
63
+ }
64
+ throw lastError;
65
+ }
66
+ }
67
+ throw lastError;
68
+ }
69
+ /**
70
+ * Split `text` into chunks no larger than `limit`, appending a continuation
71
+ * marker (e.g. `_(continued 1)_`) at the end of every part except the last.
72
+ *
73
+ * Each adapter passes its own `formatContinuation` so the marker uses the
74
+ * platform's italic / emphasis convention.
75
+ */
76
+ export function splitText(text, limit, formatContinuation) {
77
+ if (text.length <= limit)
78
+ return [text];
79
+ const parts = [];
80
+ let remaining = text;
81
+ let partNum = 1;
82
+ while (remaining.length > 0) {
83
+ const suffixReserve = formatContinuation(partNum).length + 8;
84
+ const chunkLimit = Math.max(1, limit - suffixReserve);
85
+ const chunk = remaining.slice(0, chunkLimit);
86
+ remaining = remaining.slice(chunkLimit);
87
+ const suffix = remaining.length > 0 ? `\n${formatContinuation(partNum)}` : "";
88
+ parts.push(chunk + suffix);
89
+ partNum++;
90
+ }
91
+ return parts;
92
+ }
93
+ // ============================================================================
94
+ // Per-conversation log.jsonl appender
95
+ // ============================================================================
96
+ /**
97
+ * Append a JSON-serializable entry to `${workingDir}/${channel}/log.jsonl`,
98
+ * creating the directory on first use. This is the single write path every
99
+ * adapter uses for human-readable message history.
100
+ */
101
+ export function appendChannelLog(workingDir, channel, entry) {
102
+ const dir = join(workingDir, channel);
103
+ if (!existsSync(dir))
104
+ mkdirSync(dir, { recursive: true });
105
+ appendFileSync(join(dir, "log.jsonl"), `${JSON.stringify(entry)}\n`);
106
+ }
107
+ /** Convenience for appending the bot's own outbound message. */
108
+ export function appendBotResponseLog(workingDir, channel, text, ts, threadTs) {
109
+ appendChannelLog(workingDir, channel, {
110
+ date: new Date().toISOString(),
111
+ ts,
112
+ ...(threadTs ? { threadTs } : {}),
113
+ user: "bot",
114
+ text,
115
+ attachments: [],
116
+ isBot: true,
117
+ });
118
+ }
119
+ /**
120
+ * Pick which session key a `/stop` should target without applying any
121
+ * platform-specific fallback policy. Order:
122
+ * 1. The provided sessionKey, if running.
123
+ * 2. The bare conversationId, if running.
124
+ */
125
+ export function resolveStopTarget(input) {
126
+ const { handler, conversationId, sessionKey } = input;
127
+ if (sessionKey && handler.isRunning(sessionKey))
128
+ return sessionKey;
129
+ if (handler.isRunning(conversationId))
130
+ return conversationId;
131
+ return null;
132
+ }
133
+ /**
134
+ * Return the single running scoped session for this conversation, or null when
135
+ * there are zero or multiple matches.
136
+ */
137
+ export function resolveOnlyScopedStopTarget(handler, conversationId) {
138
+ const runningScopes = handler
139
+ .getRunningSessions()
140
+ .map((s) => s.sessionKey)
141
+ .filter((k) => k.startsWith(`${conversationId}:`));
142
+ return runningScopes.length === 1 ? runningScopes[0] : null;
143
+ }
144
+ /**
145
+ * Render tool-call args for human display. Drops `label` (already in the
146
+ * heading) and folds `path` + `offset`/`limit` into a single `path:start-end`
147
+ * line. Pure data normalization with no platform-specific markup.
148
+ */
149
+ export function formatToolArgs(args) {
150
+ if (!args)
151
+ return "";
152
+ const lines = [];
153
+ for (const [key, value] of Object.entries(args)) {
154
+ if (key === "label" || key === "offset" || key === "limit")
155
+ continue;
156
+ if (key === "path" && typeof value === "string") {
157
+ const offset = args.offset;
158
+ const limit = args.limit;
159
+ lines.push(offset !== undefined && limit !== undefined
160
+ ? `${value}:${offset}-${offset + limit}`
161
+ : value);
162
+ continue;
163
+ }
164
+ lines.push(typeof value === "string" ? value : JSON.stringify(value));
165
+ }
166
+ return lines.join("\n");
167
+ }
168
+ //# sourceMappingURL=shared.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shared.js","sourceRoot":"","sources":["../../src/adapters/shared.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Helpers shared across platform adapters.\n *\n * The agent runner is platform-agnostic: it hands strings and structured tool\n * results to each adapter, which decides how to split, format, and route them.\n * The split/normalize logic itself doesn't differ across platforms — only the\n * markup wrappers — so it lives here once.\n */\n\nimport { appendFileSync, existsSync, mkdirSync } from \"fs\";\nimport { join } from \"path\";\nimport type { BotHandler } from \"../adapter.js\";\nimport * as log from \"../log.js\";\n\n// ============================================================================\n// Per-channel queue for sequential processing\n// ============================================================================\n\nexport class ChannelQueue {\n private queue: Array<() => Promise<void>> = [];\n private processing = false;\n\n constructor(private readonly name: string = \"\") {}\n\n enqueue(work: () => Promise<void>): void {\n this.queue.push(work);\n this.processNext();\n }\n\n size(): number {\n return this.queue.length;\n }\n\n private async processNext(): Promise<void> {\n if (this.processing || this.queue.length === 0) return;\n this.processing = true;\n const work = this.queue.shift()!;\n try {\n await work();\n } catch (err) {\n log.logWarning(\n `${this.name ? this.name + \" \" : \"\"}queue error`,\n err instanceof Error ? err.message : String(err),\n );\n }\n this.processing = false;\n this.processNext();\n }\n}\n\n// ============================================================================\n// Exponential backoff retry utility\n// ============================================================================\n\nexport interface RetryOptions {\n /** Predicate that returns true when an error indicates a platform-side rate limit. */\n isRateLimited: (err: Error) => boolean;\n maxRetries?: number;\n baseDelayMs?: number;\n}\n\n/**\n * Run `fn` and retry with exponential backoff when its error matches\n * `isRateLimited`. Other errors propagate immediately. Each platform supplies\n * its own predicate so we don't have to know every SDK's error shape here.\n */\nexport async function withRetry<T>(fn: () => Promise<T>, opts: RetryOptions): Promise<T> {\n const maxRetries = opts.maxRetries ?? 3;\n const baseDelayMs = opts.baseDelayMs ?? 1000;\n let lastError: Error | undefined;\n\n for (let attempt = 0; attempt < maxRetries; attempt++) {\n try {\n return await fn();\n } catch (err) {\n lastError = err instanceof Error ? err : new Error(String(err));\n\n if (opts.isRateLimited(lastError)) {\n const delay = baseDelayMs * Math.pow(2, attempt);\n log.logWarning(\n `Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`,\n );\n await new Promise((resolve) => setTimeout(resolve, delay));\n continue;\n }\n\n throw lastError;\n }\n }\n throw lastError;\n}\n\n/**\n * Split `text` into chunks no larger than `limit`, appending a continuation\n * marker (e.g. `_(continued 1)_`) at the end of every part except the last.\n *\n * Each adapter passes its own `formatContinuation` so the marker uses the\n * platform's italic / emphasis convention.\n */\nexport function splitText(\n text: string,\n limit: number,\n formatContinuation: (partNum: number) => string,\n): string[] {\n if (text.length <= limit) return [text];\n const parts: string[] = [];\n let remaining = text;\n let partNum = 1;\n while (remaining.length > 0) {\n const suffixReserve = formatContinuation(partNum).length + 8;\n const chunkLimit = Math.max(1, limit - suffixReserve);\n const chunk = remaining.slice(0, chunkLimit);\n remaining = remaining.slice(chunkLimit);\n const suffix = remaining.length > 0 ? `\\n${formatContinuation(partNum)}` : \"\";\n parts.push(chunk + suffix);\n partNum++;\n }\n return parts;\n}\n\n// ============================================================================\n// Per-conversation log.jsonl appender\n// ============================================================================\n\n/**\n * Append a JSON-serializable entry to `${workingDir}/${channel}/log.jsonl`,\n * creating the directory on first use. This is the single write path every\n * adapter uses for human-readable message history.\n */\nexport function appendChannelLog(workingDir: string, channel: string, entry: object): void {\n const dir = join(workingDir, channel);\n if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n appendFileSync(join(dir, \"log.jsonl\"), `${JSON.stringify(entry)}\\n`);\n}\n\n/** Convenience for appending the bot's own outbound message. */\nexport function appendBotResponseLog(\n workingDir: string,\n channel: string,\n text: string,\n ts: string,\n threadTs?: string,\n): void {\n appendChannelLog(workingDir, channel, {\n date: new Date().toISOString(),\n ts,\n ...(threadTs ? { threadTs } : {}),\n user: \"bot\",\n text,\n attachments: [],\n isBot: true,\n });\n}\n\n// ============================================================================\n// Stop-target resolution\n// ============================================================================\n\nexport interface ResolveStopTargetInput {\n handler: BotHandler;\n conversationId: string;\n /** Session key derived from the current message; checked first when present. */\n sessionKey?: string;\n}\n\n/**\n * Pick which session key a `/stop` should target without applying any\n * platform-specific fallback policy. Order:\n * 1. The provided sessionKey, if running.\n * 2. The bare conversationId, if running.\n */\nexport function resolveStopTarget(input: ResolveStopTargetInput): string | null {\n const { handler, conversationId, sessionKey } = input;\n\n if (sessionKey && handler.isRunning(sessionKey)) return sessionKey;\n if (handler.isRunning(conversationId)) return conversationId;\n return null;\n}\n\n/**\n * Return the single running scoped session for this conversation, or null when\n * there are zero or multiple matches.\n */\nexport function resolveOnlyScopedStopTarget(\n handler: BotHandler,\n conversationId: string,\n): string | null {\n const runningScopes = handler\n .getRunningSessions()\n .map((s) => s.sessionKey)\n .filter((k) => k.startsWith(`${conversationId}:`));\n\n return runningScopes.length === 1 ? runningScopes[0] : null;\n}\n\n/**\n * Render tool-call args for human display. Drops `label` (already in the\n * heading) and folds `path` + `offset`/`limit` into a single `path:start-end`\n * line. Pure data normalization with no platform-specific markup.\n */\nexport function formatToolArgs(args: Record<string, unknown> | undefined): string {\n if (!args) return \"\";\n const lines: string[] = [];\n\n for (const [key, value] of Object.entries(args)) {\n if (key === \"label\" || key === \"offset\" || key === \"limit\") continue;\n\n if (key === \"path\" && typeof value === \"string\") {\n const offset = args.offset as number | undefined;\n const limit = args.limit as number | undefined;\n lines.push(\n offset !== undefined && limit !== undefined\n ? `${value}:${offset}-${offset + limit}`\n : value,\n );\n continue;\n }\n\n lines.push(typeof value === \"string\" ? value : JSON.stringify(value));\n }\n\n return lines.join(\"\\n\");\n}\n"]}
@@ -1,8 +1,10 @@
1
- import type { Bot, BotEvent, BotHandler, PlatformInfo } from "../../adapter.js";
1
+ import type { Bot, BotEvent, BotHandler, ConversationKind, PlatformInfo } from "../../adapter.js";
2
2
  import type { EventsWatcher } from "../../events.js";
3
3
  import type { Attachment, ChannelStore } from "../../store.js";
4
4
  export interface SlackEvent {
5
5
  type: "mention" | "dm";
6
+ conversationId: string;
7
+ conversationKind: ConversationKind;
6
8
  channel: string;
7
9
  ts: string;
8
10
  thread_ts?: string;
@@ -45,7 +47,7 @@ export interface SlackContext {
45
47
  channel: string;
46
48
  ts: string;
47
49
  attachments: Array<{
48
- local: string;
50
+ localPath: string;
49
51
  }>;
50
52
  };
51
53
  channelName?: string;
@@ -53,14 +55,12 @@ export interface SlackContext {
53
55
  users: UserInfo[];
54
56
  respond: (text: string, shouldLog?: boolean) => Promise<void>;
55
57
  replaceMessage: (text: string) => Promise<void>;
56
- respondInThread: (text: string) => Promise<void>;
58
+ respondDiagnostic: (text: string) => Promise<void>;
57
59
  setTyping: (isTyping: boolean) => Promise<void>;
58
60
  uploadFile: (filePath: string, title?: string) => Promise<void>;
59
61
  setWorking: (working: boolean) => Promise<void>;
60
62
  deleteMessage: () => Promise<void>;
61
63
  }
62
- /** @deprecated Use BotHandler from adapter.ts instead */
63
- export type MomHandler = BotHandler;
64
64
  export declare class SlackBot implements Bot {
65
65
  private socketClient;
66
66
  private webClient;
@@ -68,6 +68,8 @@ export declare class SlackBot implements Bot {
68
68
  private workingDir;
69
69
  private store;
70
70
  private botUserId;
71
+ private botId;
72
+ private ownMentionRegex;
71
73
  private startupTs;
72
74
  private users;
73
75
  private channels;
@@ -85,7 +87,16 @@ export declare class SlackBot implements Bot {
85
87
  getChannel(channelId: string): SlackChannel | undefined;
86
88
  getAllUsers(): SlackUser[];
87
89
  getAllChannels(): SlackChannel[];
90
+ private stripOwnMention;
88
91
  postMessage(channel: string, text: string): Promise<string>;
92
+ postEphemeral(channel: string, user: string, text: string): Promise<void>;
93
+ postEphemeralBlocks(channel: string, user: string, text: string, blocks: object[]): Promise<void>;
94
+ postMessageBlocks(channel: string, text: string, blocks: object[]): Promise<string>;
95
+ postPrivate(conversationId: string, userId: string, text: string): Promise<void>;
96
+ postPrivateDiagnostic(conversationId: string, userId: string, text: string, options?: {
97
+ style?: "muted" | "error";
98
+ }): Promise<void>;
99
+ openDirectMessage(userId: string): Promise<string>;
89
100
  updateMessage(channel: string, ts: string, text: string): Promise<void>;
90
101
  deleteMessage(channel: string, ts: string): Promise<void>;
91
102
  /** Set the status for an assistant thread (shows "thinking" state) */
@@ -93,15 +104,9 @@ export declare class SlackBot implements Bot {
93
104
  postInThread(channel: string, threadTs: string, text: string): Promise<string>;
94
105
  postInThreadBlocks(channel: string, threadTs: string, text: string, blocks: object[]): Promise<string>;
95
106
  uploadFile(channel: string, filePath: string, title?: string, threadTs?: string): Promise<void>;
96
- /**
97
- * Log a message to log.jsonl (SYNC)
98
- * This is the ONLY place messages are written to log.jsonl
99
- */
100
107
  logToFile(channel: string, entry: object): void;
101
- /**
102
- * Log a bot response to log.jsonl
103
- */
104
108
  logBotResponse(channel: string, text: string, ts: string, threadTs?: string): void;
109
+ aliasSyntheticEventThread(channel: string, threadTs: string, eventTs: string): void;
105
110
  getPlatformInfo(): PlatformInfo;
106
111
  /**
107
112
  * Enqueue an event for processing. Always queues (no "already working" rejection).
@@ -109,20 +114,22 @@ export declare class SlackBot implements Bot {
109
114
  */
110
115
  enqueueEvent(event: BotEvent): boolean;
111
116
  private getQueue;
117
+ private resolveQueueKey;
118
+ private shouldTriggerSharedThreadReply;
112
119
  private buildHomeView;
113
- /**
114
- * Resolve which session key to stop.
115
- * When stop is called from a thread, the thread session (channelId:thread_ts) might
116
- * not be running — but the channel session (channelId) might be, because the bot's
117
- * reply to a top-level mention creates a thread. Check both, prefer thread first.
118
- */
119
120
  private resolveStopTarget;
121
+ private isStopText;
122
+ private createCommandAdapters;
123
+ private createSlashCommandBot;
124
+ private routeSlashLoginCommand;
125
+ private routeSlashNewCommand;
126
+ private routeSlashModelCommand;
127
+ private routeSlashSandboxCommand;
128
+ private routeSlashAutoReplyCommand;
129
+ private routeSlashSessionCommand;
120
130
  private setupEventHandlers;
121
- /**
122
- * Log a user message to log.jsonl (SYNC)
123
- * Downloads attachments in background via store
124
- */
125
131
  private logUserMessage;
132
+ private logExternalBotMessage;
126
133
  private getExistingTimestamps;
127
134
  private backfillChannel;
128
135
  private backfillAllChannels;
@@ -1 +1 @@
1
- {"version":3,"file":"bot.d.ts","sourceRoot":"","sources":["../../../src/adapters/slack/bot.ts"],"names":[],"mappings":"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","sourcesContent":["import { SocketModeClient } from \"@slack/socket-mode\";\nimport { WebClient } from \"@slack/web-api\";\nimport { appendFileSync, existsSync, mkdirSync, readFileSync } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { basename, join } from \"path\";\nimport type { Bot, BotEvent, BotHandler, PlatformInfo } from \"../../adapter.js\";\nimport type { EventsWatcher } from \"../../events.js\";\nimport * as log from \"../../log.js\";\nimport type { Attachment, ChannelStore } from \"../../store.js\";\nimport { createSlackAdapters } from \"./context.js\";\n\n// ============================================================================\n// Exponential backoff utility for Slack API calls\n// ============================================================================\n\n/**\n * Retry a function with exponential backoff on rate limit errors.\n */\nasync function withRetry<T>(\n fn: () => Promise<T>,\n maxRetries: number = 3,\n baseDelayMs: number = 1000,\n): Promise<T> {\n let lastError: Error | undefined;\n for (let attempt = 0; attempt < maxRetries; attempt++) {\n try {\n return await fn();\n } catch (err) {\n lastError = err instanceof Error ? err : new Error(String(err));\n\n // Check for rate limit errors\n let isRateLimited = false;\n\n // Check for rate_limited error code (Slack SDK)\n if (\"code\" in lastError && lastError.code === \"rate_limited\") {\n isRateLimited = true;\n }\n\n // Check for rate_limited in error response\n if (\"data\" in lastError) {\n const data = (lastError as { data?: { error?: string; response?: { status?: number } } })\n .data;\n if (data?.error === \"rate_limited\" || data?.response?.status === 429) {\n isRateLimited = true;\n }\n }\n\n if (isRateLimited) {\n const delay = baseDelayMs * Math.pow(2, attempt);\n log.logWarning(\n `Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`,\n );\n await new Promise((resolve) => setTimeout(resolve, delay));\n continue;\n }\n\n // Non-retryable error\n throw lastError;\n }\n }\n throw lastError;\n}\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface SlackEvent {\n type: \"mention\" | \"dm\";\n channel: string;\n ts: string;\n thread_ts?: string;\n user: string;\n text: string;\n files?: Array<{ name?: string; url_private_download?: string; url_private?: string }>;\n /** Processed attachments with local paths (populated after logUserMessage) */\n attachments?: Attachment[];\n /** Session key passed through to BotEvent so handleEvent uses the correct persistent session */\n sessionKey?: string;\n}\n\nexport interface SlackUser {\n id: string;\n userName: string;\n displayName: string;\n}\n\nexport interface SlackChannel {\n id: string;\n name: string;\n}\n\n// Types used by agent.ts\nexport interface ChannelInfo {\n id: string;\n name: string;\n}\n\nexport interface UserInfo {\n id: string;\n userName: string;\n displayName: string;\n}\n\nexport interface SlackContext {\n message: {\n text: string;\n rawText: string;\n user: string;\n userName?: string;\n channel: string;\n ts: string;\n attachments: Array<{ local: string }>;\n };\n channelName?: string;\n channels: ChannelInfo[];\n users: UserInfo[];\n respond: (text: string, shouldLog?: boolean) => Promise<void>;\n replaceMessage: (text: string) => Promise<void>;\n respondInThread: (text: string) => Promise<void>;\n setTyping: (isTyping: boolean) => Promise<void>;\n uploadFile: (filePath: string, title?: string) => Promise<void>;\n setWorking: (working: boolean) => Promise<void>;\n deleteMessage: () => Promise<void>;\n}\n\n/** @deprecated Use BotHandler from adapter.ts instead */\nexport type MomHandler = BotHandler;\n\n// ============================================================================\n// Per-channel queue for sequential processing\n// ============================================================================\n\ntype QueuedWork = () => Promise<void>;\n\nclass ChannelQueue {\n private queue: QueuedWork[] = [];\n private processing = false;\n\n enqueue(work: QueuedWork): void {\n this.queue.push(work);\n this.processNext();\n }\n\n size(): number {\n return this.queue.length;\n }\n\n private async processNext(): Promise<void> {\n if (this.processing || this.queue.length === 0) return;\n this.processing = true;\n const work = this.queue.shift()!;\n try {\n await work();\n } catch (err) {\n log.logWarning(\"Queue error\", err instanceof Error ? err.message : String(err));\n }\n this.processing = false;\n this.processNext();\n }\n}\n\n// ============================================================================\n// SlackBot\n// ============================================================================\n\nexport class SlackBot implements Bot {\n private socketClient: SocketModeClient;\n private webClient: WebClient;\n private handler: BotHandler;\n private workingDir: string;\n private store: ChannelStore;\n private botUserId: string | null = null;\n private startupTs: string | null = null; // Messages older than this are just logged, not processed\n\n private users = new Map<string, SlackUser>();\n private channels = new Map<string, SlackChannel>();\n private queues = new Map<string, ChannelQueue>();\n private eventsWatcher: EventsWatcher | null = null;\n\n constructor(\n handler: BotHandler,\n config: { appToken: string; botToken: string; workingDir: string; store: ChannelStore },\n ) {\n this.handler = handler;\n this.workingDir = config.workingDir;\n this.store = config.store;\n this.socketClient = new SocketModeClient({ appToken: config.appToken });\n this.webClient = new WebClient(config.botToken);\n }\n\n setEventsWatcher(watcher: EventsWatcher): void {\n this.eventsWatcher = watcher;\n }\n\n // ==========================================================================\n // Public API\n // ==========================================================================\n\n async start(): Promise<void> {\n const auth = await this.webClient.auth.test();\n this.botUserId = auth.user_id as string;\n\n await Promise.all([this.fetchUsers(), this.fetchChannels()]);\n log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);\n\n await this.backfillAllChannels();\n\n this.setupEventHandlers();\n await this.socketClient.start();\n\n // Record startup time - messages older than this are just logged, not processed\n this.startupTs = (Date.now() / 1000).toFixed(6);\n\n log.logConnected();\n }\n\n getUser(userId: string): SlackUser | undefined {\n return this.users.get(userId);\n }\n\n getChannel(channelId: string): SlackChannel | undefined {\n return this.channels.get(channelId);\n }\n\n getAllUsers(): SlackUser[] {\n return Array.from(this.users.values());\n }\n\n getAllChannels(): SlackChannel[] {\n return Array.from(this.channels.values());\n }\n\n async postMessage(channel: string, text: string): Promise<string> {\n return withRetry(async () => {\n const result = await this.webClient.chat.postMessage({ channel, text });\n return result.ts as string;\n });\n }\n\n async updateMessage(channel: string, ts: string, text: string): Promise<void> {\n return withRetry(async () => {\n await this.webClient.chat.update({ channel, ts, text });\n });\n }\n\n async deleteMessage(channel: string, ts: string): Promise<void> {\n return withRetry(async () => {\n await this.webClient.chat.delete({ channel, ts });\n });\n }\n\n // ==========================================================================\n // Slack Assistant API (AI assistant experience)\n // ==========================================================================\n\n /** Set the status for an assistant thread (shows \"thinking\" state) */\n async setAssistantStatus(channel: string, threadTs: string, status: string): Promise<void> {\n return withRetry(async () => {\n await this.webClient.assistant.threads.setStatus({\n channel_id: channel,\n thread_ts: threadTs,\n status,\n });\n });\n }\n\n async postInThread(channel: string, threadTs: string, text: string): Promise<string> {\n return withRetry(async () => {\n // Use Block Kit section for long messages to trigger Slack's \"Show more\" collapsing (~700 chars)\n const SECTION_TEXT_LIMIT = 3000;\n if (text.length > 500) {\n const blockText =\n text.length > SECTION_TEXT_LIMIT\n ? text.substring(0, SECTION_TEXT_LIMIT - 20) + \"\\n_(truncated)_\"\n : text;\n const result = await this.webClient.chat.postMessage({\n channel,\n thread_ts: threadTs,\n text, // full text as notification fallback\n blocks: [{ type: \"section\", text: { type: \"mrkdwn\", text: blockText } }],\n });\n return result.ts as string;\n }\n const result = await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });\n return result.ts as string;\n });\n }\n\n async postInThreadBlocks(\n channel: string,\n threadTs: string,\n text: string,\n blocks: object[],\n ): Promise<string> {\n return withRetry(async () => {\n const result = await this.webClient.chat.postMessage({\n channel,\n thread_ts: threadTs,\n text, // fallback for notifications\n blocks: blocks as any,\n });\n return result.ts as string;\n });\n }\n\n async uploadFile(\n channel: string,\n filePath: string,\n title?: string,\n threadTs?: string,\n ): Promise<void> {\n return withRetry(async () => {\n const fileName = title || basename(filePath);\n const fileContent = readFileSync(filePath);\n await this.webClient.files.uploadV2({\n channel_id: channel,\n file: fileContent,\n filename: fileName,\n title: fileName,\n ...(threadTs ? { thread_ts: threadTs } : {}),\n } as Parameters<typeof this.webClient.files.uploadV2>[0]);\n });\n }\n\n /**\n * Log a message to log.jsonl (SYNC)\n * This is the ONLY place messages are written to log.jsonl\n */\n logToFile(channel: string, entry: object): void {\n const dir = join(this.workingDir, channel);\n if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n appendFileSync(join(dir, \"log.jsonl\"), `${JSON.stringify(entry)}\\n`);\n }\n\n /**\n * Log a bot response to log.jsonl\n */\n logBotResponse(channel: string, text: string, ts: string, threadTs?: string): void {\n this.logToFile(channel, {\n date: new Date().toISOString(),\n ts,\n threadTs,\n user: \"bot\",\n text,\n attachments: [],\n isBot: true,\n });\n }\n\n getPlatformInfo(): PlatformInfo {\n return {\n name: \"slack\",\n formattingGuide:\n \"## Slack Formatting (mrkdwn, NOT Markdown)\\nBold: *text*, Italic: _text_, Code: `code`, Block: ```code```, Links: <url|text>\\nDo NOT use **double asterisks** or [markdown](links).\",\n channels: this.getAllChannels().map((c) => ({ id: c.id, name: c.name })),\n users: this.getAllUsers().map((u) => ({\n id: u.id,\n userName: u.userName,\n displayName: u.displayName,\n })),\n };\n }\n\n // ==========================================================================\n // Events Integration\n // ==========================================================================\n\n /**\n * Enqueue an event for processing. Always queues (no \"already working\" rejection).\n * Returns true if enqueued, false if queue is full (max 5).\n */\n enqueueEvent(event: BotEvent): boolean {\n const queue = this.getQueue(event.channel);\n if (queue.size() >= 5) {\n log.logWarning(\n `Event queue full for ${event.channel}, discarding: ${event.text.substring(0, 50)}`,\n );\n return false;\n }\n log.logInfo(`Enqueueing event for ${event.channel}: ${event.text.substring(0, 50)}`);\n queue.enqueue(() => {\n const adapters = createSlackAdapters(event as unknown as SlackEvent, this, true);\n return this.handler.handleEvent(event, this, adapters, true);\n });\n return true;\n }\n\n // ==========================================================================\n // Private - Event Handlers\n // ==========================================================================\n\n private getQueue(channelId: string): ChannelQueue {\n let queue = this.queues.get(channelId);\n if (!queue) {\n queue = new ChannelQueue();\n this.queues.set(channelId, queue);\n }\n return queue;\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n private buildHomeView(): { type: \"home\"; blocks: any[] } {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const blocks: any[] = [\n {\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: \"*Pi Agent*\\nWelcome back! Start a new task or check on running work.\",\n },\n accessory: {\n type: \"image\",\n image_url: \"https://media1.tenor.com/m/lfDATg4Bhc0AAAAC/happy-cat.gif\",\n alt_text: \"Pi Agent\",\n },\n },\n ];\n\n // --- Running tasks ---\n const runningSessions = this.handler.getRunningSessions();\n\n blocks.push(\n { type: \"divider\" },\n {\n type: \"header\",\n text: {\n type: \"plain_text\",\n text: `Running Tasks (${runningSessions.length})`,\n emoji: true,\n },\n },\n );\n\n if (runningSessions.length === 0) {\n blocks.push({\n type: \"context\",\n elements: [{ type: \"mrkdwn\", text: \"_No tasks running right now._\" }],\n });\n } else {\n // Threshold for \"stuck\" detection (10 minutes)\n const STUCK_THRESHOLD_MS = 10 * 60 * 1000;\n\n for (const session of runningSessions) {\n const channelId = session.sessionKey.split(\":\")[0];\n const channel = this.channels.get(channelId);\n const channelName = channel ? `#${channel.name}` : channelId;\n const elapsed = Math.floor((Date.now() - session.startedAt) / 60000);\n const elapsedStr = elapsed < 1 ? \"<1 min\" : `${elapsed} min`;\n\n // Check if task might be stuck\n const lastActivity = session.lastActivityAt ? Date.now() - session.lastActivityAt : 0;\n const isStuck = lastActivity > STUCK_THRESHOLD_MS;\n const statusText = isStuck ? \"_stuck_\" : \"_running_\";\n\n // Build status line: channel · status · time · step\n let statusLine = `${statusText} · ${elapsedStr}`;\n if (session.currentTool) {\n statusLine += ` · ${session.currentTool}`;\n }\n if (isStuck && lastActivity > 0) {\n const inactiveMin = Math.floor(lastActivity / 60000);\n statusLine += ` · idle ${inactiveMin}m`;\n }\n\n // Use context block for gray small text (like \"No scheduled jobs.\")\n blocks.push({\n type: \"context\",\n elements: [\n {\n type: \"mrkdwn\",\n text: `*${channelName}* · ${statusLine}`,\n },\n ],\n });\n\n // Add Force Stop button as separate element if stuck\n if (isStuck) {\n blocks.push({\n type: \"context\",\n elements: [\n {\n type: \"mrkdwn\",\n text: \" \",\n },\n {\n type: \"button\",\n text: { type: \"plain_text\", text: \"Force Stop\", emoji: true },\n action_id: `force_stop_${session.sessionKey.replace(/:/g, \"_\")}`,\n style: \"danger\",\n },\n ],\n });\n }\n }\n }\n\n // --- Cron jobs ---\n const periodicEvents = this.eventsWatcher?.getPeriodicEvents() ?? [];\n\n blocks.push(\n { type: \"divider\" },\n {\n type: \"header\",\n text: {\n type: \"plain_text\",\n text: `Scheduled Jobs (${periodicEvents.length})`,\n emoji: true,\n },\n },\n );\n\n if (periodicEvents.length === 0) {\n blocks.push({\n type: \"context\",\n elements: [{ type: \"mrkdwn\", text: \"_No scheduled jobs._\" }],\n });\n } else {\n for (const ev of periodicEvents) {\n const channelLabel =\n ev.platform === \"slack\"\n ? (() => {\n const channel = this.channels.get(ev.channelId);\n const channelName = channel ? `#${channel.name}` : ev.channelId;\n return `${ev.platform}:${channelName}`;\n })()\n : `${ev.platform}:${ev.channelId}`;\n const nextStr = ev.nextRun\n ? new Date(ev.nextRun).toLocaleString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n })\n : \"—\";\n blocks.push({\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `*${ev.text}*\\n└ \\`${ev.schedule}\\` · ${channelLabel} · Next: ${nextStr}`,\n },\n });\n }\n }\n\n // --- Footer ---\n blocks.push(\n { type: \"divider\" },\n {\n type: \"context\",\n elements: [\n { type: \"mrkdwn\", text: \"💡 @mention in a channel or send a DM to start a new task\" },\n ],\n },\n );\n\n return { type: \"home\", blocks };\n }\n\n /**\n * Resolve which session key to stop.\n * When stop is called from a thread, the thread session (channelId:thread_ts) might\n * not be running — but the channel session (channelId) might be, because the bot's\n * reply to a top-level mention creates a thread. Check both, prefer thread first.\n */\n private resolveStopTarget(channelId: string, threadTs?: string): string | null {\n if (threadTs) {\n const threadKey = `${channelId}:${threadTs}`;\n if (this.handler.isRunning(threadKey)) return threadKey;\n // Fall back to channel session — the thread may have been spawned by a top-level run\n if (this.handler.isRunning(channelId)) return channelId;\n return null;\n }\n return this.handler.isRunning(channelId) ? channelId : null;\n }\n\n private setupEventHandlers(): void {\n // Channel @mentions\n this.socketClient.on(\"app_mention\", ({ event, ack }) => {\n const e = event as {\n text: string;\n channel: string;\n user: string;\n ts: string;\n thread_ts?: string;\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n };\n\n // Skip DMs (handled by message event)\n if (e.channel.startsWith(\"D\")) {\n ack();\n return;\n }\n\n // Top-level mentions use a persistent channel session.\n // Thread replies get their own isolated session (channelId:thread_ts).\n const sessionKey = e.thread_ts ? `${e.channel}:${e.thread_ts}` : e.channel;\n\n const slackEvent: SlackEvent = {\n type: \"mention\",\n channel: e.channel,\n ts: e.ts,\n thread_ts: e.thread_ts,\n user: e.user,\n text: e.text.replace(/<@[A-Z0-9]+>/gi, \"\").trim(),\n files: e.files,\n sessionKey,\n };\n\n // SYNC: Log to log.jsonl (ALWAYS, even for old messages)\n // Also downloads attachments in background and stores local paths\n slackEvent.attachments = this.logUserMessage(slackEvent);\n\n // Only trigger processing for messages AFTER startup (not replayed old messages)\n if (this.startupTs && e.ts < this.startupTs) {\n log.logInfo(\n `[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`,\n );\n ack();\n return;\n }\n\n // Check for stop command - execute immediately, don't queue!\n if (slackEvent.text.toLowerCase().trim() === \"stop\") {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this);\n } else {\n this.postMessage(e.channel, \"_Nothing running_\");\n }\n ack();\n return;\n }\n\n // SYNC: Check if busy (per-thread)\n if (this.handler.isRunning(sessionKey)) {\n this.postMessage(\n e.channel,\n \"_Already working in this thread. Say `@mama stop` to cancel._\",\n );\n } else {\n this.getQueue(sessionKey).enqueue(() => {\n const adapters = createSlackAdapters(slackEvent, this, false);\n return this.handler.handleEvent(\n slackEvent as unknown as import(\"../../adapter.js\").BotEvent,\n this,\n adapters,\n false,\n );\n });\n }\n\n ack();\n });\n\n // All messages (for logging) + DMs (for triggering)\n this.socketClient.on(\"message\", ({ event, ack }) => {\n const e = event as {\n text?: string;\n channel: string;\n user?: string;\n ts: string;\n thread_ts?: string;\n channel_type?: string;\n subtype?: string;\n bot_id?: string;\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n };\n\n // Skip bot messages, edits, etc.\n if (e.bot_id || !e.user || e.user === this.botUserId) {\n ack();\n return;\n }\n if (e.subtype !== undefined && e.subtype !== \"file_share\") {\n ack();\n return;\n }\n if (!e.text && (!e.files || e.files.length === 0)) {\n ack();\n return;\n }\n\n const isDM = e.channel_type === \"im\";\n const isBotMention = e.text?.includes(`<@${this.botUserId}>`);\n\n // Skip channel @mentions - already handled by app_mention event\n if (!isDM && isBotMention) {\n ack();\n return;\n }\n\n const slackEvent: SlackEvent = {\n type: isDM ? \"dm\" : \"mention\",\n channel: e.channel,\n ts: e.ts,\n thread_ts: e.thread_ts,\n user: e.user,\n text: (e.text || \"\").replace(/<@[A-Z0-9]+>/gi, \"\").trim(),\n files: e.files,\n sessionKey: isDM ? e.channel : undefined,\n };\n\n // SYNC: Log to log.jsonl (ALL messages - channel chatter and DMs)\n // Also downloads attachments in background and stores local paths\n slackEvent.attachments = this.logUserMessage(slackEvent);\n\n // Only trigger processing for messages AFTER startup (not replayed old messages)\n if (this.startupTs && e.ts < this.startupTs) {\n log.logInfo(\n `[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`,\n );\n ack();\n return;\n }\n\n // Check for stop command in channel threads (without @mention)\n // app_mention handles \"@mama stop\", but bare \"stop\" in a thread comes here\n if (!isDM && e.thread_ts && slackEvent.text.toLowerCase().trim() === \"stop\") {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this);\n } else {\n this.postMessage(e.channel, \"_Nothing running_\");\n }\n ack();\n return;\n }\n\n // Only trigger handler for DMs\n if (isDM) {\n const dmSessionKey = slackEvent.sessionKey!;\n // Check for stop command - execute immediately, don't queue!\n if (slackEvent.text.toLowerCase().trim() === \"stop\") {\n if (this.handler.isRunning(dmSessionKey)) {\n this.handler.handleStop(dmSessionKey, e.channel, this); // Don't await, don't queue\n } else {\n this.postMessage(e.channel, \"_Nothing running_\");\n }\n ack();\n return;\n }\n\n if (this.handler.isRunning(dmSessionKey)) {\n this.postMessage(e.channel, \"_Already working. Say `stop` to cancel._\");\n } else {\n this.getQueue(dmSessionKey).enqueue(() => {\n const adapters = createSlackAdapters(slackEvent, this, false);\n return this.handler.handleEvent(\n slackEvent as unknown as import(\"../../adapter.js\").BotEvent,\n this,\n adapters,\n false,\n );\n });\n }\n }\n\n ack();\n });\n\n // App Home tab\n this.socketClient.on(\"app_home_opened\", ({ event, ack }) => {\n const e = event as { user: string; tab: string };\n ack();\n if (e.tab !== \"home\") return;\n\n this.webClient.views\n .publish({\n user_id: e.user,\n view: this.buildHomeView(),\n })\n .catch((err) => {\n log.logWarning(`Failed to publish App Home view`, String(err));\n });\n });\n\n // Handle button clicks (Force Stop)\n this.socketClient.on(\"block_actions\", async ({ body, ack }) => {\n const action = body.actions?.[0];\n if (!action || !action.action_id?.startsWith(\"force_stop_\")) {\n ack();\n return;\n }\n\n ack();\n const sessionKey = action.action_id.replace(\"force_stop_\", \"\").replace(/_/g, \":\");\n const userId = body.user?.id;\n const channelId = body.container?.channel_id || sessionKey.split(\":\")[0];\n\n log.logInfo(`[Force Stop] User ${userId} requested force stop for ${sessionKey}`);\n\n // Use handler's forceStop method\n this.handler.forceStop(sessionKey);\n\n // Notify in channel\n await this.postMessage(channelId, `_🔴 Force stopped by ${userId}_`);\n\n // Refresh home tab\n if (userId) {\n this.webClient.views\n .publish({\n user_id: userId,\n view: this.buildHomeView(),\n })\n .catch((err) => {\n log.logWarning(`Failed to refresh App Home view`, String(err));\n });\n }\n });\n }\n\n /**\n * Log a user message to log.jsonl (SYNC)\n * Downloads attachments in background via store\n */\n private logUserMessage(event: SlackEvent): Attachment[] {\n const user = this.users.get(event.user);\n // Process attachments - queues downloads in background\n const attachments = event.files\n ? this.store.processAttachments(event.channel, event.files, event.ts)\n : [];\n this.logToFile(event.channel, {\n date: new Date(parseFloat(event.ts) * 1000).toISOString(),\n ts: event.ts,\n threadTs: event.thread_ts,\n user: event.user,\n userName: user?.userName,\n displayName: user?.displayName,\n text: event.text,\n attachments,\n isBot: false,\n });\n return attachments;\n }\n\n // ==========================================================================\n // Private - Backfill\n // ==========================================================================\n\n private async getExistingTimestamps(channelId: string): Promise<Set<string>> {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n const timestamps = new Set<string>();\n if (!existsSync(logPath)) return timestamps;\n\n const content = await readFile(logPath, \"utf-8\");\n const lines = content.trim().split(\"\\n\").filter(Boolean);\n for (const line of lines) {\n try {\n const entry = JSON.parse(line);\n if (entry.ts) timestamps.add(entry.ts);\n } catch {}\n }\n return timestamps;\n }\n\n private async backfillChannel(channelId: string): Promise<number> {\n const existingTs = await this.getExistingTimestamps(channelId);\n\n // Find the biggest ts in log.jsonl\n let latestTs: string | undefined;\n for (const ts of existingTs) {\n if (!latestTs || parseFloat(ts) > parseFloat(latestTs)) latestTs = ts;\n }\n\n type Message = {\n user?: string;\n bot_id?: string;\n text?: string;\n ts?: string;\n subtype?: string;\n files?: Array<{ name: string }>;\n };\n const allMessages: Message[] = [];\n\n let cursor: string | undefined;\n let pageCount = 0;\n const maxPages = 3;\n\n do {\n const result = await this.webClient.conversations.history({\n channel: channelId,\n oldest: latestTs, // Only fetch messages newer than what we have\n inclusive: false,\n limit: 1000,\n cursor,\n });\n if (result.messages) {\n allMessages.push(...(result.messages as Message[]));\n }\n cursor = result.response_metadata?.next_cursor;\n pageCount++;\n } while (cursor && pageCount < maxPages);\n\n // Filter: include mama's messages, exclude other bots, skip already logged\n const relevantMessages = allMessages.filter((msg) => {\n if (!msg.ts || existingTs.has(msg.ts)) return false; // Skip duplicates\n if (msg.user === this.botUserId) return true;\n if (msg.bot_id) return false;\n if (msg.subtype !== undefined && msg.subtype !== \"file_share\") return false;\n if (!msg.user) return false;\n if (!msg.text && (!msg.files || msg.files.length === 0)) return false;\n return true;\n });\n\n // Reverse to chronological order\n relevantMessages.reverse();\n\n // Log each message to log.jsonl\n for (const msg of relevantMessages) {\n const isMamaMessage = msg.user === this.botUserId;\n const user = this.users.get(msg.user!);\n // Strip @mentions from text (same as live messages)\n const text = (msg.text || \"\").replace(/<@[A-Z0-9]+>/gi, \"\").trim();\n // Process attachments - queues downloads in background\n const attachments = msg.files\n ? this.store.processAttachments(channelId, msg.files, msg.ts!)\n : [];\n\n this.logToFile(channelId, {\n date: new Date(parseFloat(msg.ts!) * 1000).toISOString(),\n ts: msg.ts!,\n user: isMamaMessage ? \"bot\" : msg.user!,\n userName: isMamaMessage ? undefined : user?.userName,\n displayName: isMamaMessage ? undefined : user?.displayName,\n text,\n attachments,\n isBot: isMamaMessage,\n });\n }\n\n return relevantMessages.length;\n }\n\n private async backfillAllChannels(): Promise<void> {\n const startTime = Date.now();\n\n // Only backfill channels that already have a log.jsonl (mama has interacted with them before)\n const channelsToBackfill: Array<[string, SlackChannel]> = [];\n for (const [channelId, channel] of this.channels) {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n if (existsSync(logPath)) {\n channelsToBackfill.push([channelId, channel]);\n }\n }\n\n log.logBackfillStart(channelsToBackfill.length);\n\n let totalMessages = 0;\n for (const [channelId, channel] of channelsToBackfill) {\n try {\n const count = await this.backfillChannel(channelId);\n if (count > 0) log.logBackfillChannel(channel.name, count);\n totalMessages += count;\n } catch (error) {\n log.logWarning(`Failed to backfill #${channel.name}`, String(error));\n }\n\n // Add delay between channels to avoid hitting Slack rate limits\n if (channelId !== channelsToBackfill[channelsToBackfill.length - 1][0]) {\n await new Promise((resolve) => setTimeout(resolve, 500));\n }\n }\n\n const durationMs = Date.now() - startTime;\n log.logBackfillComplete(totalMessages, durationMs);\n }\n\n // ==========================================================================\n // Private - Fetch Users/Channels\n // ==========================================================================\n\n private async fetchUsers(): Promise<void> {\n let cursor: string | undefined;\n do {\n const result = await this.webClient.users.list({ limit: 200, cursor });\n const members = result.members as\n | Array<{ id?: string; name?: string; real_name?: string; deleted?: boolean }>\n | undefined;\n if (members) {\n for (const u of members) {\n if (u.id && u.name && !u.deleted) {\n this.users.set(u.id, {\n id: u.id,\n userName: u.name,\n displayName: u.real_name || u.name,\n });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n }\n\n private async fetchChannels(): Promise<void> {\n // Fetch public/private channels\n let cursor: string | undefined;\n do {\n const result = await this.webClient.conversations.list({\n types: \"public_channel,private_channel\",\n exclude_archived: true,\n limit: 200,\n cursor,\n });\n const channels = result.channels as\n | Array<{ id?: string; name?: string; is_member?: boolean }>\n | undefined;\n if (channels) {\n for (const c of channels) {\n if (c.id && c.name && c.is_member) {\n this.channels.set(c.id, { id: c.id, name: c.name });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n\n // Also fetch DM channels (IMs)\n cursor = undefined;\n do {\n const result = await this.webClient.conversations.list({\n types: \"im\",\n limit: 200,\n cursor,\n });\n const ims = result.channels as Array<{ id?: string; user?: string }> | undefined;\n if (ims) {\n for (const im of ims) {\n if (im.id) {\n // Use user's name as channel name for DMs\n const user = im.user ? this.users.get(im.user) : undefined;\n const name = user ? `DM:${user.userName}` : `DM:${im.id}`;\n this.channels.set(im.id, { id: im.id, name });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n }\n}\n"]}
1
+ {"version":3,"file":"bot.d.ts","sourceRoot":"","sources":["../../../src/adapters/slack/bot.ts"],"names":[],"mappings":"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","sourcesContent":["import { SocketModeClient } from \"@slack/socket-mode\";\nimport { WebClient } from \"@slack/web-api\";\nimport { existsSync, linkSync, readFileSync } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { basename, join } from \"path\";\nimport type {\n Bot,\n BotAdapters,\n BotEvent,\n BotHandler,\n ChatMessage,\n ChatResponseContext,\n ChatToolResult,\n ConversationKind,\n PlatformInfo,\n} from \"../../adapter.js\";\nimport type { EventsWatcher } from \"../../events.js\";\nimport * as log from \"../../log.js\";\nimport type { Attachment, ChannelStore } from \"../../store.js\";\nimport { PRODUCT_NAME, formatForceStopped, formatNothingRunning } from \"../../ui-copy.js\";\nimport {\n appendBotResponseLog,\n appendChannelLog,\n ChannelQueue,\n resolveOnlyScopedStopTarget,\n resolveStopTarget,\n withRetry,\n} from \"../shared.js\";\nimport { getThreadSessionFile } from \"../../session-store.js\";\nimport { evaluateAutoReplyPolicy } from \"../../trigger.js\";\nimport { createSlackAdapters } from \"./context.js\";\nimport { hasMaterializedSlackBranchSession } from \"./branch-manager.js\";\nimport { resolveSlackSessionKey } from \"./session.js\";\n\n// Slack WebClient errors carry either `code: \"rate_limited\"` (retry-after) or\n// the legacy `data.error === \"rate_limited\"` / 429 status shape.\nfunction slackIsRateLimited(err: Error): boolean {\n if ((err as { code?: unknown }).code === \"rate_limited\") return true;\n const data = (err as { data?: { error?: string; response?: { status?: number } } }).data;\n return data?.error === \"rate_limited\" || data?.response?.status === 429;\n}\n\nconst slackRetry = <T>(fn: () => Promise<T>): Promise<T> =>\n withRetry(fn, { isRateLimited: slackIsRateLimited });\n\nfunction collectSlackText(value: unknown, parts: string[]): void {\n if (value === null || value === undefined) return;\n if (typeof value === \"string\") {\n const trimmed = value.trim();\n if (trimmed) parts.push(trimmed);\n return;\n }\n if (Array.isArray(value)) {\n for (const item of value) collectSlackText(item, parts);\n return;\n }\n if (typeof value !== \"object\") return;\n\n const obj = value as Record<string, unknown>;\n for (const key of [\"text\", \"fallback\", \"title\", \"value\"] as const) {\n collectSlackText(obj[key], parts);\n }\n collectSlackText(obj.fields, parts);\n collectSlackText(obj.elements, parts);\n collectSlackText(obj.blocks, parts);\n}\n\nfunction buildSlackAppMessageText(event: {\n text?: string;\n blocks?: unknown[];\n attachments?: unknown[];\n}): string {\n const parts: string[] = [];\n collectSlackText(event.text, parts);\n collectSlackText(event.blocks, parts);\n collectSlackText(event.attachments, parts);\n const deduped = parts.filter((part, index) => parts.indexOf(part) === index);\n return deduped.join(\"\\n\");\n}\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface SlackEvent {\n type: \"mention\" | \"dm\";\n conversationId: string;\n conversationKind: ConversationKind;\n channel: string;\n ts: string;\n thread_ts?: string;\n user: string;\n text: string;\n files?: Array<{ name?: string; url_private_download?: string; url_private?: string }>;\n /** Processed attachments with local paths (populated after logUserMessage) */\n attachments?: Attachment[];\n /** Session key passed through to BotEvent so handleEvent uses the correct persistent session */\n sessionKey?: string;\n}\n\nexport interface SlackUser {\n id: string;\n userName: string;\n displayName: string;\n}\n\nexport interface SlackChannel {\n id: string;\n name: string;\n}\n\n// Types used by agent.ts\nexport interface ChannelInfo {\n id: string;\n name: string;\n}\n\nexport interface UserInfo {\n id: string;\n userName: string;\n displayName: string;\n}\n\nexport interface SlackContext {\n message: {\n text: string;\n rawText: string;\n user: string;\n userName?: string;\n channel: string;\n ts: string;\n attachments: Array<{ localPath: string }>;\n };\n channelName?: string;\n channels: ChannelInfo[];\n users: UserInfo[];\n respond: (text: string, shouldLog?: boolean) => Promise<void>;\n replaceMessage: (text: string) => Promise<void>;\n respondDiagnostic: (text: string) => Promise<void>;\n setTyping: (isTyping: boolean) => Promise<void>;\n uploadFile: (filePath: string, title?: string) => Promise<void>;\n setWorking: (working: boolean) => Promise<void>;\n deleteMessage: () => Promise<void>;\n}\n\n// ============================================================================\n// SlackBot\n// ============================================================================\n\nexport class SlackBot implements Bot {\n private socketClient: SocketModeClient;\n private webClient: WebClient;\n private handler: BotHandler;\n private workingDir: string;\n private store: ChannelStore;\n private botUserId: string | null = null;\n private botId: string | null = null;\n private ownMentionRegex: RegExp | null = null;\n private startupTs: string | null = null; // Messages older than this are just logged, not processed\n\n private users = new Map<string, SlackUser>();\n private channels = new Map<string, SlackChannel>();\n private queues = new Map<string, ChannelQueue>();\n private eventsWatcher: EventsWatcher | null = null;\n\n constructor(\n handler: BotHandler,\n config: { appToken: string; botToken: string; workingDir: string; store: ChannelStore },\n ) {\n this.handler = handler;\n this.workingDir = config.workingDir;\n this.store = config.store;\n this.socketClient = new SocketModeClient({\n appToken: config.appToken,\n // Default 5s is too tight: brief event-loop stalls (e.g. backfill, sync fs)\n // cause false pong timeouts; 4 in a row makes Slack drop the socket.\n clientPingTimeout: 12_000,\n });\n this.webClient = new WebClient(config.botToken);\n }\n\n setEventsWatcher(watcher: EventsWatcher): void {\n this.eventsWatcher = watcher;\n }\n\n // ==========================================================================\n // Public API\n // ==========================================================================\n\n async start(): Promise<void> {\n const auth = await this.webClient.auth.test();\n this.botUserId = auth.user_id as string;\n this.botId = typeof auth.bot_id === \"string\" ? auth.bot_id : null;\n\n await Promise.all([this.fetchUsers(), this.fetchChannels()]);\n log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);\n\n await this.backfillAllChannels();\n\n this.setupEventHandlers();\n await this.socketClient.start();\n\n // Record startup time - messages older than this are just logged, not processed\n this.startupTs = (Date.now() / 1000).toFixed(6);\n\n log.logConnected(\"Slack\");\n }\n\n getUser(userId: string): SlackUser | undefined {\n return this.users.get(userId);\n }\n\n getChannel(channelId: string): SlackChannel | undefined {\n return this.channels.get(channelId);\n }\n\n getAllUsers(): SlackUser[] {\n return Array.from(this.users.values());\n }\n\n getAllChannels(): SlackChannel[] {\n return Array.from(this.channels.values());\n }\n\n private stripOwnMention(text: string | undefined): string {\n const source = text ?? \"\";\n if (!this.botUserId) return source.trim();\n if (!this.ownMentionRegex || !this.ownMentionRegex.source.includes(this.botUserId)) {\n this.ownMentionRegex = new RegExp(`<@${this.botUserId}>`, \"gi\");\n }\n return source.replace(this.ownMentionRegex, \"\").trim();\n }\n\n async postMessage(channel: string, text: string): Promise<string> {\n return slackRetry(async () => {\n const result = await this.webClient.chat.postMessage({ channel, text });\n return result.ts as string;\n });\n }\n\n async postEphemeral(channel: string, user: string, text: string): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.chat.postEphemeral({ channel, user, text });\n });\n }\n\n async postEphemeralBlocks(\n channel: string,\n user: string,\n text: string,\n blocks: object[],\n ): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.chat.postEphemeral({ channel, user, text, blocks: blocks as any });\n });\n }\n\n async postMessageBlocks(channel: string, text: string, blocks: object[]): Promise<string> {\n return slackRetry(async () => {\n const result = await this.webClient.chat.postMessage({\n channel,\n text,\n blocks: blocks as any,\n });\n return result.ts as string;\n });\n }\n\n async postPrivate(conversationId: string, userId: string, text: string): Promise<void> {\n await this.postEphemeral(conversationId, userId, text);\n }\n\n async postPrivateDiagnostic(\n conversationId: string,\n userId: string,\n text: string,\n options?: { style?: \"muted\" | \"error\" },\n ): Promise<void> {\n if (options?.style !== \"muted\") {\n await this.postPrivate(\n conversationId,\n userId,\n options?.style === \"error\" ? `_${text}_` : text,\n );\n return;\n }\n const CONTEXT_TEXT_LIMIT = 3000;\n const blockText =\n text.length > CONTEXT_TEXT_LIMIT\n ? text.substring(0, CONTEXT_TEXT_LIMIT - 20) + \"\\n_(truncated)_\"\n : text;\n await this.postEphemeralBlocks(conversationId, userId, text, [\n { type: \"context\", elements: [{ type: \"mrkdwn\", text: blockText }] },\n ]);\n }\n\n async openDirectMessage(userId: string): Promise<string> {\n return slackRetry(async () => {\n const result = await this.webClient.conversations.open({ users: userId });\n const channelId = result.channel?.id;\n if (!channelId) {\n throw new Error(`Failed to open DM for user ${userId}`);\n }\n return channelId;\n });\n }\n\n async updateMessage(channel: string, ts: string, text: string): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.chat.update({ channel, ts, text });\n });\n }\n\n async deleteMessage(channel: string, ts: string): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.chat.delete({ channel, ts });\n });\n }\n\n // ==========================================================================\n // Slack Assistant API (AI assistant experience)\n // ==========================================================================\n\n /** Set the status for an assistant thread (shows \"thinking\" state) */\n async setAssistantStatus(channel: string, threadTs: string, status: string): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.assistant.threads.setStatus({\n channel_id: channel,\n thread_ts: threadTs,\n status,\n });\n });\n }\n\n async postInThread(channel: string, threadTs: string, text: string): Promise<string> {\n return slackRetry(async () => {\n // Use Block Kit section for long messages to trigger Slack's \"Show more\" collapsing (~700 chars)\n const SECTION_TEXT_LIMIT = 3000;\n if (text.length > 500) {\n const blockText =\n text.length > SECTION_TEXT_LIMIT\n ? text.substring(0, SECTION_TEXT_LIMIT - 20) + \"\\n_(truncated)_\"\n : text;\n const result = await this.webClient.chat.postMessage({\n channel,\n thread_ts: threadTs,\n text, // full text as notification fallback\n blocks: [{ type: \"section\", text: { type: \"mrkdwn\", text: blockText } }],\n });\n return result.ts as string;\n }\n const result = await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });\n return result.ts as string;\n });\n }\n\n async postInThreadBlocks(\n channel: string,\n threadTs: string,\n text: string,\n blocks: object[],\n ): Promise<string> {\n return slackRetry(async () => {\n const result = await this.webClient.chat.postMessage({\n channel,\n thread_ts: threadTs,\n text, // fallback for notifications\n blocks: blocks as any,\n });\n return result.ts as string;\n });\n }\n\n async uploadFile(\n channel: string,\n filePath: string,\n title?: string,\n threadTs?: string,\n ): Promise<void> {\n return slackRetry(async () => {\n const fileName = title || basename(filePath);\n const fileContent = readFileSync(filePath);\n await this.webClient.files.uploadV2({\n channel_id: channel,\n file: fileContent,\n filename: fileName,\n title: fileName,\n ...(threadTs ? { thread_ts: threadTs } : {}),\n } as Parameters<typeof this.webClient.files.uploadV2>[0]);\n });\n }\n\n logToFile(channel: string, entry: object): void {\n appendChannelLog(this.workingDir, channel, entry);\n }\n\n logBotResponse(channel: string, text: string, ts: string, threadTs?: string): void {\n appendBotResponseLog(this.workingDir, channel, text, ts, threadTs);\n }\n\n aliasSyntheticEventThread(channel: string, threadTs: string, eventTs: string): void {\n const conversationDir = join(this.workingDir, channel);\n const source = getThreadSessionFile(conversationDir, `${channel}:${eventTs}`);\n const target = getThreadSessionFile(conversationDir, `${channel}:${threadTs}`);\n if (source === target) return;\n\n try {\n linkSync(source, target);\n log.logInfo(`Aliased synthetic event session ${source} -> ${target}`);\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code === \"EEXIST\") return;\n if (code === \"ENOENT\") {\n log.logWarning(`Cannot alias synthetic event session; source missing: ${source}`);\n return;\n }\n log.logWarning(\n `Failed to alias synthetic event session ${source} -> ${target}`,\n err instanceof Error ? err.message : String(err),\n );\n }\n }\n\n getPlatformInfo(): PlatformInfo {\n return {\n name: \"slack\",\n formattingGuide:\n \"## Slack Formatting (mrkdwn, NOT Markdown)\\nBold: *text*, Italic: _text_, Code: `code`, Block: ```code```, Links: <url|text>\\nDo NOT use **double asterisks** or [markdown](links).\",\n channels: this.getAllChannels().map((c) => ({ id: c.id, name: c.name })),\n users: this.getAllUsers().map((u) => ({\n id: u.id,\n userName: u.userName,\n displayName: u.displayName,\n })),\n diagnostics: {\n showUsageSummary: true,\n },\n };\n }\n\n // ==========================================================================\n // Events Integration\n // ==========================================================================\n\n /**\n * Enqueue an event for processing. Always queues (no \"already working\" rejection).\n * Returns true if enqueued, false if queue is full (max 5).\n */\n enqueueEvent(event: BotEvent): boolean {\n const conversationId = event.conversationId;\n const queue = this.getQueue(conversationId);\n if (queue.size() >= 5) {\n log.logWarning(\n `Event queue full for ${conversationId}, discarding: ${event.text.substring(0, 50)}`,\n );\n return false;\n }\n log.logInfo(`Enqueueing event for ${conversationId}: ${event.text.substring(0, 50)}`);\n queue.enqueue(() => {\n const slackEvent: SlackEvent = {\n type: event.type as SlackEvent[\"type\"],\n conversationId,\n conversationKind: event.conversationKind,\n channel: conversationId,\n ts: event.ts,\n thread_ts: event.thread_ts,\n user: event.user,\n text: event.text,\n attachments: event.attachments?.map((attachment) => ({\n original: attachment.name,\n localPath: attachment.localPath,\n })),\n sessionKey: event.sessionKey,\n };\n const adapters = createSlackAdapters(slackEvent, this, true);\n return this.handler.handleEvent(event, this, adapters, true);\n });\n return true;\n }\n\n // ==========================================================================\n // Private - Event Handlers\n // ==========================================================================\n\n private getQueue(channelId: string): ChannelQueue {\n let queue = this.queues.get(channelId);\n if (!queue) {\n queue = new ChannelQueue(\"Slack\");\n this.queues.set(channelId, queue);\n }\n return queue;\n }\n\n private resolveQueueKey(conversationId: string, sessionKey: string): string {\n if (!sessionKey.includes(\":\")) return sessionKey;\n if (sessionKey.includes(\":event:\")) return sessionKey;\n return hasMaterializedSlackBranchSession(join(this.workingDir, conversationId), sessionKey)\n ? sessionKey\n : conversationId;\n }\n\n private shouldTriggerSharedThreadReply(channelId: string, threadTs?: string): boolean {\n if (!threadTs) return false;\n\n const sessionKey = resolveSlackSessionKey(channelId, threadTs);\n if (this.handler.isRunning(sessionKey)) return true;\n\n return hasMaterializedSlackBranchSession(join(this.workingDir, channelId), sessionKey);\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n private buildHomeView(): { type: \"home\"; blocks: any[] } {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const blocks: any[] = [\n {\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `*${PRODUCT_NAME}*\\nStart a new task or check on running work.`,\n },\n accessory: {\n type: \"image\",\n image_url: \"https://media1.tenor.com/m/lfDATg4Bhc0AAAAC/happy-cat.gif\",\n alt_text: PRODUCT_NAME,\n },\n },\n ];\n\n // --- Running tasks ---\n const runningSessions = this.handler.getRunningSessions();\n\n blocks.push(\n { type: \"divider\" },\n {\n type: \"header\",\n text: {\n type: \"plain_text\",\n text: `Running Tasks (${runningSessions.length})`,\n emoji: true,\n },\n },\n );\n\n if (runningSessions.length === 0) {\n blocks.push({\n type: \"context\",\n elements: [{ type: \"mrkdwn\", text: \"_No tasks running right now._\" }],\n });\n } else {\n // Threshold for \"stuck\" detection (10 minutes)\n const STUCK_THRESHOLD_MS = 10 * 60 * 1000;\n\n for (const session of runningSessions) {\n const channelId = session.sessionKey.split(\":\")[0];\n const channel = this.channels.get(channelId);\n const channelName = channel ? `#${channel.name}` : channelId;\n const elapsed = Math.floor((Date.now() - session.startedAt) / 60000);\n const elapsedStr = elapsed < 1 ? \"<1 min\" : `${elapsed} min`;\n\n // Check if task might be stuck\n const lastActivity = session.lastActivityAt ? Date.now() - session.lastActivityAt : 0;\n const isStuck = lastActivity > STUCK_THRESHOLD_MS;\n const statusText = isStuck ? \"_stuck_\" : \"_running_\";\n\n // Build status line: channel · status · time · step\n let statusLine = `${statusText} · ${elapsedStr}`;\n if (session.currentTool) {\n statusLine += ` · ${session.currentTool}`;\n }\n if (isStuck && lastActivity > 0) {\n const inactiveMin = Math.floor(lastActivity / 60000);\n statusLine += ` · idle ${inactiveMin}m`;\n }\n\n // Use context block for gray small text (like \"No scheduled jobs.\")\n blocks.push({\n type: \"context\",\n elements: [\n {\n type: \"mrkdwn\",\n text: `*${channelName}* · ${statusLine}`,\n },\n ],\n });\n\n // Add Force Stop button as separate element if stuck\n if (isStuck) {\n blocks.push({\n type: \"context\",\n elements: [\n {\n type: \"mrkdwn\",\n text: \" \",\n },\n {\n type: \"button\",\n text: { type: \"plain_text\", text: \"Force Stop\", emoji: true },\n action_id: `force_stop_${session.sessionKey.replace(/:/g, \"_\")}`,\n style: \"danger\",\n },\n ],\n });\n }\n }\n }\n\n // --- Cron jobs ---\n const periodicEvents = this.eventsWatcher?.getPeriodicEvents() ?? [];\n\n blocks.push(\n { type: \"divider\" },\n {\n type: \"header\",\n text: {\n type: \"plain_text\",\n text: `Scheduled Jobs (${periodicEvents.length})`,\n emoji: true,\n },\n },\n );\n\n if (periodicEvents.length === 0) {\n blocks.push({\n type: \"context\",\n elements: [{ type: \"mrkdwn\", text: \"_No scheduled jobs._\" }],\n });\n } else {\n for (const ev of periodicEvents) {\n const channelLabel =\n ev.platform === \"slack\"\n ? (() => {\n const channel = this.channels.get(ev.conversationId);\n const channelName = channel ? `#${channel.name}` : ev.conversationId;\n return `${ev.platform}:${channelName}`;\n })()\n : `${ev.platform}:${ev.conversationId}`;\n const nextStr = ev.nextRun\n ? new Date(ev.nextRun).toLocaleString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n })\n : \"—\";\n blocks.push({\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `*${ev.text}*\\n└ \\`${ev.schedule}\\` · ${channelLabel} · Next: ${nextStr}`,\n },\n });\n }\n }\n\n // --- Footer ---\n blocks.push(\n { type: \"divider\" },\n {\n type: \"context\",\n elements: [\n { type: \"mrkdwn\", text: \"💡 @mention in a channel or send a DM to start a new task\" },\n ],\n },\n );\n\n return { type: \"home\", blocks };\n }\n\n private resolveStopTarget(channelId: string, threadTs?: string): string | null {\n const directTarget = resolveStopTarget({\n handler: this.handler,\n conversationId: channelId,\n sessionKey: resolveSlackSessionKey(channelId, threadTs),\n });\n if (directTarget) return directTarget;\n if (threadTs) return null;\n return resolveOnlyScopedStopTarget(this.handler, channelId);\n }\n\n private isStopText(text: string): boolean {\n const normalized = text.trim().toLowerCase();\n return normalized === \"stop\" || normalized === \"/stop\";\n }\n\n private createCommandAdapters(\n conversationId: string,\n userId: string,\n userName: string | undefined,\n text: string,\n ts: string,\n options: { ephemeralChannelId?: string } = {},\n ): BotAdapters {\n const message: ChatMessage = {\n id: ts,\n sessionKey: conversationId,\n conversationKind: options.ephemeralChannelId ? \"shared\" : \"direct\",\n userId,\n userName,\n text,\n attachments: [],\n };\n\n const respond = async (responseText: string) => {\n if (options.ephemeralChannelId) {\n await this.postEphemeral(options.ephemeralChannelId, userId, responseText);\n return;\n }\n const messageTs = await this.postMessage(conversationId, responseText);\n this.logBotResponse(conversationId, responseText, messageTs);\n };\n\n const respondMuted = async (responseText: string) => {\n const CONTEXT_TEXT_LIMIT = 3000;\n const blockText =\n responseText.length > CONTEXT_TEXT_LIMIT\n ? responseText.substring(0, CONTEXT_TEXT_LIMIT - 20) + \"\\n_(truncated)_\"\n : responseText;\n const blocks = [{ type: \"context\", elements: [{ type: \"mrkdwn\", text: blockText }] }];\n if (options.ephemeralChannelId) {\n await this.postEphemeralBlocks(options.ephemeralChannelId, userId, responseText, blocks);\n return;\n }\n const messageTs = await this.postMessageBlocks(conversationId, responseText, blocks);\n this.logBotResponse(conversationId, responseText, messageTs);\n };\n\n const responseCtx: ChatResponseContext = {\n respond,\n replaceResponse: respond,\n respondDiagnostic: async (\n responseText: string,\n responseOptions?: { style?: \"muted\" | \"error\" },\n ) => {\n if (responseOptions?.style === \"muted\") {\n await respondMuted(responseText);\n return;\n }\n await respond(responseOptions?.style === \"error\" ? `_${responseText}_` : responseText);\n },\n respondToolResult: async (result: ChatToolResult) => {\n const duration = (result.durationMs / 1000).toFixed(1);\n await respond(\n `${result.isError ? \"Error\" : \"Done\"} ${result.toolName} (${duration}s)\\n${result.result}`,\n );\n },\n setTyping: async () => {},\n setWorking: async () => {},\n uploadFile: async (filePath: string, title?: string) => {\n await this.uploadFile(conversationId, filePath, title);\n },\n deleteResponse: async () => {},\n };\n\n return {\n message,\n responseCtx,\n platform: this.getPlatformInfo(),\n };\n }\n\n private createSlashCommandBot(conversationId: string, threadTs?: string): Bot {\n return {\n start: async () => {},\n postMessage: async (_channel: string, text: string) => {\n if (threadTs) {\n return this.postInThread(conversationId, threadTs, text);\n }\n return this.postMessage(conversationId, text);\n },\n updateMessage: async (channel: string, ts: string, text: string) => {\n await this.updateMessage(channel, ts, text);\n },\n enqueueEvent: (event: BotEvent) => this.enqueueEvent(event),\n getPlatformInfo: () => this.getPlatformInfo(),\n };\n }\n\n private async routeSlashLoginCommand(payload: {\n command: string;\n text?: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n const commandSuffix = payload.text?.trim();\n const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const sourceChannelId = payload.channel_id;\n const isDirectMessage = sourceChannelId.startsWith(\"D\");\n const targetChannelId = isDirectMessage\n ? sourceChannelId\n : await this.openDirectMessage(payload.user_id);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n\n this.logToFile(targetChannelId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: commandText,\n attachments: [],\n isBot: false,\n });\n\n if (!isDirectMessage) {\n await this.postEphemeral(\n sourceChannelId,\n payload.user_id,\n `我已私訊你 ${PRODUCT_NAME} 的登入連結,請到私訊完成設定。`,\n );\n }\n\n const event: BotEvent = {\n type: \"dm\",\n conversationId: targetChannelId,\n ...(isDirectMessage ? {} : { vaultConversationId: sourceChannelId }),\n conversationKind: \"direct\",\n ts: eventTs,\n user: payload.user_id,\n text: commandText,\n attachments: [],\n sessionKey: targetChannelId,\n };\n\n const adapters = this.createCommandAdapters(\n targetChannelId,\n payload.user_id,\n userName,\n commandText,\n eventTs,\n );\n\n await this.handler.handleEvent(event, this, adapters, false);\n }\n\n private async routeSlashNewCommand(payload: {\n command: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n const conversationId = payload.channel_id;\n if (!conversationId.startsWith(\"D\")) {\n await this.postEphemeral(\n conversationId,\n payload.user_id,\n `為了避免誤清除共享上下文,${payload.command} 目前只能在與 ${PRODUCT_NAME} 的私訊中使用。`,\n );\n return;\n }\n\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n\n this.logToFile(conversationId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: payload.command,\n attachments: [],\n isBot: false,\n });\n\n const commandBot = this.createSlashCommandBot(conversationId);\n await this.handler.handleNewCommand(conversationId, conversationId, commandBot);\n }\n\n private async routeSlashModelCommand(payload: {\n command: string;\n text?: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n const conversationId = payload.channel_id;\n const isDirectMessage = conversationId.startsWith(\"D\");\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n const commandSuffix = payload.text?.trim();\n const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;\n\n this.logToFile(conversationId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: commandText,\n attachments: [],\n isBot: false,\n });\n\n const sessionKey = conversationId;\n const event: BotEvent = {\n type: isDirectMessage ? \"dm\" : \"mention\",\n conversationId,\n conversationKind: isDirectMessage ? \"direct\" : \"shared\",\n ts: eventTs,\n user: payload.user_id,\n text: commandText,\n attachments: [],\n sessionKey,\n };\n\n const adapters = this.createCommandAdapters(\n conversationId,\n payload.user_id,\n userName,\n commandText,\n eventTs,\n isDirectMessage ? {} : { ephemeralChannelId: conversationId },\n );\n\n await this.handler.handleEvent(event, this, adapters, false);\n }\n\n private async routeSlashSandboxCommand(payload: {\n command: string;\n text?: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n await this.routeSlashModelCommand(payload);\n }\n\n private async routeSlashAutoReplyCommand(payload: {\n command: string;\n text?: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n await this.routeSlashModelCommand(payload);\n }\n\n private async routeSlashSessionCommand(payload: {\n command: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n const conversationId = payload.channel_id;\n const isDirectMessage = conversationId.startsWith(\"D\");\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n const commandText = payload.command;\n\n this.logToFile(conversationId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: commandText,\n attachments: [],\n isBot: false,\n });\n\n const sessionKey = conversationId;\n const event: BotEvent = {\n type: isDirectMessage ? \"dm\" : \"mention\",\n conversationId,\n conversationKind: isDirectMessage ? \"direct\" : \"shared\",\n ts: eventTs,\n user: payload.user_id,\n text: commandText,\n attachments: [],\n sessionKey,\n };\n\n const adapters = this.createCommandAdapters(\n conversationId,\n payload.user_id,\n userName,\n commandText,\n eventTs,\n isDirectMessage ? {} : { ephemeralChannelId: conversationId },\n );\n\n await this.handler.handleEvent(event, this, adapters, false);\n }\n\n private setupEventHandlers(): void {\n this.socketClient.on(\"disconnect\", (err: unknown) => {\n log.logWarning(\"Slack socket disconnect\", err ? String(err) : \"\");\n });\n this.socketClient.on(\"error\", (err: unknown) => {\n log.logWarning(\"Slack socket error\", err ? String(err) : \"\");\n });\n this.socketClient.on(\"unable_to_socket_mode_start\", (err: unknown) => {\n log.logWarning(\"Slack socket unable_to_start\", err ? String(err) : \"\");\n });\n\n // Channel @mentions\n this.socketClient.on(\"app_mention\", ({ event, ack }) => {\n const e = event as {\n text: string;\n channel: string;\n user: string;\n ts: string;\n thread_ts?: string;\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n };\n\n // Skip DMs (handled by message event)\n if (e.channel.startsWith(\"D\")) {\n ack();\n return;\n }\n\n // Top-level mentions use a persistent channel session.\n // Thread replies get their own isolated session (channelId:thread_ts).\n const sessionKey = resolveSlackSessionKey(e.channel, e.thread_ts);\n\n const slackEvent: SlackEvent = {\n type: \"mention\",\n conversationId: e.channel,\n conversationKind: \"shared\",\n channel: e.channel,\n ts: e.ts,\n thread_ts: e.thread_ts,\n user: e.user,\n text: this.stripOwnMention(e.text),\n files: e.files,\n sessionKey,\n };\n\n const attachmentsPromise = this.logUserMessage(slackEvent);\n\n // Only trigger processing for messages AFTER startup (not replayed old messages)\n if (this.startupTs && e.ts < this.startupTs) {\n log.logInfo(\n `[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`,\n );\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n // Check for stop command - execute immediately, don't queue!\n if (this.isStopText(slackEvent.text)) {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this);\n } else {\n this.postMessage(e.channel, formatNothingRunning(\"slack\"));\n }\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n this.getQueue(this.resolveQueueKey(e.channel, sessionKey)).enqueue(async () => {\n slackEvent.attachments = await attachmentsPromise;\n const adapters = createSlackAdapters(slackEvent, this, false);\n return this.handler.handleEvent(\n slackEvent as unknown as import(\"../../adapter.js\").BotEvent,\n this,\n adapters,\n false,\n );\n });\n\n ack();\n });\n\n // All messages (for logging) + DMs (for triggering)\n this.socketClient.on(\"message\", ({ event, ack }) => {\n const e = event as {\n text?: string;\n channel: string;\n user?: string;\n ts: string;\n thread_ts?: string;\n channel_type?: string;\n subtype?: string;\n bot_id?: string;\n app_id?: string;\n username?: string;\n bot_profile?: { id?: string; app_id?: string; name?: string; real_name?: string };\n blocks?: unknown[];\n attachments?: unknown[];\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n };\n\n const hasFiles = !!e.files && e.files.length > 0;\n const hasSlackContent = !!e.text || hasFiles || !!e.blocks?.length || !!e.attachments?.length;\n const isOwnBotMessage =\n (!!e.user && e.user === this.botUserId) || (!!this.botId && e.bot_id === this.botId);\n if (isOwnBotMessage) {\n ack();\n return;\n }\n\n const isExternalBotMessage = !!e.bot_id || e.subtype === \"bot_message\";\n if (isExternalBotMessage) {\n if (e.subtype !== undefined && e.subtype !== \"bot_message\" && e.subtype !== \"file_share\") {\n ack();\n return;\n }\n if (!hasSlackContent) {\n ack();\n return;\n }\n void this.logExternalBotMessage(e).catch((err) => {\n log.logWarning(\"Failed to log Slack bot message\", String(err));\n });\n ack();\n return;\n }\n\n if (!e.user) {\n ack();\n return;\n }\n if (e.subtype !== undefined && e.subtype !== \"file_share\") {\n ack();\n return;\n }\n if (!hasSlackContent) {\n ack();\n return;\n }\n\n const isDM = e.channel_type === \"im\";\n const conversationKind: ConversationKind = isDM ? \"direct\" : \"shared\";\n const isBotMention = e.text?.includes(`<@${this.botUserId}>`);\n\n // Skip channel @mentions - already handled by app_mention event\n if (!isDM && isBotMention) {\n ack();\n return;\n }\n\n const isSharedThreadReply =\n !isDM && this.shouldTriggerSharedThreadReply(e.channel, e.thread_ts);\n const sessionKey =\n isDM || isSharedThreadReply ? resolveSlackSessionKey(e.channel, e.thread_ts) : undefined;\n\n const slackEvent: SlackEvent = {\n type: isDM ? \"dm\" : \"mention\",\n conversationId: e.channel,\n conversationKind,\n channel: e.channel,\n ts: e.ts,\n thread_ts: e.thread_ts,\n user: e.user,\n text: this.stripOwnMention(e.text),\n files: e.files,\n sessionKey,\n };\n\n const attachmentsPromise = this.logUserMessage(slackEvent);\n\n // Only trigger processing for messages AFTER startup (not replayed old messages)\n if (this.startupTs && e.ts < this.startupTs) {\n log.logInfo(\n `[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`,\n );\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n // Stop command for DM or shared-channel thread reply (app_mention handles \"@mama stop\").\n if ((isDM || (!isDM && e.thread_ts)) && this.isStopText(slackEvent.text)) {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this);\n } else {\n this.postMessage(e.channel, formatNothingRunning(\"slack\"));\n }\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n const enqueueTriggered = () => {\n const activeSessionKey =\n slackEvent.sessionKey ?? resolveSlackSessionKey(e.channel, e.thread_ts);\n this.getQueue(this.resolveQueueKey(e.channel, activeSessionKey)).enqueue(async () => {\n slackEvent.attachments = await attachmentsPromise;\n const adapters = createSlackAdapters(slackEvent, this, false);\n return this.handler.handleEvent(\n slackEvent as unknown as import(\"../../adapter.js\").BotEvent,\n this,\n adapters,\n false,\n );\n });\n };\n\n const logOnly = () => {\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n };\n\n if (isDM || isSharedThreadReply) {\n enqueueTriggered();\n ack();\n return;\n }\n\n // Shared-channel non-mention, non-thread: gate via auto-reply policy.\n // evaluateAutoReplyPolicy never throws — judge errors/timeouts surface as\n // trigger:false with a distinct reason, and the user message has already\n // been queued for logging via logUserMessage above.\n evaluateAutoReplyPolicy({\n event: slackEvent as unknown as import(\"../../adapter.js\").BotEvent,\n workingDir: this.workingDir,\n }).then((triggerResult) => {\n if (triggerResult.trigger) enqueueTriggered();\n else logOnly();\n });\n\n ack();\n });\n\n this.socketClient.on(\"slash_commands\", async ({ body, ack }) => {\n const payload = body as {\n command?: string;\n text?: string;\n channel_id?: string;\n user_id?: string;\n user_name?: string;\n };\n\n await ack();\n\n if (!payload.command || !payload.channel_id || !payload.user_id) {\n return;\n }\n\n const handlerPromise =\n payload.command === \"/pi-login\"\n ? this.routeSlashLoginCommand({\n command: payload.command,\n text: payload.text,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-new\"\n ? this.routeSlashNewCommand({\n command: payload.command,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-session\"\n ? this.routeSlashSessionCommand({\n command: payload.command,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-model\"\n ? this.routeSlashModelCommand({\n command: payload.command,\n text: payload.text,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-sandbox\"\n ? this.routeSlashSandboxCommand({\n command: payload.command,\n text: payload.text,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-auto-reply\"\n ? this.routeSlashAutoReplyCommand({\n command: payload.command,\n text: payload.text,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : null;\n\n if (!handlerPromise) {\n return;\n }\n\n handlerPromise.catch((err) => {\n log.logWarning(\n \"Slack slash command error\",\n err instanceof Error ? err.message : String(err),\n );\n });\n });\n\n // App Home tab\n this.socketClient.on(\"app_home_opened\", ({ event, ack }) => {\n const e = event as { user: string; tab: string };\n ack();\n if (e.tab !== \"home\") return;\n\n this.webClient.views\n .publish({\n user_id: e.user,\n view: this.buildHomeView(),\n })\n .catch((err) => {\n log.logWarning(`Failed to publish App Home view`, String(err));\n });\n });\n\n // Handle button clicks (Force Stop)\n this.socketClient.on(\"block_actions\", async ({ body, ack }) => {\n const action = body.actions?.[0];\n if (!action || !action.action_id?.startsWith(\"force_stop_\")) {\n ack();\n return;\n }\n\n ack();\n const sessionKey = action.action_id.replace(\"force_stop_\", \"\").replace(/_/g, \":\");\n const userId = body.user?.id;\n const channelId = body.container?.channel_id || sessionKey.split(\":\")[0];\n\n log.logInfo(`[Force Stop] User ${userId} requested force stop for ${sessionKey}`);\n\n // Use handler's forceStop method\n this.handler.forceStop(sessionKey);\n\n // Notify in channel\n await this.postMessage(channelId, formatForceStopped(\"slack\", userId ?? \"unknown\"));\n\n // Refresh home tab\n if (userId) {\n this.webClient.views\n .publish({\n user_id: userId,\n view: this.buildHomeView(),\n })\n .catch((err) => {\n log.logWarning(`Failed to refresh App Home view`, String(err));\n });\n }\n });\n }\n\n /**\n * Log a user message to log.jsonl after attachments are ready.\n */\n private async logUserMessage(event: SlackEvent): Promise<Attachment[]> {\n const user = this.users.get(event.user);\n let attachments: Attachment[] = [];\n let attachmentError: unknown;\n if (event.files) {\n try {\n attachments = await this.store.processAttachments(event.channel, event.files, event.ts);\n } catch (err) {\n attachmentError = err;\n }\n }\n // Always write the text log, even if attachment processing failed — we want\n // a record of the user message regardless of file-handling errors.\n this.logToFile(event.channel, {\n date: new Date(parseFloat(event.ts) * 1000).toISOString(),\n ts: event.ts,\n threadTs: event.thread_ts,\n user: event.user,\n userName: user?.userName,\n displayName: user?.displayName,\n text: event.text,\n attachments,\n isBot: false,\n });\n if (attachmentError) throw attachmentError;\n return attachments;\n }\n\n private async logExternalBotMessage(event: {\n channel: string;\n ts: string;\n thread_ts?: string;\n text?: string;\n subtype?: string;\n bot_id?: string;\n app_id?: string;\n username?: string;\n bot_profile?: { app_id?: string; name?: string; real_name?: string };\n blocks?: unknown[];\n attachments?: unknown[];\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n }): Promise<Attachment[]> {\n const attachments = event.files\n ? await this.store.processAttachments(event.channel, event.files, event.ts)\n : [];\n const botName =\n event.username ?? event.bot_profile?.name ?? event.bot_profile?.real_name ?? event.bot_id;\n this.logToFile(event.channel, {\n date: new Date(parseFloat(event.ts) * 1000).toISOString(),\n ts: event.ts,\n threadTs: event.thread_ts,\n user: event.bot_id ? `bot:${event.bot_id}` : \"external-bot\",\n userName: botName,\n displayName: botName,\n text: buildSlackAppMessageText(event),\n attachments,\n isBot: true,\n botId: event.bot_id,\n appId: event.app_id ?? event.bot_profile?.app_id,\n subtype: event.subtype,\n });\n return attachments;\n }\n\n // ==========================================================================\n // Private - Backfill\n // ==========================================================================\n\n private async getExistingTimestamps(channelId: string): Promise<Set<string>> {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n const timestamps = new Set<string>();\n if (!existsSync(logPath)) return timestamps;\n\n const content = await readFile(logPath, \"utf-8\");\n const lines = content.trim().split(\"\\n\").filter(Boolean);\n for (let i = 0; i < lines.length; i++) {\n try {\n const entry = JSON.parse(lines[i]);\n if (entry.ts) timestamps.add(entry.ts);\n } catch (err) {\n log.logWarning(\n `Skipping malformed log entry at ${logPath}:${i + 1}`,\n err instanceof Error ? err.message : String(err),\n );\n }\n }\n return timestamps;\n }\n\n private async backfillChannel(channelId: string): Promise<number> {\n const existingTs = await this.getExistingTimestamps(channelId);\n\n // Find the biggest ts in log.jsonl\n let latestTs: string | undefined;\n for (const ts of existingTs) {\n if (!latestTs || parseFloat(ts) > parseFloat(latestTs)) latestTs = ts;\n }\n\n type Message = {\n user?: string;\n bot_id?: string;\n app_id?: string;\n username?: string;\n bot_profile?: { app_id?: string; name?: string; real_name?: string };\n blocks?: unknown[];\n attachments?: unknown[];\n text?: string;\n ts?: string;\n thread_ts?: string;\n subtype?: string;\n files?: Array<{ name: string }>;\n };\n const allMessages: Message[] = [];\n\n let cursor: string | undefined;\n let pageCount = 0;\n const maxPages = 3;\n\n do {\n const result = await this.webClient.conversations.history({\n channel: channelId,\n oldest: latestTs, // Only fetch messages newer than what we have\n inclusive: false,\n limit: 1000,\n cursor,\n });\n if (result.messages) {\n allMessages.push(...(result.messages as Message[]));\n }\n cursor = result.response_metadata?.next_cursor;\n pageCount++;\n } while (cursor && pageCount < maxPages);\n\n // Filter: include mama's messages, external app/bot messages, and user messages.\n const relevantMessages = allMessages.filter((msg) => {\n if (!msg.ts || existingTs.has(msg.ts)) return false; // Skip duplicates\n if (msg.user === this.botUserId) return true;\n const isExternalBotMessage = !!msg.bot_id || msg.subtype === \"bot_message\";\n if (isExternalBotMessage) {\n if (this.botId && msg.bot_id === this.botId) return false;\n if (\n msg.subtype !== undefined &&\n msg.subtype !== \"bot_message\" &&\n msg.subtype !== \"file_share\"\n ) {\n return false;\n }\n return (\n !!msg.text ||\n !!(msg.files && msg.files.length > 0) ||\n !!msg.blocks?.length ||\n !!msg.attachments?.length\n );\n }\n if (msg.subtype !== undefined && msg.subtype !== \"file_share\") return false;\n if (!msg.user) return false;\n if (!msg.text && (!msg.files || msg.files.length === 0)) return false;\n return true;\n });\n\n // Reverse to chronological order\n relevantMessages.reverse();\n\n // Log each message to log.jsonl\n for (const msg of relevantMessages) {\n const isMamaMessage = msg.user === this.botUserId;\n const isExternalBotMessage =\n !isMamaMessage && (!!msg.bot_id || msg.subtype === \"bot_message\");\n if (isExternalBotMessage) {\n await this.logExternalBotMessage({ ...msg, channel: channelId, ts: msg.ts! });\n continue;\n }\n\n const user = this.users.get(msg.user!);\n const text = this.stripOwnMention(msg.text);\n const attachments = msg.files\n ? await this.store.processAttachments(channelId, msg.files, msg.ts!)\n : [];\n\n this.logToFile(channelId, {\n date: new Date(parseFloat(msg.ts!) * 1000).toISOString(),\n ts: msg.ts!,\n threadTs: msg.thread_ts,\n user: isMamaMessage ? \"bot\" : msg.user!,\n userName: isMamaMessage ? undefined : user?.userName,\n displayName: isMamaMessage ? undefined : user?.displayName,\n text,\n attachments,\n isBot: isMamaMessage,\n });\n }\n\n return relevantMessages.length;\n }\n\n private async backfillAllChannels(): Promise<void> {\n const startTime = Date.now();\n\n // Only backfill channels that already have a log.jsonl (mama has interacted with them before)\n const channelsToBackfill: Array<[string, SlackChannel]> = [];\n for (const [channelId, channel] of this.channels) {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n if (existsSync(logPath)) {\n channelsToBackfill.push([channelId, channel]);\n }\n }\n\n log.logBackfillStart(channelsToBackfill.length);\n\n let totalMessages = 0;\n for (const [channelId, channel] of channelsToBackfill) {\n try {\n const count = await this.backfillChannel(channelId);\n if (count > 0) log.logBackfillChannel(channel.name, count);\n totalMessages += count;\n } catch (error) {\n log.logWarning(`Failed to backfill #${channel.name}`, String(error));\n }\n\n // Add delay between channels to avoid hitting Slack rate limits\n if (channelId !== channelsToBackfill[channelsToBackfill.length - 1][0]) {\n await new Promise((resolve) => setTimeout(resolve, 500));\n }\n }\n\n const durationMs = Date.now() - startTime;\n log.logBackfillComplete(totalMessages, durationMs);\n }\n\n // ==========================================================================\n // Private - Fetch Users/Channels\n // ==========================================================================\n\n private async fetchUsers(): Promise<void> {\n let cursor: string | undefined;\n do {\n const result = await this.webClient.users.list({ limit: 200, cursor });\n const members = result.members as\n | Array<{ id?: string; name?: string; real_name?: string; deleted?: boolean }>\n | undefined;\n if (members) {\n for (const u of members) {\n if (u.id && u.name && !u.deleted) {\n this.users.set(u.id, {\n id: u.id,\n userName: u.name,\n displayName: u.real_name || u.name,\n });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n }\n\n private async fetchChannels(): Promise<void> {\n // Fetch public/private channels\n let cursor: string | undefined;\n do {\n const result = await this.webClient.conversations.list({\n types: \"public_channel,private_channel\",\n exclude_archived: true,\n limit: 200,\n cursor,\n });\n const channels = result.channels as\n | Array<{ id?: string; name?: string; is_member?: boolean }>\n | undefined;\n if (channels) {\n for (const c of channels) {\n if (c.id && c.name && c.is_member) {\n this.channels.set(c.id, { id: c.id, name: c.name });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n\n // Also fetch DM channels (IMs)\n cursor = undefined;\n do {\n const result = await this.webClient.conversations.list({\n types: \"im\",\n limit: 200,\n cursor,\n });\n const ims = result.channels as Array<{ id?: string; user?: string }> | undefined;\n if (ims) {\n for (const im of ims) {\n if (im.id) {\n // Use user's name as channel name for DMs\n const user = im.user ? this.users.get(im.user) : undefined;\n const name = user ? `DM:${user.userName}` : `DM:${im.id}`;\n this.channels.set(im.id, { id: im.id, name });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n }\n}\n"]}