create-walle 0.9.0 → 0.9.3
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 +35 -31
- package/package.json +3 -3
- package/template/CLAUDE.md +23 -1
- package/template/claude-task-manager/bin/restart-ctm.sh +3 -2
- package/template/claude-task-manager/db.js +38 -0
- package/template/claude-task-manager/public/css/walle.css +123 -0
- package/template/claude-task-manager/public/index.html +962 -69
- package/template/claude-task-manager/public/js/walle.js +374 -121
- package/template/claude-task-manager/public/prompts.html +84 -26
- package/template/claude-task-manager/public/walle-icon.svg +45 -0
- package/template/claude-task-manager/server.js +69 -4
- package/template/docs/openclaw-vs-walle-comparison.md +103 -0
- package/template/package.json +1 -1
- package/template/wall-e/agent.js +63 -3
- package/template/wall-e/api-walle.js +42 -0
- package/template/wall-e/brain.js +182 -5
- package/template/wall-e/channels/imessage-channel.js +4 -1
- package/template/wall-e/channels/slack-channel.js +3 -1
- package/template/wall-e/chat.js +106 -224
- package/template/wall-e/context/compactor.js +163 -0
- package/template/wall-e/context/context-builder.js +355 -0
- package/template/wall-e/context/state-snapshot.js +209 -0
- package/template/wall-e/context/token-counter.js +55 -0
- package/template/wall-e/context/topic-matcher.js +79 -0
- package/template/wall-e/core-tasks.js +24 -0
- package/template/wall-e/events/event-bus.js +23 -0
- package/template/wall-e/loops/ingest.js +4 -0
- package/template/wall-e/loops/initiative.js +316 -0
- package/template/wall-e/loops/tasks.js +55 -5
- package/template/wall-e/skills/_bundled/email-sync/run.js +3 -1
- package/template/wall-e/skills/_bundled/morning-briefing/run.js +41 -0
- package/template/wall-e/skills/_bundled/proactive-alerts/SKILL.md +20 -0
- package/template/wall-e/skills/_bundled/proactive-alerts/run.js +144 -0
- package/template/wall-e/skills/_bundled/slack-mentions/.watched-threads.json +18 -0
- package/template/wall-e/skills/_bundled/slack-mentions/.watermark.json +4 -0
- package/template/wall-e/skills/_bundled/slack-mentions/SKILL.md +52 -0
- package/template/wall-e/skills/_bundled/slack-mentions/run.js +470 -0
- package/template/wall-e/skills/_bundled/weekly-reflection/SKILL.md +69 -0
- package/template/wall-e/tests/brain.test.js +4 -4
- package/template/wall-e/tests/compactor.test.js +323 -0
- package/template/wall-e/tests/context-builder.test.js +215 -0
- package/template/wall-e/tests/event-bus.test.js +74 -0
- package/template/wall-e/tests/initiative.test.js +354 -0
- package/template/wall-e/tests/proactive-alerts.test.js +140 -0
- package/template/wall-e/tests/session-persistence.test.js +335 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const brain = require(path.resolve(__dirname, '..', '..', '..', 'brain'));
|
|
5
|
+
|
|
6
|
+
brain.initDb();
|
|
7
|
+
const db = brain.getDb();
|
|
8
|
+
|
|
9
|
+
async function main() {
|
|
10
|
+
const now = new Date();
|
|
11
|
+
const alerts = [];
|
|
12
|
+
|
|
13
|
+
// 1. Calendar events in the next 15 minutes
|
|
14
|
+
try {
|
|
15
|
+
const { getCalendarEvents } = require(path.resolve(__dirname, '..', '..', '..', 'tools', 'local-tools'));
|
|
16
|
+
const { events } = await getCalendarEvents({ days_ahead: 0 });
|
|
17
|
+
const soonMs = 15 * 60 * 1000;
|
|
18
|
+
for (const evt of events) {
|
|
19
|
+
const start = new Date(evt.start);
|
|
20
|
+
if (isNaN(start)) continue;
|
|
21
|
+
const diff = start.getTime() - now.getTime();
|
|
22
|
+
if (diff > 0 && diff <= soonMs) {
|
|
23
|
+
alerts.push({
|
|
24
|
+
type: 'calendar_soon',
|
|
25
|
+
urgency: 'critical',
|
|
26
|
+
title: `Meeting in ${Math.round(diff / 60000)}min: ${evt.title}`,
|
|
27
|
+
data: { event: evt.title, start: evt.start, attendees: evt.attendees?.slice(0, 5) },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
} catch (err) {
|
|
32
|
+
// Calendar unavailable -- skip silently
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 2. Direct @mentions in recent Slack messages (last 30 minutes)
|
|
36
|
+
try {
|
|
37
|
+
const ownerFirst = (process.env.WALLE_OWNER_NAME || 'owner').split(' ')[0].replace(/['"*()]/g, '');
|
|
38
|
+
const hasFts = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='memories_fts'").get();
|
|
39
|
+
if (hasFts && ownerFirst.length >= 2) {
|
|
40
|
+
const cutoff = new Date(now.getTime() - 30 * 60000).toISOString();
|
|
41
|
+
const mentions = db.prepare(`
|
|
42
|
+
SELECT m.content, m.source_channel, m.participants, m.timestamp
|
|
43
|
+
FROM memories_fts f
|
|
44
|
+
JOIN memories m ON m.rowid = f.rowid
|
|
45
|
+
WHERE memories_fts MATCH ?
|
|
46
|
+
AND m.source = 'slack' AND m.direction = 'inbound'
|
|
47
|
+
AND m.timestamp > ?
|
|
48
|
+
ORDER BY m.timestamp DESC
|
|
49
|
+
LIMIT 5
|
|
50
|
+
`).all(ownerFirst, cutoff);
|
|
51
|
+
|
|
52
|
+
for (const m of mentions) {
|
|
53
|
+
alerts.push({
|
|
54
|
+
type: 'mention',
|
|
55
|
+
urgency: 'today',
|
|
56
|
+
title: `@mention from ${extractName(m.participants)} in ${m.source_channel || 'Slack'}`,
|
|
57
|
+
data: { content: (m.content || '').slice(0, 200), channel: m.source_channel, timestamp: m.timestamp },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch {}
|
|
62
|
+
|
|
63
|
+
// 3. Tasks due within 1 hour
|
|
64
|
+
try {
|
|
65
|
+
const tasks = brain.listTasks({ status: 'pending', limit: 20 });
|
|
66
|
+
const oneHourMs = 60 * 60 * 1000;
|
|
67
|
+
for (const t of tasks) {
|
|
68
|
+
if (!t.due_at) continue;
|
|
69
|
+
const due = new Date(t.due_at);
|
|
70
|
+
const diff = due.getTime() - now.getTime();
|
|
71
|
+
if (diff > 0 && diff <= oneHourMs) {
|
|
72
|
+
alerts.push({
|
|
73
|
+
type: 'deadline',
|
|
74
|
+
urgency: diff <= 15 * 60000 ? 'critical' : 'today',
|
|
75
|
+
title: `Due in ${Math.round(diff / 60000)}min: ${t.title}`,
|
|
76
|
+
data: { task_id: t.id, title: t.title, due_at: t.due_at },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch {}
|
|
81
|
+
|
|
82
|
+
// 4. Failed tasks needing attention
|
|
83
|
+
try {
|
|
84
|
+
const failed = brain.listTasks({ status: 'failed', limit: 5 });
|
|
85
|
+
for (const t of failed) {
|
|
86
|
+
alerts.push({
|
|
87
|
+
type: 'failed_task',
|
|
88
|
+
urgency: 'today',
|
|
89
|
+
title: `Failed: ${t.title} -- ${(t.error || 'unknown error').slice(0, 80)}`,
|
|
90
|
+
data: { task_id: t.id, title: t.title, error: t.error },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
} catch {}
|
|
94
|
+
|
|
95
|
+
// 5. Urgent briefing items
|
|
96
|
+
try {
|
|
97
|
+
const urgent = db.prepare(`
|
|
98
|
+
SELECT * FROM briefing_items
|
|
99
|
+
WHERE status = 'new' AND urgency IN ('critical', 'today')
|
|
100
|
+
AND (snooze_until IS NULL OR snooze_until < datetime('now'))
|
|
101
|
+
ORDER BY urgency, created_at DESC LIMIT 5
|
|
102
|
+
`).all();
|
|
103
|
+
for (const item of urgent) {
|
|
104
|
+
alerts.push({
|
|
105
|
+
type: 'briefing_item',
|
|
106
|
+
urgency: item.urgency,
|
|
107
|
+
title: item.title,
|
|
108
|
+
data: { id: item.id, category: item.category, owner: item.owner },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
} catch {}
|
|
112
|
+
|
|
113
|
+
// Output
|
|
114
|
+
if (alerts.length === 0) {
|
|
115
|
+
console.log('No alerts.');
|
|
116
|
+
} else {
|
|
117
|
+
console.log(`${alerts.length} alert(s) found:`);
|
|
118
|
+
for (const a of alerts) {
|
|
119
|
+
console.log(`[${a.urgency}] ${a.title}`);
|
|
120
|
+
}
|
|
121
|
+
// Structured output for the task runner
|
|
122
|
+
console.log('<!-- BRIEFING_ITEMS');
|
|
123
|
+
console.log(JSON.stringify(alerts.map(a => ({
|
|
124
|
+
title: a.title,
|
|
125
|
+
category: a.type,
|
|
126
|
+
urgency: a.urgency,
|
|
127
|
+
context: a.data,
|
|
128
|
+
})), null, 2));
|
|
129
|
+
console.log('-->');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
brain.closeDb();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function extractName(participants) {
|
|
136
|
+
if (!participants) return 'someone';
|
|
137
|
+
const name = participants.split(',')[0].trim().replace(/^\*+|\*+$/g, '');
|
|
138
|
+
return name.split(' ')[0] || name || 'someone';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
main().catch(err => {
|
|
142
|
+
console.error('[proactive-alerts] Error:', err.message);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"D02U25VN72R:1775337680.059359": {
|
|
3
|
+
"channel_id": "D02U25VN72R",
|
|
4
|
+
"thread_ts": "1775337680.059359",
|
|
5
|
+
"task_id": "6bc67ff2-2f75-48b5-8931-effe8152833a",
|
|
6
|
+
"session_id": "task-6bc67ff2-2f75-48b5-8931-effe8152833a",
|
|
7
|
+
"last_activity": "2026-04-04T21:23:16.469Z",
|
|
8
|
+
"last_seen_ts": "1775337770.532239"
|
|
9
|
+
},
|
|
10
|
+
"D02U25VN72R:1775337821.126619": {
|
|
11
|
+
"channel_id": "D02U25VN72R",
|
|
12
|
+
"thread_ts": "1775337821.126619",
|
|
13
|
+
"task_id": "83555195-3f8d-4748-b628-2d6077bfa5a4",
|
|
14
|
+
"session_id": "task-83555195-3f8d-4748-b628-2d6077bfa5a4",
|
|
15
|
+
"last_activity": "2026-04-04T21:26:30.938Z",
|
|
16
|
+
"last_seen_ts": "1775337970.800449"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: slack-mentions
|
|
3
|
+
description: >
|
|
4
|
+
Monitor Slack for @walle mentions from the owner and create tasks or reply
|
|
5
|
+
to questions. Searches recent messages, deduplicates via watermark, creates
|
|
6
|
+
brain tasks for assignments, and replies in-thread for questions.
|
|
7
|
+
version: 1.0.0
|
|
8
|
+
author: wall-e
|
|
9
|
+
execution: script
|
|
10
|
+
entry: run.js
|
|
11
|
+
trigger:
|
|
12
|
+
type: interval
|
|
13
|
+
schedule: "every 30s"
|
|
14
|
+
tags: [slack, mentions, inbox]
|
|
15
|
+
permissions:
|
|
16
|
+
- slack:read
|
|
17
|
+
- slack:write
|
|
18
|
+
- brain:read
|
|
19
|
+
- brain:write
|
|
20
|
+
---
|
|
21
|
+
# Slack Mentions
|
|
22
|
+
|
|
23
|
+
## What This Skill Does
|
|
24
|
+
|
|
25
|
+
Polls Slack for messages mentioning @walle or @wall-e from the owner (Juncao).
|
|
26
|
+
Classifies each mention as either a task assignment or a question, then:
|
|
27
|
+
|
|
28
|
+
- **Task**: Creates a brain task with `source: 'slack'` and `source_ref` linking
|
|
29
|
+
back to the original message. Replies in-thread confirming receipt.
|
|
30
|
+
- **Question**: Replies in-thread with a short acknowledgement (actual answering
|
|
31
|
+
is deferred to the Wall-E chat agent).
|
|
32
|
+
|
|
33
|
+
## How It Works
|
|
34
|
+
|
|
35
|
+
1. Search Slack for `@walle` and `@wall-e` mentions in the last 60 seconds
|
|
36
|
+
2. Compare message timestamps against a local watermark file to skip duplicates
|
|
37
|
+
3. For each new mention from the owner:
|
|
38
|
+
- Classify as task or question based on imperative/interrogative language
|
|
39
|
+
- Create a brain task or log the question
|
|
40
|
+
- Reply in the Slack thread
|
|
41
|
+
4. Update the watermark to the latest processed timestamp
|
|
42
|
+
|
|
43
|
+
## Watermark
|
|
44
|
+
|
|
45
|
+
Stored in `.watermark.json` alongside this skill (not in the brain DB) for
|
|
46
|
+
simplicity and crash-safety.
|
|
47
|
+
|
|
48
|
+
## Error Handling
|
|
49
|
+
|
|
50
|
+
- Slack token expiry exits with code 1 and a clear message
|
|
51
|
+
- Individual message processing errors are logged and skipped
|
|
52
|
+
- The watermark is only advanced past successfully processed messages
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
|
|
6
|
+
const brain = require(path.resolve(__dirname, '..', '..', '..', 'brain'));
|
|
7
|
+
const slackMcp = require(path.resolve(__dirname, '..', '..', '..', 'tools', 'slack-mcp'));
|
|
8
|
+
|
|
9
|
+
const WATERMARK_FILE = path.join(__dirname, '.watermark.json');
|
|
10
|
+
const OWNER_HANDLE = process.env.SLACK_OWNER_HANDLE || 'juncao';
|
|
11
|
+
const OWNER_NAME = process.env.WALLE_OWNER_NAME || 'Juncao Li';
|
|
12
|
+
const PAUSE_MS = 800;
|
|
13
|
+
const WATCH_DURATION_MS = 2 * 60 * 60 * 1000; // 2 hours
|
|
14
|
+
|
|
15
|
+
// ── Watermark helpers ──
|
|
16
|
+
|
|
17
|
+
function loadWatermark() {
|
|
18
|
+
try {
|
|
19
|
+
const data = JSON.parse(fs.readFileSync(WATERMARK_FILE, 'utf8'));
|
|
20
|
+
return data.last_ts || null;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function saveWatermark(ts) {
|
|
27
|
+
fs.writeFileSync(WATERMARK_FILE, JSON.stringify({ last_ts: ts, updated_at: new Date().toISOString() }, null, 2));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Helpers ──
|
|
31
|
+
|
|
32
|
+
function extractText(result) {
|
|
33
|
+
const raw = (result?.content || []).filter(c => c.type === 'text').map(c => c.text).join('\n');
|
|
34
|
+
if (raw.startsWith('{')) {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
return parsed.results || parsed.messages || parsed.text || raw;
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
return raw;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sleep(ms) {
|
|
44
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function classifyMention(text) {
|
|
48
|
+
const trimmed = (text || '').trim();
|
|
49
|
+
const cleaned = trimmed.replace(/@wall-?e/gi, '').trim();
|
|
50
|
+
if (/\?\s*$/.test(cleaned)) return 'question';
|
|
51
|
+
if (/^(what|who|where|when|why|how|is|are|do|does|did|can|could|would|should|will)\b/i.test(cleaned)) return 'question';
|
|
52
|
+
return 'task';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractTaskTitle(text) {
|
|
56
|
+
const cleaned = (text || '').replace(/@wall-?e/gi, '').trim();
|
|
57
|
+
if (cleaned.length <= 120) return cleaned;
|
|
58
|
+
return cleaned.slice(0, 120).replace(/\s+\S*$/, '') + '...';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function fetchThreadContext(channelId, threadTs) {
|
|
62
|
+
try {
|
|
63
|
+
const result = await slackMcp.callSlackMcp('slack_read_thread', {
|
|
64
|
+
channel_id: channelId,
|
|
65
|
+
message_ts: threadTs,
|
|
66
|
+
limit: 50,
|
|
67
|
+
});
|
|
68
|
+
return extractText(result);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.warn(`[slack-mentions] Could not fetch thread context: ${err.message}`);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseSearchResults(rawText) {
|
|
76
|
+
const messages = [];
|
|
77
|
+
const blocks = rawText.split(/(?=### Result \d)/);
|
|
78
|
+
|
|
79
|
+
for (const block of blocks) {
|
|
80
|
+
if (!block.trim() || !block.includes('Result')) continue;
|
|
81
|
+
|
|
82
|
+
const channelMatch = block.match(/Channel:.*?\(ID:\s*(\w+)\)/);
|
|
83
|
+
let channelId = channelMatch ? channelMatch[1] : null;
|
|
84
|
+
|
|
85
|
+
const fromMatch = block.match(/From:\s*(.+?)\s*\(ID:\s*(\w+)\)/);
|
|
86
|
+
const fromName = fromMatch ? fromMatch[1].trim() : null;
|
|
87
|
+
const fromUserId = fromMatch ? fromMatch[2] : null;
|
|
88
|
+
|
|
89
|
+
const timeMatch = block.match(/Time:\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/);
|
|
90
|
+
const isoTimestamp = timeMatch ? timeMatch[1] : null;
|
|
91
|
+
|
|
92
|
+
const msgTsMatch = block.match(/Message_ts:\s*(\d+\.\d+)/);
|
|
93
|
+
const msgTs = msgTsMatch ? msgTsMatch[1] : null;
|
|
94
|
+
|
|
95
|
+
const threadMatch = block.match(/Thread_ts:\s*(\d+\.\d+)/i);
|
|
96
|
+
const threadTs = threadMatch ? threadMatch[1] : null;
|
|
97
|
+
|
|
98
|
+
const linkMatch = block.match(/Permalink:\s*\[.*?\]\((https?:\/\/[^\s)]+)\)/);
|
|
99
|
+
// Slack MCP returns {workspace}.slack.com but enterprise workspaces use {workspace}.enterprise.slack.com
|
|
100
|
+
let permalink = linkMatch ? linkMatch[1] : null;
|
|
101
|
+
if (permalink) permalink = permalink.replace(/^(https:\/\/\w+)\.slack\.com/, '$1.enterprise.slack.com');
|
|
102
|
+
|
|
103
|
+
// Fallback: extract channel ID from permalink if Channel field didn't match (e.g. DMs)
|
|
104
|
+
if (!channelId && permalink) {
|
|
105
|
+
const archiveMatch = permalink.match(/\/archives\/([A-Z0-9]+)\//);
|
|
106
|
+
if (archiveMatch) channelId = archiveMatch[1];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const textMatch = block.match(/Text:\s*\n([\s\S]*?)(?:\n---|\n###|$)/);
|
|
110
|
+
const content = textMatch ? textMatch[1].trim() : '';
|
|
111
|
+
|
|
112
|
+
if (/@wall-?e/i.test(content) && content.length > 0) {
|
|
113
|
+
messages.push({
|
|
114
|
+
ts: msgTs, isoTimestamp, channelId, threadTs,
|
|
115
|
+
fromName, fromUserId, permalink, content, raw: block.trim(),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return messages;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseThreadReplies(rawText) {
|
|
124
|
+
let text = rawText;
|
|
125
|
+
if (text.startsWith('{')) {
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(text);
|
|
128
|
+
text = parsed.messages || parsed.results || parsed.text || text;
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const replies = [];
|
|
133
|
+
const blocks = text.split(/(?=--- Reply \d)/);
|
|
134
|
+
|
|
135
|
+
for (const block of blocks) {
|
|
136
|
+
if (!block.trim() || !block.includes('Reply')) continue;
|
|
137
|
+
|
|
138
|
+
const fromMatch = block.match(/From:\s*(.+?)(?:\s*\(|\n)/);
|
|
139
|
+
const tsMatch = block.match(/Message TS:\s*(\d+\.\d+)/);
|
|
140
|
+
const from = fromMatch ? fromMatch[1].trim() : null;
|
|
141
|
+
const ts = tsMatch ? tsMatch[1] : null;
|
|
142
|
+
|
|
143
|
+
const lines = block.split('\n');
|
|
144
|
+
const metaEnd = lines.findIndex(l => /^Message TS:/.test(l));
|
|
145
|
+
const content = metaEnd >= 0 ? lines.slice(metaEnd + 1).join('\n').trim() : '';
|
|
146
|
+
|
|
147
|
+
// Skip bot replies (Sent using @Claude)
|
|
148
|
+
if (content.includes('Sent using') && content.includes('Claude')) continue;
|
|
149
|
+
|
|
150
|
+
if (content && ts) {
|
|
151
|
+
replies.push({ ts, from, content });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return replies;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Watched thread polling (DB-backed) ──
|
|
159
|
+
|
|
160
|
+
async function pollWatchedThreads() {
|
|
161
|
+
// Clean up expired threads
|
|
162
|
+
const expired = brain.deleteExpiredSlackThreads(WATCH_DURATION_MS);
|
|
163
|
+
if (expired > 0) console.log(`[slack-mentions] Expired ${expired} watched thread(s)`);
|
|
164
|
+
|
|
165
|
+
const activeThreads = brain.listActiveSlackThreads(WATCH_DURATION_MS);
|
|
166
|
+
if (activeThreads.length === 0) return;
|
|
167
|
+
|
|
168
|
+
for (const thread of activeThreads) {
|
|
169
|
+
try {
|
|
170
|
+
const rawThread = await fetchThreadContext(thread.channel_id, thread.thread_ts);
|
|
171
|
+
if (!rawThread) continue;
|
|
172
|
+
|
|
173
|
+
const replies = parseThreadReplies(rawThread);
|
|
174
|
+
if (replies.length === 0) continue;
|
|
175
|
+
|
|
176
|
+
const lastSeenTs = thread.last_seen_ts || thread.thread_ts;
|
|
177
|
+
|
|
178
|
+
// Find the last non-bot reply from the owner
|
|
179
|
+
const ownerReplies = replies.filter(r => {
|
|
180
|
+
if (!r.ts) return false;
|
|
181
|
+
const name = (r.from || '').toLowerCase();
|
|
182
|
+
return name.includes(OWNER_HANDLE.toLowerCase()) ||
|
|
183
|
+
name.includes(OWNER_NAME.split(' ')[0].toLowerCase());
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
if (ownerReplies.length === 0) continue;
|
|
187
|
+
|
|
188
|
+
const lastOwnerReply = ownerReplies[ownerReplies.length - 1];
|
|
189
|
+
const lastReplyInThread = replies[replies.length - 1];
|
|
190
|
+
|
|
191
|
+
// Skip if we already replied after the owner's last message (last reply is bot)
|
|
192
|
+
const lastReplyIsBot = lastReplyInThread && lastReplyInThread.ts > lastOwnerReply.ts;
|
|
193
|
+
if (lastReplyIsBot) continue;
|
|
194
|
+
|
|
195
|
+
// Collect unseen messages (new since last_seen_ts)
|
|
196
|
+
const newReplies = ownerReplies.filter(r => r.ts > lastSeenTs);
|
|
197
|
+
|
|
198
|
+
// If no strictly-new messages but the last thread message is from the owner,
|
|
199
|
+
// it means our previous reply attempt failed — retry the last seen message
|
|
200
|
+
let messagesToProcess;
|
|
201
|
+
if (newReplies.length > 0) {
|
|
202
|
+
messagesToProcess = newReplies;
|
|
203
|
+
} else if (lastOwnerReply.ts >= lastSeenTs) {
|
|
204
|
+
console.log(`[slack-mentions] Retrying unanswered message in thread ${thread.id} (ts=${lastOwnerReply.ts})`);
|
|
205
|
+
messagesToProcess = [lastOwnerReply];
|
|
206
|
+
} else {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
console.log(`[slack-mentions] ${messagesToProcess.length} message(s) to process in watched thread ${thread.id}`);
|
|
211
|
+
|
|
212
|
+
const combinedMessage = messagesToProcess.map(r => r.content).join('\n\n');
|
|
213
|
+
const latestTs = messagesToProcess[messagesToProcess.length - 1].ts;
|
|
214
|
+
|
|
215
|
+
let replySucceeded = false;
|
|
216
|
+
try {
|
|
217
|
+
const chatModule = require(path.resolve(__dirname, '..', '..', '..', 'chat'));
|
|
218
|
+
|
|
219
|
+
const followUpPrompt = `The user replied in the Slack thread with a follow-up message:\n\n${combinedMessage}\n\n---\nRespond to their follow-up. You MUST reply in the Slack thread using mcp_call with:\n- server: slack\n- tool: slack_send_message\n- arguments: { "channel_id": "${thread.channel_id}", "thread_ts": "${thread.thread_ts}", "message": "<your response>" }`;
|
|
220
|
+
|
|
221
|
+
const result = await chatModule.chat(followUpPrompt, {
|
|
222
|
+
channel: 'task',
|
|
223
|
+
session_id: thread.session_id,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
console.log(`[slack-mentions] Follow-up reply sent for thread ${thread.id}: ${(result.reply || '').slice(0, 100)}`);
|
|
227
|
+
replySucceeded = true;
|
|
228
|
+
} catch (chatErr) {
|
|
229
|
+
console.error(`[slack-mentions] Failed to process follow-up: ${chatErr.message}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Only advance last_seen_ts if the reply succeeded
|
|
233
|
+
brain.updateSlackThread(thread.id, {
|
|
234
|
+
last_seen_ts: replySucceeded ? latestTs : thread.last_seen_ts,
|
|
235
|
+
last_activity: new Date().toISOString(),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await sleep(PAUSE_MS);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
console.error(`[slack-mentions] Error polling thread ${thread.id}: ${err.message}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── New mention processing ──
|
|
246
|
+
|
|
247
|
+
async function processNewMentions() {
|
|
248
|
+
const lastTs = loadWatermark();
|
|
249
|
+
const now = new Date();
|
|
250
|
+
const yesterday = new Date(now.getTime() - 24 * 3600 * 1000);
|
|
251
|
+
const afterStr = yesterday.toISOString().split('T')[0];
|
|
252
|
+
|
|
253
|
+
console.log(`[slack-mentions] Checking for @walle mentions (watermark: ${lastTs || 'none'})`);
|
|
254
|
+
|
|
255
|
+
let newMentions = 0;
|
|
256
|
+
let tasksCreated = 0;
|
|
257
|
+
let questionsFound = 0;
|
|
258
|
+
let maxTs = lastTs;
|
|
259
|
+
const seenTs = new Set();
|
|
260
|
+
|
|
261
|
+
const queries = ['@walle', '@wall-e'];
|
|
262
|
+
|
|
263
|
+
for (const query of queries) {
|
|
264
|
+
try {
|
|
265
|
+
const result = await slackMcp.callSlackMcp('slack_search_public_and_private', {
|
|
266
|
+
query: `${query} after:${afterStr}`,
|
|
267
|
+
sort: 'timestamp',
|
|
268
|
+
sort_dir: 'desc',
|
|
269
|
+
limit: 10,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const rawText = extractText(result);
|
|
273
|
+
console.log(`[slack-mentions] Search "${query}": raw length=${rawText?.length || 0}`);
|
|
274
|
+
if (!rawText || rawText.length < 10) continue;
|
|
275
|
+
|
|
276
|
+
const messages = parseSearchResults(rawText);
|
|
277
|
+
console.log(`[slack-mentions] Parsed ${messages.length} message(s) from "${query}" search`);
|
|
278
|
+
|
|
279
|
+
for (const msg of messages) {
|
|
280
|
+
if (lastTs && msg.ts && msg.ts <= lastTs) continue;
|
|
281
|
+
if (lastTs && msg.isoTimestamp && msg.isoTimestamp <= lastTs) continue;
|
|
282
|
+
|
|
283
|
+
const dedupeKey = msg.ts || msg.permalink || msg.content;
|
|
284
|
+
if (seenTs.has(dedupeKey)) continue;
|
|
285
|
+
seenTs.add(dedupeKey);
|
|
286
|
+
|
|
287
|
+
const senderName = (msg.fromName || '').toLowerCase();
|
|
288
|
+
const isFromOwner = senderName.includes(OWNER_HANDLE.toLowerCase()) ||
|
|
289
|
+
senderName.includes(OWNER_NAME.split(' ')[0].toLowerCase());
|
|
290
|
+
|
|
291
|
+
if (!isFromOwner) {
|
|
292
|
+
console.log(`[slack-mentions] Skipping non-owner mention from: ${msg.fromName || 'unknown'}`);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
newMentions++;
|
|
297
|
+
const kind = classifyMention(msg.content);
|
|
298
|
+
const effectiveTs = msg.ts || msg.isoTimestamp || now.toISOString();
|
|
299
|
+
|
|
300
|
+
const rawRef = msg.permalink || (msg.channelId ? `${msg.channelId}:${msg.ts || 'unknown'}` : `${kind}:${effectiveTs}`);
|
|
301
|
+
const sourceRef = rawRef.split('?')[0];
|
|
302
|
+
const existing = brain.listTasks({ source: 'slack' }).find(t => t.source_ref && t.source_ref.split('?')[0] === sourceRef);
|
|
303
|
+
if (existing) {
|
|
304
|
+
console.log(`[slack-mentions] Skipping already-processed mention: ${sourceRef}`);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Fetch full thread context before creating the task
|
|
309
|
+
const threadTs = msg.threadTs || msg.ts;
|
|
310
|
+
let threadContext = null;
|
|
311
|
+
if (msg.channelId && threadTs) {
|
|
312
|
+
threadContext = await fetchThreadContext(msg.channelId, threadTs);
|
|
313
|
+
await sleep(PAUSE_MS);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const contextBlock = threadContext
|
|
317
|
+
? `\n\nFull thread context:\n${threadContext}`
|
|
318
|
+
: '';
|
|
319
|
+
|
|
320
|
+
if (kind === 'task') {
|
|
321
|
+
const title = extractTaskTitle(msg.content);
|
|
322
|
+
try {
|
|
323
|
+
const { id } = brain.insertTask({
|
|
324
|
+
title,
|
|
325
|
+
description: `Slack task from ${OWNER_NAME}:\n\n${msg.content}${contextBlock}\n\n---\n**IMPORTANT**: When done, reply with results in the Slack thread using mcp_call with:\n- server: slack\n- tool: slack_send_message\n- arguments: { "channel_id": "${msg.channelId}", "thread_ts": "${threadTs}", "message": "<your results>" }`,
|
|
326
|
+
priority: 'normal',
|
|
327
|
+
type: 'once',
|
|
328
|
+
execution: 'chat',
|
|
329
|
+
source: 'slack',
|
|
330
|
+
source_ref: sourceRef,
|
|
331
|
+
});
|
|
332
|
+
tasksCreated++;
|
|
333
|
+
console.log(`[slack-mentions] Task created: ${id} — ${title}`);
|
|
334
|
+
|
|
335
|
+
// Watch this thread for follow-ups (in DB)
|
|
336
|
+
try {
|
|
337
|
+
const threadId1 = brain.upsertSlackThread({ channelId: msg.channelId, threadTs, taskId: id, sessionId: `task-${id}` });
|
|
338
|
+
console.log(`[slack-mentions] Upserted watched thread: ${threadId1} (task, channel=${msg.channelId})`);
|
|
339
|
+
} catch (upsertErr) {
|
|
340
|
+
console.error(`[slack-mentions] FAILED to upsert watched thread: ${upsertErr.message} (channelId=${msg.channelId}, threadTs=${threadTs})`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (msg.channelId) {
|
|
344
|
+
try {
|
|
345
|
+
await slackMcp.callSlackMcp('slack_send_message', {
|
|
346
|
+
channel_id: msg.channelId,
|
|
347
|
+
message: `Got it! I've created a task for this. I'll update you here when it's done.`,
|
|
348
|
+
thread_ts: threadTs,
|
|
349
|
+
});
|
|
350
|
+
} catch (replyErr) {
|
|
351
|
+
console.warn(`[slack-mentions] Could not reply in thread: ${replyErr.message}`);
|
|
352
|
+
}
|
|
353
|
+
await sleep(PAUSE_MS);
|
|
354
|
+
}
|
|
355
|
+
} catch (taskErr) {
|
|
356
|
+
console.error(`[slack-mentions] Failed to create task: ${taskErr.message}`);
|
|
357
|
+
}
|
|
358
|
+
} else {
|
|
359
|
+
questionsFound++;
|
|
360
|
+
console.log(`[slack-mentions] Question detected: ${msg.content.slice(0, 80)}...`);
|
|
361
|
+
|
|
362
|
+
if (msg.channelId) {
|
|
363
|
+
try {
|
|
364
|
+
await slackMcp.callSlackMcp('slack_send_message', {
|
|
365
|
+
channel_id: msg.channelId,
|
|
366
|
+
message: `Let me look into that — I'll get back to you shortly.`,
|
|
367
|
+
thread_ts: threadTs,
|
|
368
|
+
});
|
|
369
|
+
} catch (replyErr) {
|
|
370
|
+
console.warn(`[slack-mentions] Could not reply in thread: ${replyErr.message}`);
|
|
371
|
+
}
|
|
372
|
+
await sleep(PAUSE_MS);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
const { id } = brain.insertTask({
|
|
377
|
+
title: `Answer Slack question: ${extractTaskTitle(msg.content)}`,
|
|
378
|
+
description: `Question from ${OWNER_NAME} in Slack:\n\n${msg.content}${contextBlock}\n\n---\n**IMPORTANT**: After answering, you MUST reply in the Slack thread using mcp_call with:\n- server: slack\n- tool: slack_send_message\n- arguments: { "channel_id": "${msg.channelId}", "thread_ts": "${threadTs}", "message": "<your answer>" }`,
|
|
379
|
+
priority: 'high',
|
|
380
|
+
type: 'once',
|
|
381
|
+
execution: 'chat',
|
|
382
|
+
source: 'slack',
|
|
383
|
+
source_ref: sourceRef,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// Watch this thread for follow-ups (in DB)
|
|
387
|
+
try {
|
|
388
|
+
const threadId2 = brain.upsertSlackThread({ channelId: msg.channelId, threadTs, taskId: id, sessionId: `task-${id}` });
|
|
389
|
+
console.log(`[slack-mentions] Upserted watched thread: ${threadId2} (question, channel=${msg.channelId})`);
|
|
390
|
+
} catch (upsertErr) {
|
|
391
|
+
console.error(`[slack-mentions] FAILED to upsert watched thread: ${upsertErr.message} (channelId=${msg.channelId}, threadTs=${threadTs})`);
|
|
392
|
+
}
|
|
393
|
+
} catch (taskErr) {
|
|
394
|
+
console.error(`[slack-mentions] Failed to create question task: ${taskErr.message}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (effectiveTs > (maxTs || '')) {
|
|
399
|
+
maxTs = effectiveTs;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
} catch (err) {
|
|
403
|
+
if (err.message.includes('401') || err.message.includes('expired')) {
|
|
404
|
+
console.error('[slack-mentions] Slack token expired!');
|
|
405
|
+
brain.closeDb();
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
408
|
+
console.error(`[slack-mentions] Search error for "${query}": ${err.message}`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
await sleep(PAUSE_MS);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (maxTs && maxTs !== lastTs) {
|
|
415
|
+
saveWatermark(maxTs);
|
|
416
|
+
console.log(`[slack-mentions] Watermark updated: ${maxTs}`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (newMentions === 0) {
|
|
420
|
+
console.log('[slack-mentions] No new mentions.');
|
|
421
|
+
} else {
|
|
422
|
+
console.log(`[slack-mentions] Processed ${newMentions} mention(s): ${tasksCreated} task(s), ${questionsFound} question(s).`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ── Adaptive schedule ──
|
|
427
|
+
|
|
428
|
+
function setNextRunInterval() {
|
|
429
|
+
const activeThreads = brain.listActiveSlackThreads(WATCH_DURATION_MS);
|
|
430
|
+
const hasActiveThreads = activeThreads.length > 0;
|
|
431
|
+
|
|
432
|
+
const allTasks = brain.listTasks({});
|
|
433
|
+
const selfTask = allTasks.find(t => t.skill === 'slack-mentions' && t.type === 'recurring');
|
|
434
|
+
if (selfTask) {
|
|
435
|
+
const intervalMs = hasActiveThreads ? 10_000 : 30_000;
|
|
436
|
+
const nextRun = new Date(Date.now() + intervalMs).toISOString();
|
|
437
|
+
brain.updateTask(selfTask.id, { next_run_at: nextRun });
|
|
438
|
+
if (hasActiveThreads) {
|
|
439
|
+
console.log(`[slack-mentions] Active threads detected — next poll in 10s`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── Main ──
|
|
445
|
+
|
|
446
|
+
async function main() {
|
|
447
|
+
brain.initDb();
|
|
448
|
+
|
|
449
|
+
if (!slackMcp.isAuthenticated()) {
|
|
450
|
+
console.error('[slack-mentions] Slack token expired! Run: node tools/slack-mcp.js auth');
|
|
451
|
+
process.exit(1);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Phase 1: Check watched threads for follow-up replies
|
|
455
|
+
await pollWatchedThreads();
|
|
456
|
+
|
|
457
|
+
// Phase 2: Search for new @walle mentions
|
|
458
|
+
await processNewMentions();
|
|
459
|
+
|
|
460
|
+
// Phase 3: Set next poll interval (10s if active threads, 30s otherwise)
|
|
461
|
+
setNextRunInterval();
|
|
462
|
+
|
|
463
|
+
brain.closeDb();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
main().catch(err => {
|
|
467
|
+
console.error('[slack-mentions] Error:', err.message);
|
|
468
|
+
try { brain.closeDb(); } catch {};
|
|
469
|
+
process.exit(1);
|
|
470
|
+
});
|