create-walle 0.9.13 → 0.9.15

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 (98) hide show
  1. package/README.md +8 -3
  2. package/bin/create-walle.js +232 -32
  3. package/bin/mcp-inject.js +18 -53
  4. package/package.json +3 -1
  5. package/template/claude-task-manager/api-prompts.js +11 -2
  6. package/template/claude-task-manager/approval-agent.js +7 -0
  7. package/template/claude-task-manager/db.js +94 -75
  8. package/template/claude-task-manager/docs/session-standup-command-center-design.md +242 -0
  9. package/template/claude-task-manager/docs/session-tooltip-freshness-design.md +224 -0
  10. package/template/claude-task-manager/docs/session-ux-issue-review-2026-05-01.md +369 -0
  11. package/template/claude-task-manager/fuzzy-utils.js +10 -2
  12. package/template/claude-task-manager/git-utils.js +140 -10
  13. package/template/claude-task-manager/lib/agent-capabilities.js +1 -1
  14. package/template/claude-task-manager/lib/agent-presets.js +38 -5
  15. package/template/claude-task-manager/lib/codex-terminal-final.js +53 -0
  16. package/template/claude-task-manager/lib/ctm-session-context-api.js +222 -0
  17. package/template/claude-task-manager/lib/session-diagnostics.js +56 -0
  18. package/template/claude-task-manager/lib/session-history.js +309 -16
  19. package/template/claude-task-manager/lib/session-standup.js +409 -0
  20. package/template/claude-task-manager/lib/session-stream.js +253 -20
  21. package/template/claude-task-manager/lib/standup-attention.js +200 -0
  22. package/template/claude-task-manager/lib/status-hooks.js +8 -2
  23. package/template/claude-task-manager/lib/update-telemetry.js +114 -0
  24. package/template/claude-task-manager/lib/walle-ctm-history.js +49 -6
  25. package/template/claude-task-manager/lib/walle-default-model.js +55 -0
  26. package/template/claude-task-manager/lib/walle-mcp-auto-config.js +66 -0
  27. package/template/claude-task-manager/lib/walle-supervisor.js +86 -19
  28. package/template/claude-task-manager/lib/walle-transcript.js +1 -3
  29. package/template/claude-task-manager/lib/worktree-cwd.js +82 -0
  30. package/template/claude-task-manager/package.json +1 -0
  31. package/template/claude-task-manager/providers/codex-mcp.js +104 -0
  32. package/template/claude-task-manager/providers/index.js +2 -0
  33. package/template/claude-task-manager/public/css/setup.css +2 -1
  34. package/template/claude-task-manager/public/css/walle.css +71 -0
  35. package/template/claude-task-manager/public/index.html +2388 -429
  36. package/template/claude-task-manager/public/js/message-renderer.js +314 -35
  37. package/template/claude-task-manager/public/js/session-search-utils.js +185 -3
  38. package/template/claude-task-manager/public/js/session-status-precedence.js +125 -0
  39. package/template/claude-task-manager/public/js/setup.js +62 -19
  40. package/template/claude-task-manager/public/js/stream-view.js +396 -55
  41. package/template/claude-task-manager/public/js/terminal-restore-state.js +57 -0
  42. package/template/claude-task-manager/public/js/walle-session.js +234 -26
  43. package/template/claude-task-manager/public/js/walle.js +143 -2
  44. package/template/claude-task-manager/server.js +1402 -433
  45. package/template/claude-task-manager/session-integrity.js +77 -28
  46. package/template/claude-task-manager/workers/approval-widget-validator.js +15 -5
  47. package/template/claude-task-manager/workers/scrollback-worker.js +5 -6
  48. package/template/claude-task-manager/workers/state-detectors/codex.js +6 -0
  49. package/template/package.json +1 -1
  50. package/template/wall-e/agent-runners/claude-code.js +2 -0
  51. package/template/wall-e/agent.js +63 -8
  52. package/template/wall-e/api-walle.js +330 -52
  53. package/template/wall-e/brain.js +291 -42
  54. package/template/wall-e/chat.js +172 -15
  55. package/template/wall-e/coding/compaction-service.js +19 -5
  56. package/template/wall-e/coding/stream-processor.js +22 -2
  57. package/template/wall-e/coding/workspace-replay.js +1 -4
  58. package/template/wall-e/coding-orchestrator.js +250 -80
  59. package/template/wall-e/compat.js +0 -28
  60. package/template/wall-e/context/context-builder.js +3 -1
  61. package/template/wall-e/embeddings.js +2 -7
  62. package/template/wall-e/eval/agent-runner.js +30 -9
  63. package/template/wall-e/eval/benchmark-generator.js +21 -1
  64. package/template/wall-e/eval/benchmarks/chat-eval.json +66 -6
  65. package/template/wall-e/eval/benchmarks/coding-agent.json +0 -596
  66. package/template/wall-e/eval/cc-replay.js +1 -0
  67. package/template/wall-e/eval/codex-cli-baseline.js +633 -0
  68. package/template/wall-e/eval/debug-agent003.js +1 -0
  69. package/template/wall-e/eval/eval-orchestrator.js +3 -3
  70. package/template/wall-e/eval/run-agent-benchmarks.js +11 -3
  71. package/template/wall-e/eval/run-codex-cli-baseline.js +177 -0
  72. package/template/wall-e/eval/run-model-comparison.js +1 -0
  73. package/template/wall-e/eval/swebench-adapter.js +1 -0
  74. package/template/wall-e/evaluation/quorum-evaluator.js +0 -1
  75. package/template/wall-e/extraction/knowledge-extractor.js +1 -2
  76. package/template/wall-e/lib/mcp-integration.js +336 -0
  77. package/template/wall-e/llm/ollama.js +47 -8
  78. package/template/wall-e/llm/ollama.plugin.json +1 -1
  79. package/template/wall-e/llm/tool-adapter.js +1 -0
  80. package/template/wall-e/loops/ingest.js +42 -8
  81. package/template/wall-e/loops/initiative.js +87 -2
  82. package/template/wall-e/mcp-server.js +872 -19
  83. package/template/wall-e/memory/ctm-context-client.js +230 -0
  84. package/template/wall-e/memory/ctm-session-context.js +1376 -0
  85. package/template/wall-e/prompts/coding/memory-protocol.md +6 -0
  86. package/template/wall-e/server.js +30 -1
  87. package/template/wall-e/skills/_bundled/memory-search/SKILL.md +8 -0
  88. package/template/wall-e/skills/_bundled/scan-ctm-sessions/SKILL.md +20 -0
  89. package/template/wall-e/skills/_bundled/scan-ctm-sessions/run.js +43 -0
  90. package/template/wall-e/skills/_bundled/slack-mentions/run.js +471 -188
  91. package/template/wall-e/skills/skill-planner.js +86 -4
  92. package/template/wall-e/slack/socket-mode-listener.js +276 -0
  93. package/template/wall-e/telemetry.js +70 -2
  94. package/template/wall-e/tools/builtin-middleware.js +55 -2
  95. package/template/wall-e/tools/shell-policy.js +1 -1
  96. package/template/wall-e/tools/slack-owner.js +104 -0
  97. package/template/website/index.html +4 -4
  98. package/template/builder-journal.md +0 -17
