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.
- package/README.md +8 -3
- package/bin/create-walle.js +232 -32
- package/bin/mcp-inject.js +18 -53
- package/package.json +3 -1
- package/template/claude-task-manager/api-prompts.js +11 -2
- package/template/claude-task-manager/approval-agent.js +7 -0
- package/template/claude-task-manager/db.js +94 -75
- package/template/claude-task-manager/docs/session-standup-command-center-design.md +242 -0
- package/template/claude-task-manager/docs/session-tooltip-freshness-design.md +224 -0
- package/template/claude-task-manager/docs/session-ux-issue-review-2026-05-01.md +369 -0
- package/template/claude-task-manager/fuzzy-utils.js +10 -2
- package/template/claude-task-manager/git-utils.js +140 -10
- package/template/claude-task-manager/lib/agent-capabilities.js +1 -1
- package/template/claude-task-manager/lib/agent-presets.js +38 -5
- package/template/claude-task-manager/lib/codex-terminal-final.js +53 -0
- package/template/claude-task-manager/lib/ctm-session-context-api.js +222 -0
- package/template/claude-task-manager/lib/session-diagnostics.js +56 -0
- package/template/claude-task-manager/lib/session-history.js +309 -16
- package/template/claude-task-manager/lib/session-standup.js +409 -0
- package/template/claude-task-manager/lib/session-stream.js +253 -20
- package/template/claude-task-manager/lib/standup-attention.js +200 -0
- package/template/claude-task-manager/lib/status-hooks.js +8 -2
- package/template/claude-task-manager/lib/update-telemetry.js +114 -0
- package/template/claude-task-manager/lib/walle-ctm-history.js +49 -6
- package/template/claude-task-manager/lib/walle-default-model.js +55 -0
- package/template/claude-task-manager/lib/walle-mcp-auto-config.js +66 -0
- package/template/claude-task-manager/lib/walle-supervisor.js +86 -19
- package/template/claude-task-manager/lib/walle-transcript.js +1 -3
- package/template/claude-task-manager/lib/worktree-cwd.js +82 -0
- package/template/claude-task-manager/package.json +1 -0
- package/template/claude-task-manager/providers/codex-mcp.js +104 -0
- package/template/claude-task-manager/providers/index.js +2 -0
- package/template/claude-task-manager/public/css/setup.css +2 -1
- package/template/claude-task-manager/public/css/walle.css +71 -0
- package/template/claude-task-manager/public/index.html +2388 -429
- package/template/claude-task-manager/public/js/message-renderer.js +314 -35
- package/template/claude-task-manager/public/js/session-search-utils.js +185 -3
- package/template/claude-task-manager/public/js/session-status-precedence.js +125 -0
- package/template/claude-task-manager/public/js/setup.js +62 -19
- package/template/claude-task-manager/public/js/stream-view.js +396 -55
- package/template/claude-task-manager/public/js/terminal-restore-state.js +57 -0
- package/template/claude-task-manager/public/js/walle-session.js +234 -26
- package/template/claude-task-manager/public/js/walle.js +143 -2
- package/template/claude-task-manager/server.js +1402 -433
- package/template/claude-task-manager/session-integrity.js +77 -28
- package/template/claude-task-manager/workers/approval-widget-validator.js +15 -5
- package/template/claude-task-manager/workers/scrollback-worker.js +5 -6
- package/template/claude-task-manager/workers/state-detectors/codex.js +6 -0
- package/template/package.json +1 -1
- package/template/wall-e/agent-runners/claude-code.js +2 -0
- package/template/wall-e/agent.js +63 -8
- package/template/wall-e/api-walle.js +330 -52
- package/template/wall-e/brain.js +291 -42
- package/template/wall-e/chat.js +172 -15
- package/template/wall-e/coding/compaction-service.js +19 -5
- package/template/wall-e/coding/stream-processor.js +22 -2
- package/template/wall-e/coding/workspace-replay.js +1 -4
- package/template/wall-e/coding-orchestrator.js +250 -80
- package/template/wall-e/compat.js +0 -28
- package/template/wall-e/context/context-builder.js +3 -1
- package/template/wall-e/embeddings.js +2 -7
- package/template/wall-e/eval/agent-runner.js +30 -9
- package/template/wall-e/eval/benchmark-generator.js +21 -1
- package/template/wall-e/eval/benchmarks/chat-eval.json +66 -6
- package/template/wall-e/eval/benchmarks/coding-agent.json +0 -596
- package/template/wall-e/eval/cc-replay.js +1 -0
- package/template/wall-e/eval/codex-cli-baseline.js +633 -0
- package/template/wall-e/eval/debug-agent003.js +1 -0
- package/template/wall-e/eval/eval-orchestrator.js +3 -3
- package/template/wall-e/eval/run-agent-benchmarks.js +11 -3
- package/template/wall-e/eval/run-codex-cli-baseline.js +177 -0
- package/template/wall-e/eval/run-model-comparison.js +1 -0
- package/template/wall-e/eval/swebench-adapter.js +1 -0
- package/template/wall-e/evaluation/quorum-evaluator.js +0 -1
- package/template/wall-e/extraction/knowledge-extractor.js +1 -2
- package/template/wall-e/lib/mcp-integration.js +336 -0
- package/template/wall-e/llm/ollama.js +47 -8
- package/template/wall-e/llm/ollama.plugin.json +1 -1
- package/template/wall-e/llm/tool-adapter.js +1 -0
- package/template/wall-e/loops/ingest.js +42 -8
- package/template/wall-e/loops/initiative.js +87 -2
- package/template/wall-e/mcp-server.js +872 -19
- package/template/wall-e/memory/ctm-context-client.js +230 -0
- package/template/wall-e/memory/ctm-session-context.js +1376 -0
- package/template/wall-e/prompts/coding/memory-protocol.md +6 -0
- package/template/wall-e/server.js +30 -1
- package/template/wall-e/skills/_bundled/memory-search/SKILL.md +8 -0
- package/template/wall-e/skills/_bundled/scan-ctm-sessions/SKILL.md +20 -0
- package/template/wall-e/skills/_bundled/scan-ctm-sessions/run.js +43 -0
- package/template/wall-e/skills/_bundled/slack-mentions/run.js +471 -188
- package/template/wall-e/skills/skill-planner.js +86 -4
- package/template/wall-e/slack/socket-mode-listener.js +276 -0
- package/template/wall-e/telemetry.js +70 -2
- package/template/wall-e/tools/builtin-middleware.js +55 -2
- package/template/wall-e/tools/shell-policy.js +1 -1
- package/template/wall-e/tools/slack-owner.js +104 -0
- package/template/website/index.html +4 -4
- 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
|
|
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
|
|
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] ${
|
|
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()}] ${
|
|
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:
|
|
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/
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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/
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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
|
|
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
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
const
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
496
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
537
|
-
|
|
755
|
+
if (!delivery.ok) claimStatus = 'ack_failed';
|
|
756
|
+
await sleep(PAUSE_MS);
|
|
538
757
|
}
|
|
539
|
-
|
|
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
|
-
|
|
542
|
-
console.error(`[slack-mentions] Failed to create coding task: ${taskErr.message}`);
|
|
763
|
+
return;
|
|
543
764
|
}
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
765
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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 (
|
|
584
|
-
|
|
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
|
-
|
|
592
|
-
|
|
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
|
|
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
|
|
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 = {
|
|
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 => {
|