@geminixiang/mama 0.2.0-beta.3 → 0.2.0-beta.4

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 (46) hide show
  1. package/README.md +2 -2
  2. package/dist/adapters/discord/bot.d.ts.map +1 -1
  3. package/dist/adapters/discord/bot.js +58 -72
  4. package/dist/adapters/discord/bot.js.map +1 -1
  5. package/dist/adapters/shared.d.ts +48 -0
  6. package/dist/adapters/shared.d.ts.map +1 -1
  7. package/dist/adapters/shared.js +111 -0
  8. package/dist/adapters/shared.js.map +1 -1
  9. package/dist/adapters/slack/bot.d.ts +2 -19
  10. package/dist/adapters/slack/bot.d.ts.map +1 -1
  11. package/dist/adapters/slack/bot.js +49 -185
  12. package/dist/adapters/slack/bot.js.map +1 -1
  13. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  14. package/dist/adapters/telegram/bot.js +78 -100
  15. package/dist/adapters/telegram/bot.js.map +1 -1
  16. package/dist/agent.d.ts.map +1 -1
  17. package/dist/agent.js +2 -0
  18. package/dist/agent.js.map +1 -1
  19. package/dist/bindings.d.ts.map +1 -1
  20. package/dist/bindings.js +3 -2
  21. package/dist/bindings.js.map +1 -1
  22. package/dist/config.d.ts.map +1 -1
  23. package/dist/config.js +3 -2
  24. package/dist/config.js.map +1 -1
  25. package/dist/fs-atomic.d.ts +10 -0
  26. package/dist/fs-atomic.d.ts.map +1 -0
  27. package/dist/fs-atomic.js +45 -0
  28. package/dist/fs-atomic.js.map +1 -0
  29. package/dist/main.d.ts.map +1 -1
  30. package/dist/main.js +5 -7
  31. package/dist/main.js.map +1 -1
  32. package/dist/session-store.d.ts +5 -1
  33. package/dist/session-store.d.ts.map +1 -1
  34. package/dist/session-store.js +14 -9
  35. package/dist/session-store.js.map +1 -1
  36. package/dist/session-view/portal.d.ts +2 -0
  37. package/dist/session-view/portal.d.ts.map +1 -1
  38. package/dist/session-view/portal.js +35 -6
  39. package/dist/session-view/portal.js.map +1 -1
  40. package/dist/session-view/service.d.ts.map +1 -1
  41. package/dist/session-view/service.js +58 -22
  42. package/dist/session-view/service.js.map +1 -1
  43. package/dist/vault.d.ts.map +1 -1
  44. package/dist/vault.js +11 -55
  45. package/dist/vault.js.map +1 -1
  46. package/package.json +7 -8
@@ -1,81 +1,24 @@
1
1
  import { SocketModeClient } from "@slack/socket-mode";
2
2
  import { WebClient } from "@slack/web-api";
3
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
3
+ import { existsSync, readFileSync } from "fs";
4
4
  import { readFile } from "fs/promises";
5
5
  import { basename, join } from "path";
6
6
  import * as log from "../../log.js";
7
7
  import { PRODUCT_NAME, formatForceStopped, formatNothingRunning } from "../../ui-copy.js";
8
+ import { appendBotResponseLog, appendChannelLog, ChannelQueue, resolveOnlyScopedStopTarget, resolveStopTarget, withRetry, } from "../shared.js";
9
+ // Slack WebClient errors carry either `code: "rate_limited"` (retry-after) or
10
+ // the legacy `data.error === "rate_limited"` / 429 status shape.
11
+ function slackIsRateLimited(err) {
12
+ if (err.code === "rate_limited")
13
+ return true;
14
+ const data = err.data;
15
+ return data?.error === "rate_limited" || data?.response?.status === 429;
16
+ }
17
+ const slackRetry = (fn) => withRetry(fn, { isRateLimited: slackIsRateLimited });
8
18
  import { createSlackAdapters } from "./context.js";
9
19
  import { hasMaterializedSlackBranchSession } from "./branch-manager.js";
10
20
  import { resolveSlackSessionKey } from "./session.js";
11
21
  // ============================================================================