@@ -2,9 +2,16 @@
2
2
  'use strict';
3
3
  const path = require('path');
4
4
  const fs = require('fs');
5
+ const crypto = require('crypto');
5
6
 
6
7
  const brain = require(path.resolve(__dirname, '..', '..', '..', 'brain'));
7
8
  const slackMcp = require(path.resolve(__dirname, '..', '..', '..', 'tools', 'slack-mcp'));
9
+ const { ReplyDispatcher } = require(path.resolve(__dirname, '..', '..', '..', 'channels', 'reply-dispatcher'));
10
+ const {
11
+ SILENT_REPLY_TOKEN,
12
+ isSilentReply,
13
+ stripSilentReply,
14
+ } = require(path.resolve(__dirname, '..', '..', '..', 'loops', 'silent-reply-tokens'));
8
15
 
9
16
  const WATERMARK_FILE = path.join(__dirname, '.watermark.json');
10
17
  const OWNER_HANDLE = process.env.SLACK_OWNER_HANDLE || '';
@@ -39,6 +46,14 @@ function discoverHomeChannels() {
39
46
  }
40
47
  let HOME_CHANNELS = [];
41
48
 
49
+ const slackReplyDispatcher = new ReplyDispatcher({
50
+ send: async (reply) => slackMcp.callSlackMcp('slack_send_message', {
51
+ channel_id: reply.target,
52
+ thread_ts: reply.threadTs,
53
+ message: reply.message,
54
+ }),
55
+ });
56
+
42
57
  // ── Watermark helpers ──
43
58
 