12
- // Exponential backoff utility for Slack API calls
13
- // ============================================================================
14
- /**
15
- * Retry a function with exponential backoff on rate limit errors.
16
- */
17
- async function withRetry(fn, maxRetries = 3, baseDelayMs = 1000) {
18
- let lastError;
19
- for (let attempt = 0; attempt < maxRetries; attempt++) {
20
- try {
21
- return await fn();
22
- }
23
- catch (err) {
24
- lastError = err instanceof Error ? err : new Error(String(err));
25
- // Check for rate limit errors
26
- let isRateLimited = false;
27
- // Check for rate_limited error code (Slack SDK)
28
- if ("code" in lastError && lastError.code === "rate_limited") {
29
- isRateLimited = true;
30
- }
31
- // Check for rate_limited in error response
32
- if ("data" in lastError) {
33
- const data = lastError
34
- .data;
35
- if (data?.error === "rate_limited" || data?.response?.status === 429) {
36
- isRateLimited = true;
37
- }
38
- }
39
- if (isRateLimited) {
40
- const delay = baseDelayMs * Math.pow(2, attempt);
41
- log.logWarning(`Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
42
- await new Promise((resolve) => setTimeout(resolve, delay));
43
- continue;
44
- }
45
- // Non-retryable error
46
- throw lastError;
47
- }
48
- }
49
- throw lastError;
50
- }
51
- class ChannelQueue {
52
- constructor() {
53
- this.queue = [];
54
- this.processing = false;
55
- }
56
- enqueue(work) {
57
- this.queue.push(work);
58
- this.processNext();
59
- }
60
- size() {
61
- return this.queue.length;
62
- }
63
- async processNext() {
64
- if (this.processing || this.queue.length === 0)
65
- return;
66
- this.processing = true;
67
- const work = this.queue.shift();
68
- try {
69
- await work();
70
- }
71
- catch (err) {
72
- log.logWarning("Queue error", err instanceof Error ? err.message : String(err));
73
- }
74
- this.processing = false;
75
- this.processNext();
76
- }
77
- }
78
- // ============================================================================
79
22
  // SlackBot
80
23
  // ============================================================================
81
24
  export class SlackBot {
@@ -123,18 +66,18 @@ export class SlackBot {
123
66
  return Array.from(this.channels.values());
124
67
  }
125
68
  async postMessage(channel, text) {
126
- return withRetry(async () => {
69
+ return slackRetry(async () => {
127
70
  const result = await this.webClient.chat.postMessage({ channel, text });
128
71
  return result.ts;
129
72
  });
130
73
  }
131
74
  async postEphemeral(channel, user, text) {
132
- return withRetry(async () => {
75
+ return slackRetry(async () => {
133
76
  await this.webClient.chat.postEphemeral({ channel, user, text });
134
77
  });
135
78
  }
136
79
  async openDirectMessage(userId) {
137
- return withRetry(async () => {
80
+ return slackRetry(async () => {
138
81
  const result = await this.webClient.conversations.open({ users: userId });
139
82
  const channelId = result.channel?.id;
140
83
  if (!channelId) {
@@ -144,12 +87,12 @@ export class SlackBot {
144
87
  });
145
88
  }
146
89
  async updateMessage(channel, ts, text) {
147
- return withRetry(async () => {
90
+ return slackRetry(async () => {
148
91
  await this.webClient.chat.update({ channel, ts, text });
149
92
  });
150
93
  }
151
94
  async deleteMessage(channel, ts) {
152
- return withRetry(async () => {
95
+ return slackRetry(async () => {
153
96
  await this.webClient.chat.delete({ channel, ts });
154
97
  });
155
98
  }
@@ -158,7 +101,7 @@ export class SlackBot {
158
101
  // ==========================================================================
159
102
  /** Set the status for an assistant thread (shows "thinking" state) */
160
103
  async setAssistantStatus(channel, threadTs, status) {
161
- return withRetry(async () => {
104
+ return slackRetry(async () => {
162
105
  await this.webClient.assistant.threads.setStatus({
163
106
  channel_id: channel,
164
107
  thread_ts: threadTs,
@@ -167,7 +110,7 @@ export class SlackBot {
167
110
  });
168
111
  }
169
112
  async postInThread(channel, threadTs, text) {
170
- return withRetry(async () => {
113
+ return slackRetry(async () => {
171
114
  // Use Block Kit section for long messages to trigger Slack's "Show more" collapsing (~700 chars)
172
115
  const SECTION_TEXT_LIMIT = 3000;
173
116
  if (text.length > 500) {
@@ -187,7 +130,7 @@ export class SlackBot {
187
130
  });
188
131
  }
189
132
  async postInThreadBlocks(channel, threadTs, text, blocks) {
190
- return withRetry(async () => {
133
+ return slackRetry(async () => {
191
134
  const result = await this.webClient.chat.postMessage({
192
135
  channel,
193
136
  thread_ts: threadTs,
@@ -198,7 +141,7 @@ export class SlackBot {
198
141
  });
199
142
  }
200
143
  async uploadFile(channel, filePath, title, threadTs) {
201
- return withRetry(async () => {
144
+ return slackRetry(async () => {
202
145
  const fileName = title || basename(filePath);
203
146
  const fileContent = readFileSync(filePath);
204
147
  await this.webClient.files.uploadV2({
@@ -210,29 +153,11 @@ export class SlackBot {
210
153
  });
211
154
  });
212
155
  }
213
- /**
214
- * Log a message to log.jsonl (SYNC)
215
- * This is the ONLY place messages are written to log.jsonl
216
- */
217
156
  logToFile(channel, entry) {
218
- const dir = join(this.workingDir, channel);
219
- if (!existsSync(dir))
220
- mkdirSync(dir, { recursive: true });
221
- appendFileSync(join(dir, "log.jsonl"), `${JSON.stringify(entry)}\n`);
157
+ appendChannelLog(this.workingDir, channel, entry);
222
158
  }
223
- /**
224
- * Log a bot response to log.jsonl
225
- */
226
159
  logBotResponse(channel, text, ts, threadTs) {
227
- this.logToFile(channel, {
228
- date: new Date().toISOString(),
229
- ts,
230
- threadTs,
231
- user: "bot",
232
- text,
233
- attachments: [],
234
- isBot: true,
235
- });
160
+ appendBotResponseLog(this.workingDir, channel, text, ts, threadTs);
236
161
  }
237
162
  getPlatformInfo() {
238
163
  return {
@@ -291,7 +216,7 @@ export class SlackBot {
291
216
  getQueue(channelId) {
292
217
  let queue = this.queues.get(channelId);
293
218
  if (!queue) {
294
- queue = new ChannelQueue();
219
+ queue = new ChannelQueue("Slack");
295
220
  this.queues.set(channelId, queue);
296
221
  }
297
222
  return queue;
@@ -303,6 +228,14 @@ export class SlackBot {
303
228
  ? sessionKey
304
229
  : conversationId;
305
230
  }
231
+ shouldTriggerSharedThreadReply(channelId, threadTs) {
232
+ if (!threadTs)
233
+ return false;
234
+ const sessionKey = resolveSlackSessionKey(channelId, threadTs);
235
+ if (this.handler.isRunning(sessionKey))
236
+ return true;
237
+ return hasMaterializedSlackBranchSession(join(this.workingDir, channelId), sessionKey);
238
+ }
306
239
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
307
240
  buildHomeView() {
308
241
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -439,76 +372,19 @@ export class SlackBot {
439
372
  });
440
373
  return { type: "home", blocks };
441
374
  }
442
- /**
443
- * Resolve which session key to stop.
444
- * When stop is called from a thread, the thread session (channelId:thread_ts) might
445
- * not be running — but the channel session (channelId) might be, because the bot's
446
- * reply to a top-level mention creates a thread. Check both, prefer thread first.
447
- *
448
- * For top-level stop requests, fall back to the only running scoped thread session in
449
- * this conversation when there is exactly one candidate. This keeps DM/channel stop
450
- * useful after thread sessions were introduced without guessing between multiple threads.
451
- */
452
375
  resolveStopTarget(channelId, threadTs) {
453
- if (threadTs) {
454
- const threadKey = resolveSlackSessionKey(channelId, threadTs);
455
- if (this.handler.isRunning(threadKey))
456
- return threadKey;
457
- if (this.handler.isRunning(channelId))
458
- return channelId;
376
+ const directTarget = resolveStopTarget({
377
+ handler: this.handler,
378
+ conversationId: channelId,
379
+ sessionKey: threadTs ? resolveSlackSessionKey(channelId, threadTs) : undefined,
380
+ });
381
+ if (directTarget)
382
+ return directTarget;
383
+ if (threadTs)
459
384
  return null;
460
- }
461
- if (this.handler.isRunning(channelId))
462
- return channelId;
463
- const runningInConversation = this.handler
464
- .getRunningSessions()
465
- .map((session) => session.sessionKey)
466
- .filter((sessionKey) => sessionKey.startsWith(`${channelId}:`));
467
- return runningInConversation.length === 1 ? runningInConversation[0] : null;
468
- }
469
- createDirectCommandAdapters(conversationId, userId, userName, text, ts) {
470
- const message = {
471
- id: ts,
472
- sessionKey: conversationId,
473
- conversationKind: "direct",
474
- userId,
475
- userName,
476
- text,
477
- attachments: [],
478
- };
479
- const responseCtx = {
480
- respond: async (responseText) => {
481
- const messageTs = await this.postMessage(conversationId, responseText);
482
- this.logBotResponse(conversationId, responseText, messageTs);
483
- },
484
- replaceResponse: async (responseText) => {
485
- const messageTs = await this.postMessage(conversationId, responseText);
486
- this.logBotResponse(conversationId, responseText, messageTs);
487
- },
488
- respondDiagnostic: async (responseText) => {
489
- const messageTs = await this.postMessage(conversationId, responseText);
490
- this.logBotResponse(conversationId, responseText, messageTs);
491
- },
492
- respondToolResult: async (result) => {
493
- const duration = (result.durationMs / 1000).toFixed(1);
494
- const text = `${result.isError ? "Error" : "Done"} ${result.toolName} (${duration}s)\n${result.result}`;
495
- const messageTs = await this.postMessage(conversationId, text);
496
- this.logBotResponse(conversationId, text, messageTs);
497
- },
498
- setTyping: async () => { },
499
- setWorking: async () => { },
500
- uploadFile: async (filePath, title) => {
501
- await this.uploadFile(conversationId, filePath, title);
502
- },
503
- deleteResponse: async () => { },
504
- };
505
- return {
506
- message,
507
- responseCtx,
508
- platform: this.getPlatformInfo(),
509
- };
385
+ return resolveOnlyScopedStopTarget(this.handler, channelId);
510
386
  }
511
- createPrivateSessionCommandAdapters(conversationId, userId, userName, text, ts, options) {
387
+ createCommandAdapters(conversationId, userId, userName, text, ts, options = {}) {
512
388
  const message = {
513
389
  id: ts,
514
390
  sessionKey: conversationId,
@@ -518,8 +394,7 @@ export class SlackBot {
518
394
  text,
519
395
  attachments: [],
520
396
  };
521
- let hasResponded = false;
522
- const respondPrivately = async (responseText) => {
397
+ const respond = async (responseText) => {
523
398
  if (options.ephemeralChannelId) {
524
399
  await this.postEphemeral(options.ephemeralChannelId, userId, responseText);
525
400
  return;
@@ -528,23 +403,12 @@ export class SlackBot {
528
403
  this.logBotResponse(conversationId, responseText, messageTs);
529
404
  };
530
405
  const responseCtx = {
531
- respond: async (responseText) => {
532
- hasResponded = true;
533
- await respondPrivately(responseText);
534
- },
535
- replaceResponse: async (responseText) => {
536
- if (!hasResponded) {
537
- hasResponded = true;
538
- }
539
- await respondPrivately(responseText);
540
- },
541
- respondDiagnostic: async (responseText) => {
542
- await respondPrivately(responseText);
543
- },
406
+ respond,
407
+ replaceResponse: respond,
408
+ respondDiagnostic: respond,
544
409
  respondToolResult: async (result) => {
545
410
  const duration = (result.durationMs / 1000).toFixed(1);
546
- const formatted = `${result.isError ? "Error" : "Done"} ${result.toolName} (${duration}s)\n${result.result}`;
547
- await respondPrivately(formatted);
411
+ await respond(`${result.isError ? "Error" : "Done"} ${result.toolName} (${duration}s)\n${result.result}`);
548
412
  },
549
413
  setTyping: async () => { },
550
414
  setWorking: async () => { },
@@ -608,7 +472,7 @@ export class SlackBot {
608
472
  attachments: [],
609
473
  sessionKey: targetChannelId,
610
474
  };
611
- const adapters = this.createDirectCommandAdapters(targetChannelId, payload.user_id, userName, commandText, eventTs);
475
+ const adapters = this.createCommandAdapters(targetChannelId, payload.user_id, userName, commandText, eventTs);
612
476
  await this.handler.handleEvent(event, this, adapters, false);
613
477
  }
614
478
  async routeSlashNewCommand(payload) {
@@ -659,7 +523,7 @@ export class SlackBot {
659
523
  attachments: [],
660
524
  sessionKey,
661
525
  };
662
- const adapters = this.createPrivateSessionCommandAdapters(conversationId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: conversationId });
526
+ const adapters = this.createCommandAdapters(conversationId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: conversationId });
663
527
  await this.handler.handleEvent(event, this, adapters, false);
664
528
  }
665
529
  setupEventHandlers() {
@@ -742,7 +606,7 @@ export class SlackBot {
742
606
  ack();
743
607
  return;
744
608
  }
745
- const isSharedThreadReply = !isDM && !!e.thread_ts;
609
+ const isSharedThreadReply = !isDM && this.shouldTriggerSharedThreadReply(e.channel, e.thread_ts);
746
610
  const sessionKey = isDM || isSharedThreadReply ? resolveSlackSessionKey(e.channel, e.thread_ts) : undefined;
747
611
  const slackEvent = {
748
612
  type: isDM ? "dm" : "mention",