44
59
  function loadWatermark() {
@@ -71,6 +86,170 @@ function sleep(ms) {
71
86
  return new Promise(resolve => setTimeout(resolve, ms));
72
87
  }
73
88
 
89
+ function cleanWalleAddress(text) {
90
+ return (text || '')
91
+ .replace(/<@[A-Z0-9]+>/g, '@walle')
92
+ .replace(/@wall-?e/gi, '')
93
+ .trim();
94
+ }
95
+
96
+ function actorLabel(msg) {
97
+ return msg.fromName || msg.fromUserId || getConfiguredOwnerName() || 'A Slack user';
98
+ }
99
+
100
+ function compactSlackTs(ts) {
101
+ return String(ts || '').replace('.', '');
102
+ }
103
+
104
+ function safeSlackIdPart(value) {
105
+ return String(value || 'unknown').replace(/[^A-Za-z0-9_-]/g, '_');
106
+ }
107
+
108
+ function buildSlackThreadSessionId(channelId, threadTs) {
109
+ return `slack-thread-${safeSlackIdPart(channelId)}-${compactSlackTs(threadTs || 'unknown')}`;
110
+ }
111
+
112
+ function buildSlackDeliveryId(kind, channelId, threadTs, messageTs) {
113
+ return `slack:${kind}:${channelId || 'unknown'}:${threadTs || 'unknown'}:${messageTs || 'unknown'}`;
114
+ }
115
+
116
+ function hashPayload(value) {
117
+ return crypto.createHash('sha1').update(String(value || '')).digest('hex');
118
+ }
119
+
120
+ function claimSlackMessage(msg, source = 'poll') {
121
+ if (!msg?.channelId || !msg?.ts || typeof brain.claimSlackInboundEvent !== 'function') {
122
+ return { claimed: true, id: null };
123
+ }
124
+ try {
125
+ return brain.claimSlackInboundEvent({
126
+ channelId: msg.channelId,
127
+ messageTs: msg.ts,
128
+ threadTs: msg.threadTs || msg.ts,
129
+ source,
130
+ payloadHash: hashPayload(msg.content || ''),
131
+ });
132
+ } catch (err) {
133
+ console.warn(`[slack-mentions] Could not claim Slack inbound event ${msg.channelId}:${msg.ts}: ${err.message}`);
134
+ return { claimed: true, id: null };
135
+ }
136
+ }
137
+
138
+ function completeSlackMessage(claim, status = 'completed', error = null) {
139
+ if (!claim?.id || typeof brain.completeSlackInboundEvent !== 'function') return;
140
+ try {
141
+ brain.completeSlackInboundEvent(claim.id, { status, error });
142
+ } catch (err) {
143
+ console.warn(`[slack-mentions] Could not complete Slack inbound event ${claim.id}: ${err.message}`);
144
+ }
145
+ }
146
+
147
+ function sourceRefMatchesMessage(sourceRef, msg) {
148
+ if (!sourceRef || !msg) return false;
149
+ const ref = String(sourceRef).split('?')[0];
150
+ const permalink = msg.permalink ? String(msg.permalink).split('?')[0] : '';
151
+ if (permalink && ref === permalink) return true;
152
+ if (msg.channelId && msg.ts && ref === `${msg.channelId}:${msg.ts}`) return true;
153
+ if (msg.channelId && msg.ts && ref.includes(`/archives/${msg.channelId}/p${compactSlackTs(msg.ts)}`)) return true;
154
+ return false;
155
+ }
156
+
157
+ function compareSlackTs(a, b) {
158
+ const an = Number(a);
159
+ const bn = Number(b);
160
+ if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
161
+ return String(a || '').localeCompare(String(b || ''));
162
+ }
163
+
164
+ function slackTsAfter(a, b) {
165
+ if (!a) return false;
166
+ if (!b) return true;
167
+ return compareSlackTs(a, b) > 0;
168
+ }
169
+
170
+ function getConfiguredOwnerName() {
171
+ if (OWNER_NAME) return OWNER_NAME;
172
+ try {
173
+ return brain.getOwnerName ? (brain.getOwnerName() || '') : '';
174
+ } catch {
175
+ return '';
176
+ }
177
+ }
178
+
179
+ function isOwnerName(name, ownerHandle = OWNER_HANDLE, ownerName) {
180
+ const normalizedName = String(name || '').toLowerCase().trim();
181
+ if (!normalizedName) return false;
182
+
183
+ const normalizedHandle = String(ownerHandle || '').toLowerCase().trim().replace(/^@/, '');
184
+ if (normalizedHandle && normalizedName.includes(normalizedHandle)) return true;
185
+
186
+ const effectiveOwnerName = ownerName === undefined ? getConfiguredOwnerName() : ownerName;
187
+ const normalizedOwner = String(effectiveOwnerName || '').toLowerCase().trim();
188
+ if (normalizedOwner && normalizedName.includes(normalizedOwner)) return true;
189
+
190
+ const firstName = normalizedOwner.split(/\s+/)[0];
191
+ return Boolean(firstName && firstName.length >= 2 && normalizedName.includes(firstName));
192
+ }
193
+
194
+ function isOwnerReply(reply, opts = {}) {
195
+ return Boolean(reply?.ts) && isOwnerName(reply.from, opts.ownerHandle, opts.ownerName);
196
+ }
197
+
198
+ function isMessageDraftRequest(text) {
199
+ const t = String(text || '').toLowerCase();
200
+ return /\b(write|draft|compose|wordsmith|rewrite)\b/.test(t)
201
+ && /\b(message|note|reply|response|email|slack|dm)\b/.test(t);
202
+ }
203
+
204
+ function slackResponseInstruction(text) {
205
+ const noReply = `If you cannot produce a useful final answer from the available context, reply with ONLY: ${SILENT_REPLY_TOKEN}. Do not ask the Slack user for more context and do not narrate searches or attempts.`;
206
+ if (isMessageDraftRequest(text)) {
207
+ return [
208
+ 'The user wants a message draft.',
209
+ 'Use the full Slack thread context and memories to infer the situation.',
210
+ 'Return exactly one ready-to-copy draft in first person for the user to review and send.',
211
+ 'Do not include analysis, tool narration, caveats, or follow-up questions.',
212
+ 'Do not claim you actually sent anything or that you are the user.',
213
+ 'If context is incomplete but enough signal exists, write the safest concise draft from the available context.',
214
+ noReply,
215
+ ].join(' ');
216
+ }
217
+ return `Give a concise, helpful answer (2-6 sentences). Do NOT mention any tool failures, search steps, or technical issues in your response. ${noReply}`;
218
+ }
219
+
220
+ function looksLikeSlackToolError(answer) {
221
+ const text = String(answer || '');
222
+ return /\b(authentication failed|auth.?required|tool.?fail|error.*MCP|MCP.*error|OAuth.*expired)\b/i.test(text)
223
+ || /\b(I encountered|I was unable|I couldn't)\b.*\b(authenticate|connect|access|reach|call the tool)\b/i.test(text);
224
+ }
225
+
226
+ function validateSlackAnswer(answer) {
227
+ const raw = String(answer || '').trim();
228
+ if (isSilentReply(raw)) return { ok: false, reason: 'silent_reply' };
229
+ const stripped = stripSilentReply(raw);
230
+ if (stripped.hadToken && !stripped.text.trim()) return { ok: false, reason: 'silent_reply' };
231
+ const text = stripped.text.trim();
232
+ if (text.length < 5) return { ok: false, reason: 'empty' };
233
+ if (looksLikeSlackToolError(text)) return { ok: false, reason: 'tool_error' };
234
+ return { ok: true, text };
235
+ }
236
+
237
+ function selectThreadMessagesToProcess(replies, thread, opts = {}) {
238
+ const lastSeenTs = thread?.last_seen_ts || thread?.thread_ts || null;
239
+ const ownerReplies = (replies || []).filter(reply => isOwnerReply(reply, opts));
240
+ if (ownerReplies.length === 0) {
241
+ return { messages: [], latestTs: lastSeenTs, reason: 'no_owner_replies' };
242
+ }
243
+
244
+ const newReplies = ownerReplies.filter(reply => slackTsAfter(reply.ts, lastSeenTs));
245
+ if (newReplies.length === 0) {
246
+ return { messages: [], latestTs: lastSeenTs, reason: 'no_new_owner_replies' };
247
+ }
248
+
249
+ const latestTs = newReplies[newReplies.length - 1].ts;
250
+ return { messages: newReplies, latestTs, reason: 'new_owner_replies' };
251
+ }
252
+
74
253
  /**
75
254
  * Generate an answer using chat and send it directly to Slack.
76
255
  * Returns the reply text on success, or null on failure.
@@ -79,26 +258,48 @@ function sleep(ms) {
79
258
  * (for live data like Google Calendar). We send the reply ourselves via
80
259
  * callSlackMcp rather than relying on the model to call slack_send_message.
81
260
  */
82
- async function generateAndSendReply(msg, threadTs, contextBlock, kind) {
261
+ async function deliverSlackReply({ channelId, threadTs, message, deliveryId }) {
262
+ if (!channelId) return { ok: true, status: 'no_channel' };
263
+ const status = await slackReplyDispatcher.dispatch({
264
+ channel: 'slack',
265
+ target: channelId,
266
+ threadTs,
267
+ message,
268
+ deliveryId,
269
+ });
270
+ if (status.status === 'sent') {
271
+ console.log(`[slack-mentions] Reply sent to Slack thread ${threadTs}`);
272
+ return { ok: true, status: status.status };
273
+ }
274
+ if (status.status === 'skipped' && status.reason === 'duplicate') {
275
+ console.log(`[slack-mentions] Duplicate Slack delivery skipped: ${deliveryId}`);
276
+ return { ok: false, status: status.status, reason: status.reason };
277
+ }
278
+ console.error(`[slack-mentions] Failed to send reply to Slack: ${status.reason || 'unknown error'}`);
279
+ return { ok: false, status: status.status, reason: status.reason };
280
+ }
281
+
282
+ async function generateAndSendReply(msg, threadTs, contextBlock, kind, opts = {}) {
83
283
  const chatModule = require(path.resolve(__dirname, '..', '..', '..', 'chat'));
84
- const question = (msg.content || '').replace(/@wall-?e/gi, '').trim();
284
+ const question = cleanWalleAddress(msg.content);
85
285
 
86
286
  // Step 1: Generate the answer with limited tools
87
287
  const isGreeting = /\b(welcome|hello|hi|hey|greet|introduce|join|new member|new hire)\b/i.test(question);
88
288
  let answer;
89
289
  try {
90
290
  const today = new Date().toISOString().split('T')[0];
291
+ const instruction = slackResponseInstruction(question);
91
292
  // Build a context-aware prompt: greetings/welcomes get a warmer, more proactive prompt
92
293
  let prompt;
93
294
  if (isGreeting) {
94
- prompt = `[SLACK GREETING] ${OWNER_NAME} said in Slack:\n\n"${question}"${contextBlock}\n\nToday is ${today}. This appears to be a greeting or welcome message. Search your memories (e.g. lookup_person) to learn about the people mentioned. Respond warmly — welcome them, share any relevant context you know about them, and proactively offer to help. Be genuine and concise (2-4 sentences). Do NOT mention any tool failures or technical issues in your response.`;
295
+ prompt = `[SLACK GREETING] ${actorLabel(msg)} said in Slack:\n\n"${question}"${contextBlock}\n\nToday is ${today}. This appears to be a greeting or welcome message. Search your memories (e.g. lookup_person) to learn about the people mentioned. Respond warmly — welcome them, share any relevant context you know about them, and proactively offer to help. Be genuine and concise (2-4 sentences). Do NOT mention any tool failures or technical issues in your response.`;
95
296
  } else {
96
- prompt = `[SLACK ${kind.toUpperCase()}] ${OWNER_NAME} asked in Slack:\n\n${question}${contextBlock}\n\nToday is ${today}. Search your memories for relevant info. For schedule/calendar questions, search with the specific date (e.g. "calendar 2026-04-13") since calendar events are stored as memories with source=calendar. Use mcp_call only if memories don't have what you need. Give a concise, helpful answer (2-6 sentences). Do NOT mention any tool failures or technical issues in your response — just answer naturally.`;
297
+ prompt = `[SLACK ${kind.toUpperCase()}] ${actorLabel(msg)} asked in Slack:\n\n${question}${contextBlock}\n\nToday is ${today}. Search your memories for relevant info. For schedule/calendar questions, search with the specific date (e.g. "calendar 2026-04-13") since calendar events are stored as memories with source=calendar. Use mcp_call only if memories don't have what you need. ${instruction}`;
97
298
  }
98
299
 
99
300
  const result = await chatModule.chat(prompt, {
100
301
  channel: 'task',
101
- session_id: `slack-reply-${msg.ts || Date.now()}`,
302
+ session_id: opts.sessionId || buildSlackThreadSessionId(msg.channelId, threadTs),
102
303
  allowedTools: ['search_memories', 'think', 'lookup_person', 'mcp_call', 'calendar_events', 'mail_messages', 'mail_read', 'mail_search'],
103
304
  maxToolCalls: 6,
104
305
  timeoutMs: 90000, // 90s to allow MCP calls (e.g. Google Calendar)
@@ -112,36 +313,29 @@ async function generateAndSendReply(msg, threadTs, contextBlock, kind) {
112
313
  }
113
314
 
114
315
  // Step 2: Send the reply directly via Slack MCP
115
- // Guard: never send error/failure text to Slack — fall back to a graceful message
116
- const looksLikeError = answer && (
117
- /\b(authentication failed|auth.?required|tool.?fail|error.*MCP|MCP.*error|OAuth.*expired)\b/i.test(answer)
118
- || /\b(I encountered|I was unable|I couldn't)\b.*\b(authenticate|connect|access|reach|call the tool)\b/i.test(answer)
119
- );
120
- if (!answer || answer.length < 5 || looksLikeError) {
121
- if (looksLikeError) console.warn(`[slack-mentions] Suppressed error-like answer: ${answer.slice(0, 200)}`);
316
+ // Guard: never send error/progress/uncertainty text to Slack.
317
+ const validation = validateSlackAnswer(answer);
318
+ if (!validation.ok) {
319
+ if (answer) console.warn(`[slack-mentions] Suppressed ${validation.reason} answer: ${answer.slice(0, 200)}`);
320
+ if (!isGreeting) return null;
122
321
  answer = isGreeting
123
322
  ? `Welcome! Great to have you here. Let me know if there's anything I can help with!`
124
- : `I'm not sure about that — let me look into it and get back to you.`;
323
+ : null;
324
+ } else {
325
+ answer = validation.text;
125
326
  }
327
+ if (!answer) return null;
126
328
 
127
329
  // Truncate for Slack (4000 char limit)
128
330
  if (answer.length > 3500) answer = answer.slice(0, 3500) + '...';
129
331
 
130
- if (msg.channelId) {
131
- try {
132
- await slackMcp.callSlackMcp('slack_send_message', {
133
- channel_id: msg.channelId,
134
- thread_ts: threadTs,
135
- message: answer,
136
- });
137
- console.log(`[slack-mentions] Reply sent to Slack thread ${threadTs}`);
138
- return answer;
139
- } catch (sendErr) {
140
- console.error(`[slack-mentions] Failed to send reply to Slack: ${sendErr.message}`);
141
- return null;
142
- }
143
- }
144
- return answer;
332
+ const delivery = await deliverSlackReply({
333
+ channelId: msg.channelId,
334
+ threadTs,
335
+ message: answer,
336
+ deliveryId: opts.deliveryId || buildSlackDeliveryId('reply', msg.channelId, threadTs, msg.ts),
337
+ });
338
+ return delivery.ok ? answer : null;
145
339
  }
146
340
 
147
341
  /**
@@ -149,17 +343,19 @@ async function generateAndSendReply(msg, threadTs, contextBlock, kind) {
149
343
  * Mirrors generateAndSendReply: we control the chat call and send the reply
150
344
  * ourselves, rather than letting the model call slack_send_message directly.
151
345
  */
152
- async function generateAndSendFollowUp(combinedMessage, thread) {
346
+ async function generateAndSendFollowUp(combinedMessage, thread, threadContext, opts = {}) {
153
347
  const chatModule = require(path.resolve(__dirname, '..', '..', '..', 'chat'));
154
348
 
155
349
  let answer;
156
350
  try {
157
351
  const today = new Date().toISOString().split('T')[0];
158
- const prompt = `[SLACK FOLLOW-UP] ${OWNER_NAME} replied in a Slack thread:\n\n${combinedMessage}\n\nToday is ${today}. Search your memories for relevant info. Give a concise, helpful answer (2-6 sentences). Do NOT mention any tool failures or technical issues in your response — just answer naturally.`;
352
+ const contextBlock = threadContext ? `\n\nFull Slack thread context:\n${threadContext}` : '';
353
+ const instruction = slackResponseInstruction(combinedMessage);
354
+ const prompt = `[SLACK FOLLOW-UP] ${getConfiguredOwnerName() || 'The owner'} replied in a Slack thread:\n\n${combinedMessage}${contextBlock}\n\nToday is ${today}. Search your memories for relevant info. ${instruction}`;
159
355
 
160
356
  const result = await chatModule.chat(prompt, {
161
357
  channel: 'task',
162
- session_id: thread.session_id,
358
+ session_id: thread.session_id || buildSlackThreadSessionId(thread.channel_id, thread.thread_ts),
163
359
  allowedTools: ['search_memories', 'think', 'lookup_person', 'mcp_call', 'calendar_events', 'mail_messages', 'mail_read', 'mail_search'],
164
360
  maxToolCalls: 6,
165
361
  timeoutMs: 90000,
@@ -171,35 +367,28 @@ async function generateAndSendFollowUp(combinedMessage, thread) {
171
367
  return null;
172
368
  }
173
369
 
174
- // Guard: never send error/failure text to Slack
175
- const looksLikeError = answer && (
176
- /\b(authentication failed|auth.?required|tool.?fail|error.*MCP|MCP.*error|OAuth.*expired)\b/i.test(answer)
177
- || /\b(I encountered|I was unable|I couldn't)\b.*\b(authenticate|connect|access|reach|call the tool)\b/i.test(answer)
178
- );
179
- if (!answer || answer.length < 5 || looksLikeError) {
180
- if (looksLikeError) console.warn(`[slack-mentions] Suppressed error-like follow-up: ${answer.slice(0, 200)}`);
181
- answer = `I'm not sure about that — let me look into it and get back to you.`;
370
+ // Guard: never send error/progress/uncertainty text to Slack.
371
+ const validation = validateSlackAnswer(answer);
372
+ if (!validation.ok) {
373
+ if (answer) console.warn(`[slack-mentions] Suppressed ${validation.reason} follow-up: ${answer.slice(0, 200)}`);
374
+ return null;
182
375
  }
376
+ answer = validation.text;
183
377
 
184
378
  if (answer.length > 3500) answer = answer.slice(0, 3500) + '...';
185
379
 
186
- try {
187
- await slackMcp.callSlackMcp('slack_send_message', {
188
- channel_id: thread.channel_id,
189
- thread_ts: thread.thread_ts,
190
- message: answer,
191
- });
192
- console.log(`[slack-mentions] Follow-up sent to Slack thread ${thread.thread_ts}`);
193
- return answer;
194
- } catch (sendErr) {
195
- console.error(`[slack-mentions] Failed to send follow-up to Slack: ${sendErr.message}`);
196
- return null;
197
- }
380
+ const delivery = await deliverSlackReply({
381
+ channelId: thread.channel_id,
382
+ threadTs: thread.thread_ts,
383
+ message: answer,
384
+ deliveryId: opts.deliveryId || buildSlackDeliveryId('followup', thread.channel_id, thread.thread_ts, opts.messageTs),
385
+ });
386
+ return delivery.ok ? answer : null;
198
387
  }
199
388
 
200
389
  function classifyMention(text) {
201
390
  const trimmed = (text || '').trim();
202
- const cleaned = trimmed.replace(/@wall-?e/gi, '').trim();
391
+ const cleaned = cleanWalleAddress(trimmed);
203
392
  if (/\?\s*$/.test(cleaned)) return 'question';
204
393
  if (/^(what|who|where|when|why|how|is|are|do|does|did|can|could|would|should|will)\b/i.test(cleaned)) return 'question';
205
394
 
@@ -212,7 +401,7 @@ function classifyMention(text) {
212
401
  }
213
402
 
214
403
  function extractTaskTitle(text) {
215
- const cleaned = (text || '').replace(/@wall-?e/gi, '').trim();
404
+ const cleaned = cleanWalleAddress(text);
216
405
  if (cleaned.length <= 120) return cleaned;
217
406
  return cleaned.slice(0, 120).replace(/\s+\S*$/, '') + '...';
218
407
  }
@@ -332,61 +521,72 @@ async function pollWatchedThreads() {
332
521
  const replies = parseThreadReplies(rawThread);
333
522
  if (replies.length === 0) continue;
334
523
 
335
- const lastSeenTs = thread.last_seen_ts || thread.thread_ts;
336
-
337
- // Find the last non-bot reply from the owner
338
- const ownerReplies = replies.filter(r => {
339
- if (!r.ts) return false;
340
- const name = (r.from || '').toLowerCase();
341
- return name.includes(OWNER_HANDLE.toLowerCase()) ||
342
- name.includes(OWNER_NAME.split(' ')[0].toLowerCase());
343
- });
344
-
345
- if (ownerReplies.length === 0) continue;
346
-
347
- const lastOwnerReply = ownerReplies[ownerReplies.length - 1];
348
- const lastReplyInThread = replies[replies.length - 1];
524
+ const selection = selectThreadMessagesToProcess(replies, thread);
525
+ const messagesToProcess = selection.messages;
526
+ if (messagesToProcess.length === 0) {
527
+ continue;
528
+ }
349
529
 
350
- // Skip if we already replied after the owner's last message (last reply is bot)
351
- const lastReplyIsBot = lastReplyInThread && lastReplyInThread.ts > lastOwnerReply.ts;
352
- if (lastReplyIsBot) continue;
530
+ console.log(`[slack-mentions] ${messagesToProcess.length} message(s) to process in watched thread ${thread.id}`);
353
531
 
354
- // Collect unseen messages (new since last_seen_ts)
355
- const newReplies = ownerReplies.filter(r => r.ts > lastSeenTs);
532
+ const latestTs = selection.latestTs || messagesToProcess[messagesToProcess.length - 1].ts;
533
+ const claimed = [];
534
+ for (const reply of messagesToProcess) {
535
+ const claim = claimSlackMessage({
536
+ channelId: thread.channel_id,
537
+ threadTs: thread.thread_ts,
538
+ ts: reply.ts,
539
+ content: reply.content,
540
+ }, 'thread_poll');
541
+ if (!claim.claimed) {
542
+ console.log(`[slack-mentions] Skipping already-claimed thread reply ${thread.channel_id}:${reply.ts}`);
543
+ continue;
544
+ }
545
+ claimed.push({ reply, claim });
546
+ }
356
547
 
357
- // If no strictly-new messages but the last thread message is from the owner,
358
- // it means our previous reply attempt failed — retry the last seen message
359
- let messagesToProcess;
360
- if (newReplies.length > 0) {
361
- messagesToProcess = newReplies;
362
- } else if (lastOwnerReply.ts >= lastSeenTs) {
363
- console.log(`[slack-mentions] Retrying unanswered message in thread ${thread.id} (ts=${lastOwnerReply.ts})`);
364
- messagesToProcess = [lastOwnerReply];
365
- } else {
548
+ if (claimed.length === 0) {
549
+ brain.updateSlackThread(thread.id, {
550
+ last_seen_ts: latestTs || thread.last_seen_ts,
551
+ last_activity: new Date().toISOString(),
552
+ });
366
553
  continue;
367
554
  }
368
555
 
369
- console.log(`[slack-mentions] ${messagesToProcess.length} message(s) to process in watched thread ${thread.id}`);
370
-
371
- const combinedMessage = messagesToProcess.map(r => r.content).join('\n\n');
372
- const latestTs = messagesToProcess[messagesToProcess.length - 1].ts;
556
+ const combinedMessage = claimed.map(item => item.reply.content).join('\n\n');
557
+ const deliveryMessageTs = claimed[claimed.length - 1]?.reply?.ts || latestTs;
373
558
 
374
559
  let replySucceeded = false;
560
+ let claimStatus = 'completed';
561
+ let claimError = null;
375
562
  try {
376
- const reply = await generateAndSendFollowUp(combinedMessage, thread);
563
+ const reply = await generateAndSendFollowUp(combinedMessage, thread, rawThread, {
564
+ messageTs: deliveryMessageTs,
565
+ deliveryId: buildSlackDeliveryId('followup', thread.channel_id, thread.thread_ts, deliveryMessageTs),
566
+ });
377
567
  replySucceeded = !!reply;
378
568
  if (reply) {
379
569
  console.log(`[slack-mentions] Follow-up reply sent for thread ${thread.id}: ${reply.slice(0, 100)}`);
570
+ } else {
571
+ claimStatus = 'no_reply';
380
572
  }
381
573
  } catch (chatErr) {
382
574
  console.error(`[slack-mentions] Failed to process follow-up: ${chatErr.message}`);
575
+ claimStatus = 'failed';
576
+ claimError = chatErr.message;
577
+ } finally {
578
+ for (const item of claimed) completeSlackMessage(item.claim, claimStatus, claimError);
383
579
  }
384
580
 
385
- // Only advance last_seen_ts if the reply succeeded
581
+ // Advance even when the reply is suppressed or sending fails. Retrying the
582
+ // same Slack message every 10s is worse than missing one transient reply.
386
583
  brain.updateSlackThread(thread.id, {
387
- last_seen_ts: replySucceeded ? latestTs : thread.last_seen_ts,
584
+ last_seen_ts: latestTs || thread.last_seen_ts,
388
585
  last_activity: new Date().toISOString(),
389
586
  });
587
+ if (!replySucceeded) {
588
+ console.warn(`[slack-mentions] Marked thread ${thread.id} seen without sending a follow-up`);
589
+ }
390
590
 
391
591
  await sleep(PAUSE_MS);
392
592
  } catch (err) {
@@ -461,12 +661,10 @@ async function pollHomeChannels(lastTs, seenTs, stats) {
461
661
  if (lastTs && msg.ts && msg.ts <= lastTs) continue;
462
662
 
463
663
  // Check sender is owner
464
- const senderName = (msg.fromName || '').toLowerCase();
465
- const isFromOwner = senderName.includes(OWNER_HANDLE.toLowerCase()) ||
466
- senderName.includes(OWNER_NAME.split(' ')[0].toLowerCase());
664
+ const isFromOwner = isOwnerName(msg.fromName);
467
665
  if (!isFromOwner) continue;
468
666
 
469
- await _processMention(msg, seenTs, stats);
667
+ await _processMention(msg, seenTs, stats, { source: 'home_poll' });
470
668
  }
471
669
 
472
670
  await sleep(PAUSE_MS);
@@ -478,119 +676,186 @@ async function pollHomeChannels(lastTs, seenTs, stats) {
478
676
 
479
677
  // ── New mention processing ──
480
678
 
481
- // Shared per-message processor creates tasks/questions from a mention
482
- async function _processMention(msg, seenTs, stats) {
483
- const dedupeKey = msg.ts || msg.permalink || msg.content;
679
+ // Shared per-message processor - creates tasks/questions from a mention.
680
+ async function _processMention(msg, seenTs, stats, opts = {}) {
681
+ const dedupeKey = msg.channelId && msg.ts ? `${msg.channelId}:${msg.ts}` : (msg.ts || msg.permalink || msg.content);
484
682
  if (seenTs.has(dedupeKey)) return;
485
683
  seenTs.add(dedupeKey);
486
684
 
487
685
  const now = new Date();
686
+ const effectiveTs = msg.ts || msg.isoTimestamp || now.toISOString();
687
+ const threadTs = msg.threadTs || msg.ts;
688
+ const sessionId = buildSlackThreadSessionId(msg.channelId, threadTs);
488
689
  const sourceRef = (msg.permalink || (msg.channelId ? `${msg.channelId}:${msg.ts || 'unknown'}` : `unknown:${msg.ts}`)).split('?')[0];
489
- const existing = brain.listTasks({ source: 'slack' }).find(t => t.source_ref && t.source_ref.split('?')[0] === sourceRef);
490
- if (existing) {
491
- console.log(`[slack-mentions] Skipping already-processed mention: ${sourceRef}`);
690
+ const claim = claimSlackMessage({ ...msg, threadTs }, opts.source || 'mention_poll');
691
+
692
+ if (effectiveTs > (stats.maxTs || '')) stats.maxTs = effectiveTs;
693
+ if (!claim.claimed) {
694
+ console.log(`[slack-mentions] Skipping already-claimed mention: ${sourceRef}`);
492
695
  return;
493
696
  }
494
697
 
495
- stats.newMentions++;
496
- const kind = classifyMention(msg.content);
497
- const effectiveTs = msg.ts || msg.isoTimestamp || now.toISOString();
498
- const threadTs = msg.threadTs || msg.ts;
698
+ let claimStatus = 'completed';
699
+ let claimError = null;
499
700
 
500
- // Fetch full thread context
501
- let threadContext = null;
502
- if (msg.channelId && threadTs) {
503
- threadContext = await fetchThreadContext(msg.channelId, threadTs);
504
- await sleep(PAUSE_MS);
505
- }
506
- const contextBlock = threadContext ? `\n\nFull thread context:\n${threadContext}` : '';
701
+ try {
702
+ const existing = brain.listTasks({ source: 'slack' }).find(t => sourceRefMatchesMessage(t.source_ref, msg));
703
+ if (existing) {
704
+ console.log(`[slack-mentions] Skipping already-processed mention: ${sourceRef}`);
705
+ claimStatus = 'skipped_existing_task';
706
+ return;
707
+ }
507
708
 
508
- if (kind === 'coding') {
509
- const title = extractTaskTitle(msg.content);
510
- try {
511
- const { id } = brain.insertTask({
512
- title: title.slice(0, 100),
513
- description: `Coding request from Slack:\n\n${msg.content}`,
514
- priority: 'normal',
515
- type: 'once',
516
- execution: 'skill',
517
- skill: 'coding-agent',
518
- skill_config: JSON.stringify({
519
- request: msg.content.replace(/@wall-?e/gi, '').trim(),
520
- cwd: process.env.HOME || '/tmp',
521
- options: { delivery: 'commit' },
522
- }),
523
- source: 'slack',
524
- source_ref: sourceRef,
525
- });
526
- stats.tasksCreated++;
527
- console.log(`[slack-mentions] Coding task created: ${id} — ${title}`);
709
+ stats.newMentions++;
710
+ const kind = classifyMention(msg.content);
711
+
712
+ // Fetch full thread context
713
+ let threadContext = null;
714
+ if (msg.channelId && threadTs) {
715
+ threadContext = await fetchThreadContext(msg.channelId, threadTs);
716
+ await sleep(PAUSE_MS);
717
+ }
718
+ const contextBlock = threadContext ? `\n\nFull thread context:\n${threadContext}` : '';
719
+
720
+ if (kind === 'coding') {
721
+ const title = extractTaskTitle(msg.content);
722
+ try {
723
+ const { id } = brain.insertTask({
724
+ title: title.slice(0, 100),
725
+ description: `Coding request from Slack:\n\n${msg.content}`,
726
+ priority: 'normal',
727
+ type: 'once',
728
+ execution: 'skill',
729
+ skill: 'coding-agent',
730
+ skill_config: JSON.stringify({
731
+ request: cleanWalleAddress(msg.content),
732
+ cwd: process.env.HOME || '/tmp',
733
+ options: { delivery: 'commit' },
734
+ }),
735
+ source: 'slack',
736
+ source_ref: sourceRef,
737
+ });
738
+ stats.tasksCreated++;
739
+ console.log(`[slack-mentions] Coding task created: ${id} - ${title}`);
528
740
 
529
- if (msg.channelId) {
530
741
  try {
531
- await slackMcp.callSlackMcp('slack_send_message', {
532
- channel_id: msg.channelId,
533
- message: `On it — planning the implementation now. I'll update this thread when done.`,
534
- thread_ts: threadTs,
742
+ const threadId = brain.upsertSlackThread({ channelId: msg.channelId, threadTs, taskId: id, sessionId });
743
+ if (msg.ts) brain.updateSlackThread(threadId, { last_seen_ts: msg.ts, last_activity: new Date().toISOString() });
744
+ } catch (upsertErr) {
745
+ console.error(`[slack-mentions] FAILED to upsert watched thread: ${upsertErr.message}`);
746
+ }
747
+
748
+ if (msg.channelId) {
749
+ const delivery = await deliverSlackReply({
750
+ channelId: msg.channelId,
751
+ threadTs,
752
+ message: `On it - planning the implementation now. I'll update this thread when done.`,
753
+ deliveryId: buildSlackDeliveryId('coding-ack', msg.channelId, threadTs, msg.ts),
535
754
  });
536
- } catch (replyErr) {
537
- console.warn(`[slack-mentions] Could not reply in thread: ${replyErr.message}`);
755
+ if (!delivery.ok) claimStatus = 'ack_failed';
756
+ await sleep(PAUSE_MS);
538
757
  }
539
- await sleep(PAUSE_MS);
758
+ } catch (taskErr) {
759
+ claimStatus = 'failed';
760
+ claimError = taskErr.message;
761
+ console.error(`[slack-mentions] Failed to create coding task: ${taskErr.message}`);
540
762
  }
541
- } catch (taskErr) {
542
- console.error(`[slack-mentions] Failed to create coding task: ${taskErr.message}`);
763
+ return;
543
764
  }
544
- return;
545
- }
546
765
 
547
- if (kind === 'task' || kind === 'question') {
548
- stats.questionsFound += kind === 'question' ? 1 : 0;
549
- stats.tasksCreated += kind === 'task' ? 1 : 0;
550
- const title = extractTaskTitle(msg.content);
551
- console.log(`[slack-mentions] ${kind} detected: ${title.slice(0, 80)}`);
766
+ if (kind === 'task' || kind === 'question') {
767
+ stats.questionsFound += kind === 'question' ? 1 : 0;
768
+ stats.tasksCreated += kind === 'task' ? 1 : 0;
769
+ const title = extractTaskTitle(msg.content);
770
+ console.log(`[slack-mentions] ${kind} detected: ${title.slice(0, 80)}`);
552
771
 
553
- // Phase 1: Generate answer directly and send it no task intermediary.
554
- // This is the most reliable approach: we control the chat call and send the
555
- // reply ourselves, instead of hoping the task's chat engine will do it.
556
- const reply = await generateAndSendReply(msg, threadTs, contextBlock, kind);
557
-
558
- // Track as a completed task for history/dedup
559
- try {
560
- const { id } = brain.insertTask({
561
- title: kind === 'question' ? `Answer Slack question: ${title}` : title,
562
- description: `${kind === 'question' ? 'Question' : 'Task'} from ${OWNER_NAME} in Slack:\n\n${msg.content}${contextBlock}`,
563
- priority: kind === 'question' ? 'high' : 'normal',
564
- type: 'once',
565
- execution: 'chat',
566
- source: 'slack',
567
- source_ref: sourceRef,
568
- });
569
- // Mark completed/failed immediately since we handled it inline
570
- brain.updateTask(id, {
571
- status: reply ? 'completed' : 'failed',
572
- result: (reply || 'Failed to generate or send reply').slice(0, 10000),
573
- completed_at: new Date().toISOString(),
574
- run_count: 1,
772
+ // Generate the response and route delivery through the channel dispatcher.
773
+ const reply = await generateAndSendReply(msg, threadTs, contextBlock, kind, {
774
+ sessionId,
775
+ deliveryId: buildSlackDeliveryId('reply', msg.channelId, threadTs, msg.ts),
575
776
  });
777
+ if (!reply) claimStatus = 'no_reply';
778
+
779
+ // Track as a completed task for history/dedup.
576
780
  try {
577
- const threadId = brain.upsertSlackThread({ channelId: msg.channelId, threadTs, taskId: id, sessionId: `task-${id}` });
578
- // Set last_seen_ts so pollWatchedThreads knows we already replied to this message.
579
- // Without this, the next poll cycle sees the owner's message as "unanswered" and sends a duplicate reply.
580
- if (reply && msg.ts) {
581
- brain.updateSlackThread(threadId, { last_seen_ts: msg.ts, last_activity: new Date().toISOString() });
781
+ const { id } = brain.insertTask({
782
+ title: kind === 'question' ? `Answer Slack question: ${title}` : title,
783
+ description: `${kind === 'question' ? 'Question' : 'Task'} from ${getConfiguredOwnerName() || 'Slack'} in Slack:\n\n${msg.content}${contextBlock}`,
784
+ priority: kind === 'question' ? 'high' : 'normal',
785
+ type: 'once',
786
+ execution: 'chat',
787
+ source: 'slack',
788
+ source_ref: sourceRef,
789
+ });
790
+ brain.updateTask(id, {
791
+ status: reply ? 'completed' : 'failed',
792
+ result: (reply || 'No Slack reply sent').slice(0, 10000),
793
+ completed_at: new Date().toISOString(),
794
+ run_count: 1,
795
+ });
796
+ try {
797
+ const threadId = brain.upsertSlackThread({ channelId: msg.channelId, threadTs, taskId: id, sessionId });
798
+ if (msg.ts) {
799
+ brain.updateSlackThread(threadId, { last_seen_ts: msg.ts, last_activity: new Date().toISOString() });
800
+ }
801
+ } catch (upsertErr) {
802
+ console.error(`[slack-mentions] FAILED to upsert watched thread: ${upsertErr.message}`);
582
803
  }
583
- } catch (upsertErr) {
584
- console.error(`[slack-mentions] FAILED to upsert watched thread: ${upsertErr.message}`);
804
+ } catch (taskErr) {
805
+ claimStatus = 'failed';
806
+ claimError = taskErr.message;
807
+ console.error(`[slack-mentions] Failed to create task record: ${taskErr.message}`);
585
808
  }
586
- } catch (taskErr) {
587
- console.error(`[slack-mentions] Failed to create task record: ${taskErr.message}`);
588
809
  }
810
+ } catch (err) {
811
+ claimStatus = 'failed';
812
+ claimError = err.message;
813
+ console.error(`[slack-mentions] Failed to process mention ${sourceRef}: ${err.message}`);
814
+ } finally {
815
+ completeSlackMessage(claim, claimStatus, claimError);
816
+ }
817
+ }
818
+
819
+ function normalizeEventText(event) {
820
+ if (!event || typeof event.text !== 'string') return '';
821
+ if (event.type === 'app_mention') return event.text.replace(/<@[A-Z0-9]+>/g, '@walle');
822
+ return event.text;
823
+ }
824
+
825
+ function shouldAcceptSocketEvent(event) {
826
+ if (!event || !event.type) return false;
827
+ if (event.bot_id || event.subtype) return false;
828
+ if (event.type === 'app_mention') return true;
829
+ if (event.type === 'message') {
830
+ return event.channel_type === 'im' || event.channel_type === 'mpim';
589
831
  }
832
+ return false;
833
+ }
590
834
 
591
- if (effectiveTs > (stats.maxTs || '')) {
592
- stats.maxTs = effectiveTs;
835
+ async function processIncomingSlackEvent(event, opts = {}) {
836
+ if (!shouldAcceptSocketEvent(event)) {
837
+ return { processed: false, reason: 'ignored_event_type' };
593
838
  }
839
+ brain.initDb();
840
+ const stats = { newMentions: 0, tasksCreated: 0, questionsFound: 0, maxTs: null };
841
+ const seenTs = new Set();
842
+ const threadTs = event.thread_ts || event.ts;
843
+ const msg = {
844
+ ts: event.ts,
845
+ threadTs,
846
+ channelId: event.channel,
847
+ fromUserId: event.user,
848
+ fromName: opts.fromName || event.user,
849
+ permalink: opts.permalink || null,
850
+ content: normalizeEventText(event),
851
+ isoTimestamp: event.event_ts || event.ts || new Date().toISOString(),
852
+ };
853
+ await _processMention(msg, seenTs, stats, { source: 'socket' });
854
+ return {
855
+ processed: stats.newMentions > 0,
856
+ tasksCreated: stats.tasksCreated,
857
+ questionsFound: stats.questionsFound,
858
+ };
594
859
  }
595
860
 
596
861
  async function processNewMentions() {
@@ -640,15 +905,13 @@ async function processNewMentions() {
640
905
  if (lastTs && msg.ts && msg.ts <= lastTs) continue;
641
906
  if (lastTs && msg.isoTimestamp && msg.isoTimestamp <= lastTs) continue;
642
907
 
643
- const senderName = (msg.fromName || '').toLowerCase();
644
- const isFromOwner = senderName.includes(OWNER_HANDLE.toLowerCase()) ||
645
- senderName.includes(OWNER_NAME.split(' ')[0].toLowerCase());
908
+ const isFromOwner = isOwnerName(msg.fromName);
646
909
  if (!isFromOwner) {
647
910
  console.log(`[slack-mentions] Skipping non-owner mention from: ${msg.fromName || 'unknown'}`);
648
911
  continue;
649
912
  }
650
913
 
651
- await _processMention(msg, seenTs, stats);
914
+ await _processMention(msg, seenTs, stats, { source: 'search' });
652
915
  }
653
916
  } catch (err) {
654
917
  if (err.message.includes('401') || err.message.includes('expired') ||
@@ -666,7 +929,7 @@ async function processNewMentions() {
666
929
  // Phase 2b: DM channel polling — catches thread replies that search misses
667
930
  try {
668
931
  await pollDmChannels(lastTs, seenTs, async (msg, seen) => {
669
- await _processMention(msg, seen, stats);
932
+ await _processMention(msg, seen, stats, { source: 'dm_poll' });
670
933
  });
671
934
  } catch (err) {
672
935
  console.warn(`[slack-mentions] DM polling error: ${err.message}`);
@@ -767,9 +1030,7 @@ async function pollDmChannels(lastTs, seenTs, processMsg) {
767
1030
  if (seenTs.has(msg.ts)) continue;
768
1031
 
769
1032
  // Check sender is owner
770
- const senderName = (msg.fromName || '').toLowerCase();
771
- const isFromOwner = senderName.includes(OWNER_HANDLE.toLowerCase()) ||
772
- senderName.includes(OWNER_NAME.split(' ')[0].toLowerCase());
1033
+ const isFromOwner = isOwnerName(msg.fromName);
773
1034
  if (!isFromOwner) continue;
774
1035
 
775
1036
  msg.channelId = channelId;
@@ -805,6 +1066,14 @@ function setNextRunInterval() {
805
1066
 
806
1067
  async function main() {
807
1068
  brain.initDb();
1069
+ if (typeof brain.pruneSlackInboundEvents === 'function') {
1070
+ try {
1071
+ const pruned = brain.pruneSlackInboundEvents(7 * 24 * 60 * 60 * 1000);
1072
+ if (pruned > 0) console.log(`[slack-mentions] Pruned ${pruned} old inbound event(s)`);
1073
+ } catch (err) {
1074
+ console.warn(`[slack-mentions] Could not prune inbound event ledger: ${err.message}`);
1075
+ }
1076
+ }
808
1077
  HOME_CHANNELS = discoverHomeChannels();
809
1078
 
810
1079
  if (!(await slackMcp.isAuthenticated())) {
@@ -824,7 +1093,21 @@ async function main() {
824
1093
  }
825
1094
 
826
1095
  // Export for skill-planner; also run directly when invoked as CLI script
827
- module.exports = { run: main };
1096
+ module.exports = {
1097
+ run: main,
1098
+ processIncomingSlackEvent,
1099
+ shouldAcceptSocketEvent,
1100
+ cleanWalleAddress,
1101
+ sourceRefMatchesMessage,
1102
+ isOwnerName,
1103
+ isMessageDraftRequest,
1104
+ slackResponseInstruction,
1105
+ buildSlackThreadSessionId,
1106
+ buildSlackDeliveryId,
1107
+ validateSlackAnswer,
1108
+ parseThreadReplies,
1109
+ selectThreadMessagesToProcess,
1110
+ };
828
1111
 
829
1112
  if (require.main === module) {
830
1113
  main().catch(err => {