clementine-agent 1.0.0

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 (190) hide show
  1. package/.env.example +44 -0
  2. package/LICENSE +21 -0
  3. package/README.md +795 -0
  4. package/dist/agent/agent-manager.d.ts +69 -0
  5. package/dist/agent/agent-manager.js +441 -0
  6. package/dist/agent/assistant.d.ts +225 -0
  7. package/dist/agent/assistant.js +3888 -0
  8. package/dist/agent/auto-update.d.ts +32 -0
  9. package/dist/agent/auto-update.js +186 -0
  10. package/dist/agent/daily-planner.d.ts +24 -0
  11. package/dist/agent/daily-planner.js +379 -0
  12. package/dist/agent/execution-advisor.d.ts +10 -0
  13. package/dist/agent/execution-advisor.js +272 -0
  14. package/dist/agent/hooks.d.ts +45 -0
  15. package/dist/agent/hooks.js +564 -0
  16. package/dist/agent/insight-engine.d.ts +66 -0
  17. package/dist/agent/insight-engine.js +225 -0
  18. package/dist/agent/intent-classifier.d.ts +48 -0
  19. package/dist/agent/intent-classifier.js +214 -0
  20. package/dist/agent/link-extractor.d.ts +19 -0
  21. package/dist/agent/link-extractor.js +90 -0
  22. package/dist/agent/mcp-bridge.d.ts +62 -0
  23. package/dist/agent/mcp-bridge.js +435 -0
  24. package/dist/agent/metacognition.d.ts +66 -0
  25. package/dist/agent/metacognition.js +221 -0
  26. package/dist/agent/orchestrator.d.ts +81 -0
  27. package/dist/agent/orchestrator.js +790 -0
  28. package/dist/agent/profiles.d.ts +22 -0
  29. package/dist/agent/profiles.js +91 -0
  30. package/dist/agent/prompt-cache.d.ts +24 -0
  31. package/dist/agent/prompt-cache.js +68 -0
  32. package/dist/agent/prompt-evolver.d.ts +28 -0
  33. package/dist/agent/prompt-evolver.js +279 -0
  34. package/dist/agent/role-scaffolds.d.ts +28 -0
  35. package/dist/agent/role-scaffolds.js +433 -0
  36. package/dist/agent/safe-restart.d.ts +41 -0
  37. package/dist/agent/safe-restart.js +150 -0
  38. package/dist/agent/self-improve.d.ts +66 -0
  39. package/dist/agent/self-improve.js +1706 -0
  40. package/dist/agent/session-event-log.d.ts +114 -0
  41. package/dist/agent/session-event-log.js +233 -0
  42. package/dist/agent/skill-extractor.d.ts +72 -0
  43. package/dist/agent/skill-extractor.js +435 -0
  44. package/dist/agent/source-mods.d.ts +61 -0
  45. package/dist/agent/source-mods.js +230 -0
  46. package/dist/agent/source-preflight.d.ts +25 -0
  47. package/dist/agent/source-preflight.js +100 -0
  48. package/dist/agent/stall-guard.d.ts +62 -0
  49. package/dist/agent/stall-guard.js +109 -0
  50. package/dist/agent/strategic-planner.d.ts +60 -0
  51. package/dist/agent/strategic-planner.js +352 -0
  52. package/dist/agent/team-bus.d.ts +89 -0
  53. package/dist/agent/team-bus.js +556 -0
  54. package/dist/agent/team-router.d.ts +26 -0
  55. package/dist/agent/team-router.js +37 -0
  56. package/dist/agent/tool-loop-detector.d.ts +59 -0
  57. package/dist/agent/tool-loop-detector.js +242 -0
  58. package/dist/agent/workflow-runner.d.ts +36 -0
  59. package/dist/agent/workflow-runner.js +317 -0
  60. package/dist/agent/workflow-variables.d.ts +16 -0
  61. package/dist/agent/workflow-variables.js +62 -0
  62. package/dist/channels/discord-agent-bot.d.ts +101 -0
  63. package/dist/channels/discord-agent-bot.js +881 -0
  64. package/dist/channels/discord-bot-manager.d.ts +80 -0
  65. package/dist/channels/discord-bot-manager.js +262 -0
  66. package/dist/channels/discord-utils.d.ts +51 -0
  67. package/dist/channels/discord-utils.js +293 -0
  68. package/dist/channels/discord.d.ts +12 -0
  69. package/dist/channels/discord.js +1832 -0
  70. package/dist/channels/slack-agent-bot.d.ts +73 -0
  71. package/dist/channels/slack-agent-bot.js +320 -0
  72. package/dist/channels/slack-bot-manager.d.ts +66 -0
  73. package/dist/channels/slack-bot-manager.js +236 -0
  74. package/dist/channels/slack-utils.d.ts +39 -0
  75. package/dist/channels/slack-utils.js +189 -0
  76. package/dist/channels/slack.d.ts +11 -0
  77. package/dist/channels/slack.js +196 -0
  78. package/dist/channels/telegram.d.ts +10 -0
  79. package/dist/channels/telegram.js +235 -0
  80. package/dist/channels/webhook.d.ts +9 -0
  81. package/dist/channels/webhook.js +78 -0
  82. package/dist/channels/whatsapp.d.ts +11 -0
  83. package/dist/channels/whatsapp.js +181 -0
  84. package/dist/cli/chat.d.ts +14 -0
  85. package/dist/cli/chat.js +220 -0
  86. package/dist/cli/cron.d.ts +17 -0
  87. package/dist/cli/cron.js +552 -0
  88. package/dist/cli/dashboard.d.ts +15 -0
  89. package/dist/cli/dashboard.js +17677 -0
  90. package/dist/cli/index.d.ts +3 -0
  91. package/dist/cli/index.js +2474 -0
  92. package/dist/cli/routes/delegations.d.ts +19 -0
  93. package/dist/cli/routes/delegations.js +154 -0
  94. package/dist/cli/routes/digest.d.ts +17 -0
  95. package/dist/cli/routes/digest.js +375 -0
  96. package/dist/cli/routes/goals.d.ts +14 -0
  97. package/dist/cli/routes/goals.js +258 -0
  98. package/dist/cli/routes/workflows.d.ts +18 -0
  99. package/dist/cli/routes/workflows.js +97 -0
  100. package/dist/cli/setup.d.ts +8 -0
  101. package/dist/cli/setup.js +619 -0
  102. package/dist/cli/tunnel.d.ts +35 -0
  103. package/dist/cli/tunnel.js +141 -0
  104. package/dist/config.d.ts +145 -0
  105. package/dist/config.js +278 -0
  106. package/dist/events/bus.d.ts +43 -0
  107. package/dist/events/bus.js +136 -0
  108. package/dist/gateway/cron-scheduler.d.ts +166 -0
  109. package/dist/gateway/cron-scheduler.js +1767 -0
  110. package/dist/gateway/delivery-queue.d.ts +30 -0
  111. package/dist/gateway/delivery-queue.js +110 -0
  112. package/dist/gateway/heartbeat-scheduler.d.ts +99 -0
  113. package/dist/gateway/heartbeat-scheduler.js +1298 -0
  114. package/dist/gateway/heartbeat.d.ts +3 -0
  115. package/dist/gateway/heartbeat.js +3 -0
  116. package/dist/gateway/lanes.d.ts +24 -0
  117. package/dist/gateway/lanes.js +76 -0
  118. package/dist/gateway/notifications.d.ts +29 -0
  119. package/dist/gateway/notifications.js +75 -0
  120. package/dist/gateway/router.d.ts +210 -0
  121. package/dist/gateway/router.js +1330 -0
  122. package/dist/index.d.ts +12 -0
  123. package/dist/index.js +1015 -0
  124. package/dist/memory/chunker.d.ts +28 -0
  125. package/dist/memory/chunker.js +226 -0
  126. package/dist/memory/consolidation.d.ts +44 -0
  127. package/dist/memory/consolidation.js +171 -0
  128. package/dist/memory/context-assembler.d.ts +50 -0
  129. package/dist/memory/context-assembler.js +149 -0
  130. package/dist/memory/embeddings.d.ts +38 -0
  131. package/dist/memory/embeddings.js +180 -0
  132. package/dist/memory/graph-store.d.ts +66 -0
  133. package/dist/memory/graph-store.js +613 -0
  134. package/dist/memory/mmr.d.ts +21 -0
  135. package/dist/memory/mmr.js +75 -0
  136. package/dist/memory/search.d.ts +26 -0
  137. package/dist/memory/search.js +67 -0
  138. package/dist/memory/store.d.ts +530 -0
  139. package/dist/memory/store.js +2022 -0
  140. package/dist/security/integrity.d.ts +24 -0
  141. package/dist/security/integrity.js +58 -0
  142. package/dist/security/patterns.d.ts +34 -0
  143. package/dist/security/patterns.js +110 -0
  144. package/dist/security/scanner.d.ts +32 -0
  145. package/dist/security/scanner.js +263 -0
  146. package/dist/tools/admin-tools.d.ts +12 -0
  147. package/dist/tools/admin-tools.js +1278 -0
  148. package/dist/tools/external-tools.d.ts +11 -0
  149. package/dist/tools/external-tools.js +1327 -0
  150. package/dist/tools/goal-tools.d.ts +9 -0
  151. package/dist/tools/goal-tools.js +159 -0
  152. package/dist/tools/mcp-server.d.ts +13 -0
  153. package/dist/tools/mcp-server.js +141 -0
  154. package/dist/tools/memory-tools.d.ts +10 -0
  155. package/dist/tools/memory-tools.js +568 -0
  156. package/dist/tools/session-tools.d.ts +6 -0
  157. package/dist/tools/session-tools.js +146 -0
  158. package/dist/tools/shared.d.ts +216 -0
  159. package/dist/tools/shared.js +340 -0
  160. package/dist/tools/team-tools.d.ts +6 -0
  161. package/dist/tools/team-tools.js +447 -0
  162. package/dist/tools/tool-meta.d.ts +34 -0
  163. package/dist/tools/tool-meta.js +133 -0
  164. package/dist/tools/vault-tools.d.ts +8 -0
  165. package/dist/tools/vault-tools.js +457 -0
  166. package/dist/types.d.ts +716 -0
  167. package/dist/types.js +16 -0
  168. package/dist/vault-migrations/0001-add-execution-framework.d.ts +10 -0
  169. package/dist/vault-migrations/0001-add-execution-framework.js +47 -0
  170. package/dist/vault-migrations/0002-add-agentic-communication.d.ts +12 -0
  171. package/dist/vault-migrations/0002-add-agentic-communication.js +79 -0
  172. package/dist/vault-migrations/0003-update-execution-pipeline-narration.d.ts +11 -0
  173. package/dist/vault-migrations/0003-update-execution-pipeline-narration.js +73 -0
  174. package/dist/vault-migrations/helpers.d.ts +14 -0
  175. package/dist/vault-migrations/helpers.js +44 -0
  176. package/dist/vault-migrations/runner.d.ts +14 -0
  177. package/dist/vault-migrations/runner.js +139 -0
  178. package/dist/vault-migrations/types.d.ts +42 -0
  179. package/dist/vault-migrations/types.js +9 -0
  180. package/install.sh +320 -0
  181. package/package.json +84 -0
  182. package/scripts/postinstall.js +125 -0
  183. package/vault/00-System/AGENTS.md +66 -0
  184. package/vault/00-System/CRON.md +71 -0
  185. package/vault/00-System/HEARTBEAT.md +58 -0
  186. package/vault/00-System/MEMORY.md +16 -0
  187. package/vault/00-System/SOUL.md +96 -0
  188. package/vault/05-Tasks/TASKS.md +19 -0
  189. package/vault/06-Templates/_Daily-Template.md +28 -0
  190. package/vault/06-Templates/_People-Template.md +22 -0
@@ -0,0 +1,1832 @@
1
+ /**
2
+ * Clementine TypeScript — Discord channel adapter.
3
+ *
4
+ * DM-only personal assistant bot using discord.js v14.
5
+ * Features: streaming responses, message chunking, model switching,
6
+ * heartbeat/cron commands, slash commands, and autonomous notifications.
7
+ */
8
+ import { ActivityType, Client, EmbedBuilder, Events, GatewayIntentBits, Partials, REST, Routes, SlashCommandBuilder, } from 'discord.js';
9
+ import pino from 'pino';
10
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { chunkText, sendChunked, DiscordStreamingMessage, friendlyToolName, formatCronEmbed, } from './discord-utils.js';
14
+ import { DISCORD_TOKEN, DISCORD_OWNER_ID, DISCORD_WATCHED_CHANNELS, MODELS, ASSISTANT_NAME, PKG_DIR, VAULT_DIR, BASE_DIR, DEFAULT_MODEL_TIER, ENABLE_1M_CONTEXT, } from '../config.js';
15
+ import { findProjectByName, getLinkedProjects } from '../agent/assistant.js';
16
+ import * as cronParser from 'cron-parser';
17
+ const logger = pino({ name: 'clementine.discord' });
18
+ const BOT_MESSAGE_TRACKING_LIMIT = 100;
19
+ // ── Slash command definitions ──────────────────────────────────────────
20
+ const slashCommands = [
21
+ new SlashCommandBuilder().setName('plan').setDescription('Break a task into parallel steps')
22
+ .addStringOption(o => o.setName('task').setDescription('What to plan').setRequired(true)),
23
+ new SlashCommandBuilder().setName('deep').setDescription('Extended mode (100 turns) for heavy tasks')
24
+ .addStringOption(o => o.setName('message').setDescription('Your message').setRequired(true)),
25
+ new SlashCommandBuilder().setName('quick').setDescription('Quick reply using Haiku model')
26
+ .addStringOption(o => o.setName('message').setDescription('Your message').setRequired(true)),
27
+ new SlashCommandBuilder().setName('opus').setDescription('Deep reply using Opus model')
28
+ .addStringOption(o => o.setName('message').setDescription('Your message').setRequired(true)),
29
+ new SlashCommandBuilder().setName('model').setDescription('Switch default model')
30
+ .addStringOption(o => o.setName('tier').setDescription('Model tier').setRequired(true)
31
+ .addChoices({ name: 'Haiku', value: 'haiku' }, { name: 'Sonnet', value: 'sonnet' }, { name: 'Opus', value: 'opus' })),
32
+ new SlashCommandBuilder().setName('cron').setDescription('Manage scheduled tasks')
33
+ .addStringOption(o => o.setName('action').setDescription('Action').setRequired(true)
34
+ .addChoices({ name: 'List jobs', value: 'list' }, { name: 'Run a job', value: 'run' }, { name: 'Enable a job', value: 'enable' }, { name: 'Disable a job', value: 'disable' }))
35
+ .addStringOption(o => o.setName('job').setDescription('Job name (for run/enable/disable)').setAutocomplete(true)),
36
+ new SlashCommandBuilder().setName('heartbeat').setDescription('Run heartbeat check manually'),
37
+ new SlashCommandBuilder().setName('tools').setDescription('List available MCP tools'),
38
+ new SlashCommandBuilder().setName('project').setDescription('Set active project context')
39
+ .addStringOption(o => o.setName('action').setDescription('Action').setRequired(true)
40
+ .addChoices({ name: 'List projects', value: 'list' }, { name: 'Set active project', value: 'set' }, { name: 'Clear active project', value: 'clear' }, { name: 'Show current', value: 'status' }))
41
+ .addStringOption(o => o.setName('name').setDescription('Project name (for set)').setAutocomplete(true)),
42
+ new SlashCommandBuilder().setName('workflow').setDescription('Manage multi-step workflows')
43
+ .addStringOption(o => o.setName('action').setDescription('Action').setRequired(true)
44
+ .addChoices({ name: 'List workflows', value: 'list' }, { name: 'Run a workflow', value: 'run' }))
45
+ .addStringOption(o => o.setName('name').setDescription('Workflow name (for run)').setAutocomplete(true))
46
+ .addStringOption(o => o.setName('inputs').setDescription('Input overrides (key=val key=val)')),
47
+ new SlashCommandBuilder().setName('status').setDescription('Check unleashed task progress')
48
+ .addStringOption(o => o.setName('job').setDescription('Job name (omit for all)')),
49
+ new SlashCommandBuilder().setName('self-improve').setDescription('Manage Clementine self-improvement')
50
+ .addSubcommand(sub => sub.setName('run').setDescription('Trigger self-improvement cycle'))
51
+ .addSubcommand(sub => sub.setName('status').setDescription('Show self-improvement status'))
52
+ .addSubcommand(sub => sub.setName('history').setDescription('Show experiment history'))
53
+ .addSubcommand(sub => sub.setName('pending').setDescription('List pending proposals')),
54
+ new SlashCommandBuilder().setName('team').setDescription('Manage agent team')
55
+ .addStringOption(o => o.setName('action').setDescription('Action').setRequired(true)
56
+ .addChoices({ name: 'List agents', value: 'list' }, { name: 'Agent status', value: 'status' }, { name: 'Recent messages', value: 'messages' }, { name: 'Topology', value: 'topology' })),
57
+ new SlashCommandBuilder().setName('dashboard').setDescription('Live system status embed (auto-refreshes)'),
58
+ new SlashCommandBuilder().setName('verbose').setDescription('Set response verbosity level')
59
+ .addStringOption(o => o.setName('level').setDescription('Verbosity level').setRequired(true)
60
+ .addChoices({ name: 'Quiet', value: 'quiet' }, { name: 'Normal', value: 'normal' }, { name: 'Detailed', value: 'detailed' })),
61
+ new SlashCommandBuilder().setName('clear').setDescription('Reset conversation session'),
62
+ new SlashCommandBuilder().setName('help').setDescription('Show all available commands'),
63
+ ];
64
+ const botMessageMap = new Map();
65
+ function trackBotMessage(messageId, context) {
66
+ botMessageMap.set(messageId, context);
67
+ // Evict oldest entries to prevent memory leak
68
+ if (botMessageMap.size > BOT_MESSAGE_TRACKING_LIMIT) {
69
+ const firstKey = botMessageMap.keys().next().value;
70
+ if (firstKey)
71
+ botMessageMap.delete(firstKey);
72
+ }
73
+ }
74
+ // ── Lazy memory store for feedback logging ──────────────────────────────
75
+ let _feedbackStore = null;
76
+ async function getFeedbackStore() {
77
+ if (_feedbackStore)
78
+ return _feedbackStore;
79
+ try {
80
+ const { MemoryStore } = await import('../memory/store.js');
81
+ const { MEMORY_DB_PATH } = await import('../config.js');
82
+ const store = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
83
+ store.initialize();
84
+ _feedbackStore = store;
85
+ return _feedbackStore;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ // ── Emoji to feedback rating mapping ────────────────────────────────────
92
+ function emojiToRating(emoji) {
93
+ const positiveEmoji = ['\u{1F44D}', 'thumbsup', '\u{2764}\ufe0f', 'heart', '\u{2B50}', 'star'];
94
+ const negativeEmoji = ['\u{1F44E}', 'thumbsdown'];
95
+ if (positiveEmoji.includes(emoji))
96
+ return 'positive';
97
+ if (negativeEmoji.includes(emoji))
98
+ return 'negative';
99
+ return null;
100
+ }
101
+ // ── Approval buttons helper ──────────────────────────────────────────
102
+ /**
103
+ * Send a message with approve/deny buttons and return the message.
104
+ * The requestId is embedded in the button customId for routing.
105
+ */
106
+ async function sendApprovalButtons(channel, content, prefix, requestId, options) {
107
+ if (!('send' in channel))
108
+ return null;
109
+ const buttons = [
110
+ {
111
+ type: 2, // Button
112
+ style: 3, // Green
113
+ label: 'Approve',
114
+ custom_id: `${prefix}_${requestId}_approve`,
115
+ },
116
+ ];
117
+ if (options?.showRevise) {
118
+ buttons.push({
119
+ type: 2,
120
+ style: 1, // Blurple/Primary
121
+ label: 'Revise',
122
+ custom_id: `${prefix}_${requestId}_revise`,
123
+ });
124
+ }
125
+ buttons.push({
126
+ type: 2, // Button
127
+ style: 4, // Red
128
+ label: 'Cancel',
129
+ custom_id: `${prefix}_${requestId}_deny`,
130
+ });
131
+ const components = [{ type: 1, components: buttons }];
132
+ return channel.send({ content: content.slice(0, 2000), components: components });
133
+ }
134
+ // ── Tools listing ─────────────────────────────────────────────────────
135
+ function formatToolsList() {
136
+ const lines = ['**Available Tools**\n'];
137
+ // MCP tools (parse from source)
138
+ const mcpSrc = path.join(PKG_DIR, 'src', 'tools', 'mcp-server.ts');
139
+ if (existsSync(mcpSrc)) {
140
+ const src = readFileSync(mcpSrc, 'utf-8');
141
+ const toolPattern = /server\.tool\(\s*'([^']+)',\s*(['"])(.+?)\2/gs;
142
+ const tools = [];
143
+ let match;
144
+ while ((match = toolPattern.exec(src)) !== null) {
145
+ tools.push({ name: match[1], desc: match[3] });
146
+ }
147
+ if (tools.length > 0) {
148
+ lines.push(`**MCP Tools** (${tools.length})`);
149
+ for (const t of tools) {
150
+ lines.push(`\`${t.name}\` — ${t.desc.slice(0, 80)}${t.desc.length > 80 ? '...' : ''}`);
151
+ }
152
+ lines.push('');
153
+ }
154
+ }
155
+ // SDK tools
156
+ lines.push('**SDK Built-in Tools** (8)');
157
+ lines.push('`Read` `Write` `Edit` `Bash` `Glob` `Grep` `WebSearch` `WebFetch`');
158
+ lines.push('');
159
+ // Claude Code plugins
160
+ const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
161
+ if (existsSync(settingsPath)) {
162
+ try {
163
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
164
+ const plugins = Object.entries(settings.enabledPlugins ?? {})
165
+ .filter(([, v]) => v)
166
+ .map(([id]) => id.split('@')[0]);
167
+ if (plugins.length > 0) {
168
+ lines.push(`**Claude Code Plugins** (${plugins.length})`);
169
+ lines.push(plugins.map((p) => `\`${p}\``).join(' '));
170
+ lines.push('');
171
+ }
172
+ }
173
+ catch { /* ignore */ }
174
+ }
175
+ return lines.join('\n');
176
+ }
177
+ // ── Unleashed status helper ───────────────────────────────────────────
178
+ function handleUnleashedStatus(jobName) {
179
+ const unleashedDir = path.join(BASE_DIR, 'unleashed');
180
+ if (!existsSync(unleashedDir)) {
181
+ return 'No unleashed tasks found.';
182
+ }
183
+ const dirs = readdirSync(unleashedDir).filter(d => {
184
+ try {
185
+ return statSync(path.join(unleashedDir, d)).isDirectory();
186
+ }
187
+ catch {
188
+ return false;
189
+ }
190
+ });
191
+ if (dirs.length === 0)
192
+ return 'No unleashed tasks found.';
193
+ // If a specific job is requested, show detailed status
194
+ if (jobName) {
195
+ const safeName = jobName.replace(/[^a-zA-Z0-9_-]/g, '_');
196
+ const statusFile = path.join(unleashedDir, safeName, 'status.json');
197
+ if (!existsSync(statusFile)) {
198
+ return `No status found for unleashed task "${jobName}".`;
199
+ }
200
+ try {
201
+ const status = JSON.parse(readFileSync(statusFile, 'utf-8'));
202
+ const elapsed = status.startedAt
203
+ ? Math.round((Date.now() - new Date(status.startedAt).getTime()) / 60000)
204
+ : 0;
205
+ const elapsedStr = elapsed < 60 ? `${elapsed}m` : `${Math.floor(elapsed / 60)}h ${elapsed % 60}m`;
206
+ const remaining = status.maxHours && status.startedAt
207
+ ? Math.max(0, Math.round(status.maxHours * 60 - elapsed))
208
+ : null;
209
+ const remainStr = remaining != null ? (remaining < 60 ? `${remaining}m` : `${Math.floor(remaining / 60)}h ${remaining % 60}m`) : 'unknown';
210
+ const lines = [
211
+ `**Unleashed: ${status.jobName ?? jobName}**`,
212
+ `Status: **${status.status ?? 'unknown'}**`,
213
+ `Phase: ${status.phase ?? 0}`,
214
+ `Elapsed: ${elapsedStr}`,
215
+ ...(status.status === 'running' ? [`Remaining: ~${remainStr}`] : []),
216
+ ...(status.lastPhaseOutputPreview ? [`Last output: _${status.lastPhaseOutputPreview.slice(0, 200)}_`] : []),
217
+ ];
218
+ return lines.join('\n');
219
+ }
220
+ catch {
221
+ return `Failed to read status for "${jobName}".`;
222
+ }
223
+ }
224
+ // List all unleashed tasks
225
+ const lines = ['**Unleashed Tasks:**\n'];
226
+ for (const dir of dirs) {
227
+ const statusFile = path.join(unleashedDir, dir, 'status.json');
228
+ if (!existsSync(statusFile))
229
+ continue;
230
+ try {
231
+ const status = JSON.parse(readFileSync(statusFile, 'utf-8'));
232
+ const elapsed = status.startedAt
233
+ ? Math.round((Date.now() - new Date(status.startedAt).getTime()) / 60000)
234
+ : 0;
235
+ const elapsedStr = elapsed < 60 ? `${elapsed}m` : `${Math.floor(elapsed / 60)}h ${elapsed % 60}m`;
236
+ const statusEmoji = status.status === 'running' ? '\u{1F535}' : status.status === 'completed' ? '\u2705' : '\u26A0\uFE0F';
237
+ lines.push(`${statusEmoji} **${status.jobName ?? dir}** — ${status.status ?? 'unknown'} · phase ${status.phase ?? 0} · ${elapsedStr}`);
238
+ }
239
+ catch { /* skip corrupt */ }
240
+ }
241
+ return lines.length === 1 ? 'No unleashed tasks found.' : lines.join('\n');
242
+ }
243
+ // ── Shared command helpers ────────────────────────────────────────────
244
+ function handleHelp() {
245
+ return [
246
+ '**Commands** \u2014 also available as /slash commands',
247
+ '`!plan <task>` \u2014 Break a task into parallel steps',
248
+ '`!deep <msg>` \u2014 Extended mode (100 turns)',
249
+ '`!q <msg>` \u2014 Quick reply (Haiku) \u00b7 `!d <msg>` \u2014 Deep reply (Opus)',
250
+ '`!model [haiku|sonnet|opus]` \u2014 Switch default model',
251
+ '`!verbose [quiet|normal|detailed]` \u2014 Set response verbosity',
252
+ '`!project <name>` \u2014 Set active project \u00b7 `!project list|clear|status`',
253
+ '`!cron list|run|enable|disable` \u2014 Manage scheduled tasks',
254
+ '`!workflow list|run <name>` \u2014 Manage multi-step workflows',
255
+ '`!self-improve run|status|history|pending|apply|deny` \u2014 Self-improvement',
256
+ '`!team setup|list|status|messages|topology` \u2014 Manage agent team',
257
+ '`!status [job]` \u2014 Check unleashed task progress',
258
+ '`!dashboard` \u2014 Send a fresh system status embed',
259
+ '`!heartbeat` \u2014 Run heartbeat \u00b7 `!tools` \u2014 List tools \u00b7 `!clear` \u2014 Reset',
260
+ '`!stop` \u2014 Interrupt current response',
261
+ '`!help` \u2014 This message',
262
+ ].join('\n');
263
+ }
264
+ function handleModelSwitch(gateway, sessionKey, tier) {
265
+ const t = tier?.toLowerCase();
266
+ if (t && t in MODELS) {
267
+ gateway.setSessionModel(sessionKey, MODELS[t]);
268
+ return `Model switched to **${t}** (\`${MODELS[t]}\`).`;
269
+ }
270
+ const current = gateway.getSessionModel(sessionKey) ?? 'default';
271
+ return `Current model: \`${current}\`\nOptions: \`!model haiku\`, \`!model sonnet\`, \`!model opus\``;
272
+ }
273
+ function handleProjectCommand(gateway, sessionKey, action, projectName) {
274
+ if (action === 'list' || !action) {
275
+ const projects = getLinkedProjects();
276
+ if (projects.length === 0)
277
+ return 'No linked projects. Link projects from the dashboard.';
278
+ const current = gateway.getSessionProject(sessionKey);
279
+ const lines = projects.map(p => {
280
+ const name = path.basename(p.path);
281
+ const desc = p.description ? ` — ${p.description}` : '';
282
+ const active = current && p.path === current.path ? ' **(active)**' : '';
283
+ return `\`${name}\`${desc}${active}`;
284
+ });
285
+ return `**Linked Projects**\n${lines.join('\n')}`;
286
+ }
287
+ if (action === 'clear') {
288
+ gateway.clearSessionProject(sessionKey);
289
+ return 'Project context cleared. Auto-matching is back on.';
290
+ }
291
+ if (action === 'status') {
292
+ const current = gateway.getSessionProject(sessionKey);
293
+ if (!current)
294
+ return 'No active project. Using auto-matching.';
295
+ const name = path.basename(current.path);
296
+ const desc = current.description ? ` — ${current.description}` : '';
297
+ return `Active project: **${name}**${desc}\n\`${current.path}\``;
298
+ }
299
+ // action === 'set'
300
+ if (!projectName) {
301
+ const projects = getLinkedProjects();
302
+ if (projects.length === 0)
303
+ return 'No linked projects. Link projects from the dashboard.';
304
+ const names = projects.map(p => `\`${path.basename(p.path)}\``).join(', ');
305
+ return `Usage: \`!project <name>\`\nAvailable: ${names}`;
306
+ }
307
+ const project = findProjectByName(projectName);
308
+ if (!project) {
309
+ const projects = getLinkedProjects();
310
+ const names = projects.map(p => `\`${path.basename(p.path)}\``).join(', ');
311
+ return `Project "${projectName}" not found.\nAvailable: ${names}`;
312
+ }
313
+ // Clear the session so it starts fresh with the project's cwd/tools, then set the project
314
+ gateway.clearSession(sessionKey);
315
+ gateway.setSessionProject(sessionKey, project);
316
+ const name = path.basename(project.path);
317
+ const desc = project.description ? ` — ${project.description}` : '';
318
+ return `Switched to **${name}**${desc}\nWorking in \`${project.path}\`. Session cleared for fresh context.`;
319
+ }
320
+ function handleCronCommand(cronScheduler, action, jobName) {
321
+ // Returns a string for immediate replies, or null when async handling is needed (run)
322
+ if (action === 'list' || !action) {
323
+ return cronScheduler.listJobs();
324
+ }
325
+ if (action === 'disable' && jobName) {
326
+ return cronScheduler.disableJob(jobName);
327
+ }
328
+ if (action === 'enable' && jobName) {
329
+ return cronScheduler.enableJob(jobName);
330
+ }
331
+ if (!jobName) {
332
+ return 'Usage: `!cron list|run|disable|enable <job>`';
333
+ }
334
+ return null; // caller handles 'run' async
335
+ }
336
+ // ── Entry point ───────────────────────────────────────────────────────
337
+ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher, botManager) {
338
+ const watchedChannels = new Set(DISCORD_WATCHED_CHANNELS);
339
+ // Exclude channels owned by agent bots (they have their own Client)
340
+ if (botManager) {
341
+ for (const id of botManager.getOwnedChannelIds()) {
342
+ watchedChannels.delete(id);
343
+ }
344
+ }
345
+ const client = new Client({
346
+ intents: [
347
+ GatewayIntentBits.Guilds,
348
+ GatewayIntentBits.DirectMessages,
349
+ GatewayIntentBits.MessageContent,
350
+ GatewayIntentBits.GuildMessageReactions,
351
+ GatewayIntentBits.DirectMessageReactions,
352
+ GatewayIntentBits.GuildMessages,
353
+ ],
354
+ partials: [Partials.Channel, Partials.Reaction, Partials.Message],
355
+ });
356
+ // ── Presence updater ─────────────────────────────────────────────
357
+ function updatePresence(sessionKey) {
358
+ if (!client.user)
359
+ return;
360
+ const info = gateway.getPresenceInfo(sessionKey ?? `discord:user:${DISCORD_OWNER_ID}`);
361
+ const parts = [
362
+ info.model,
363
+ info.project ?? 'No project',
364
+ `${info.exchanges}/${info.maxExchanges}`,
365
+ `${info.memoryCount}m`,
366
+ ];
367
+ client.user.setPresence({
368
+ activities: [{ name: parts.join(' · '), type: ActivityType.Watching }],
369
+ status: 'online',
370
+ });
371
+ }
372
+ // ── Live status embed (event-driven) ────────────────────────────
373
+ let statusEmbedMessage = null;
374
+ let statusEmbedDebounce = null;
375
+ function buildStatusEmbed() {
376
+ const now = new Date();
377
+ // ── Determine overall health color ─────────────────────────────
378
+ const todayStats = cronScheduler.getTodayStats();
379
+ const runningJobs = cronScheduler.getRunningJobs();
380
+ const runningWorkflows = cronScheduler.getRunningWorkflowNames();
381
+ const activeCount = runningJobs.length + runningWorkflows.length;
382
+ const healthColor = todayStats.errors > 0 ? 0xE74C3C // red — errors today
383
+ : activeCount > 0 ? 0xF39C12 // amber — work in progress
384
+ : 0x2ECC71; // green — all clear
385
+ const embed = new EmbedBuilder()
386
+ .setTitle(`${ASSISTANT_NAME} System Status`)
387
+ .setColor(healthColor)
388
+ .setTimestamp(now)
389
+ .setFooter({ text: 'Auto-updates on state changes \u00b7 !dashboard to refresh' });
390
+ // ── Lanes (only show if something is active or queued) ────────
391
+ const lanes = gateway.getLaneStatus();
392
+ const busyLanes = Object.entries(lanes).filter(([, l]) => l.active > 0 || l.queued > 0);
393
+ if (busyLanes.length > 0) {
394
+ const laneLines = busyLanes.map(([name, l]) => {
395
+ const queued = l.queued > 0 ? ` (+${l.queued} queued)` : '';
396
+ return `${name} **${l.active}**/${l.limit}${queued}`;
397
+ });
398
+ embed.addFields({ name: '\u{1F6A6} Lanes', value: laneLines.join(' \u00b7 '), inline: false });
399
+ }
400
+ // ── Active work ───────────────────────────────────────────────
401
+ const runningItems = [];
402
+ for (const j of runningJobs)
403
+ runningItems.push(`\u23F3 ${j}`);
404
+ for (const w of runningWorkflows)
405
+ runningItems.push(`\u{1F504} ${w} (workflow)`);
406
+ // Unleashed tasks
407
+ const unleashedDir = path.join(BASE_DIR, 'unleashed');
408
+ if (existsSync(unleashedDir)) {
409
+ try {
410
+ const dirs = readdirSync(unleashedDir).filter(d => {
411
+ try {
412
+ return statSync(path.join(unleashedDir, d)).isDirectory();
413
+ }
414
+ catch {
415
+ return false;
416
+ }
417
+ });
418
+ for (const dir of dirs) {
419
+ const sf = path.join(unleashedDir, dir, 'status.json');
420
+ if (!existsSync(sf))
421
+ continue;
422
+ try {
423
+ const s = JSON.parse(readFileSync(sf, 'utf-8'));
424
+ if (s.status !== 'running')
425
+ continue;
426
+ const elapsed = s.startedAt
427
+ ? Math.round((Date.now() - new Date(s.startedAt).getTime()) / 60000)
428
+ : 0;
429
+ runningItems.push(`\u{1F680} ${s.jobName ?? dir} \u00b7 phase ${s.phase ?? 0} \u00b7 ${formatDuration(elapsed)}`);
430
+ }
431
+ catch { /* skip */ }
432
+ }
433
+ }
434
+ catch { /* skip */ }
435
+ }
436
+ if (runningItems.length > 0) {
437
+ embed.addFields({
438
+ name: `\u2699\uFE0F Active (${runningItems.length})`,
439
+ value: runningItems.join('\n'),
440
+ inline: false,
441
+ });
442
+ }
443
+ // ── Today's stats ─────────────────────────────────────────────
444
+ const statsLine = `\u2705 ${todayStats.ok} passed` +
445
+ (todayStats.errors > 0 ? ` \u00b7 \u274C ${todayStats.errors} failed` : '') +
446
+ (todayStats.skipped > 0 ? ` \u00b7 \u23ED ${todayStats.skipped} skipped` : '');
447
+ embed.addFields({ name: `\u{1F4CA} Today (${todayStats.total} runs)`, value: statsLine, inline: true });
448
+ // ── Sessions ──────────────────────────────────────────────────
449
+ const provenance = gateway.getAllProvenance();
450
+ embed.addFields({
451
+ name: '\u{1F4AC} Sessions',
452
+ value: `${provenance.size} active`,
453
+ inline: true,
454
+ });
455
+ // ── Next scheduled runs ───────────────────────────────────────
456
+ const jobDefs = cronScheduler.getJobDefinitions();
457
+ const upcoming = [];
458
+ for (const job of jobDefs) {
459
+ if (!job.active)
460
+ continue;
461
+ try {
462
+ const next = getNextCronRun(job.schedule);
463
+ if (next)
464
+ upcoming.push({ name: job.name, nextMs: next.getTime(), agent: job.agentSlug });
465
+ }
466
+ catch { /* skip unparseable */ }
467
+ }
468
+ upcoming.sort((a, b) => a.nextMs - b.nextMs);
469
+ if (upcoming.length > 0) {
470
+ const nextLines = upcoming.slice(0, 6).map(u => {
471
+ const diffMs = u.nextMs - now.getTime();
472
+ const diffMin = Math.round(diffMs / 60000);
473
+ const timeStr = formatDuration(diffMin);
474
+ // Strip agent slug prefix from job name if it matches (avoid "ross-the-sdr:task ross-the-sdr")
475
+ const displayName = u.agent && u.name.startsWith(`${u.agent}:`)
476
+ ? u.name.slice(u.agent.length + 1)
477
+ : u.name;
478
+ const agentTag = u.agent ? ` _${u.agent}_` : '';
479
+ return `\`${timeStr.padStart(4)}\` ${displayName}${agentTag}`;
480
+ });
481
+ if (upcoming.length > 6)
482
+ nextLines.push(`_+${upcoming.length - 6} more_`);
483
+ embed.addFields({ name: '\u{1F4C5} Next Runs', value: nextLines.join('\n'), inline: false });
484
+ }
485
+ // ── Agents ────────────────────────────────────────────────────
486
+ const agents = gateway.getAgentManager().listAll();
487
+ if (agents.length > 0) {
488
+ const agentJobCounts = new Map();
489
+ for (const job of jobDefs) {
490
+ if (job.agentSlug && job.active) {
491
+ agentJobCounts.set(job.agentSlug, (agentJobCounts.get(job.agentSlug) ?? 0) + 1);
492
+ }
493
+ }
494
+ const agentLines = agents.map(a => {
495
+ const jobCount = agentJobCounts.get(a.slug) ?? 0;
496
+ const model = a.model || 'sonnet';
497
+ const jobTag = jobCount > 0 ? `${jobCount} job${jobCount > 1 ? 's' : ''}` : 'no jobs';
498
+ return `**${a.name}** \u2014 ${model} \u00b7 ${jobTag}`;
499
+ });
500
+ embed.addFields({ name: `\u{1F916} Agents (${agents.length})`, value: agentLines.join('\n'), inline: false });
501
+ }
502
+ // ── Self-improvement ──────────────────────────────────────────
503
+ const siState = cronScheduler.getSelfImproveStatus();
504
+ const siPending = cronScheduler.getSelfImprovePending();
505
+ const m = siState.baselineMetrics;
506
+ const siLines = [];
507
+ if (m.feedbackPositiveRatio > 0 || m.cronSuccessRate > 0) {
508
+ siLines.push(`Feedback: ${(m.feedbackPositiveRatio * 100).toFixed(0)}% \u00b7 Cron: ${(m.cronSuccessRate * 100).toFixed(0)}% \u00b7 ${siState.totalExperiments} experiments`);
509
+ }
510
+ else {
511
+ siLines.push(`${siState.totalExperiments} experiments`);
512
+ }
513
+ if (siPending.length > 0) {
514
+ siLines.push(`**${siPending.length} pending approval${siPending.length > 1 ? 's' : ''}** \u2014 \`!self-improve pending\``);
515
+ }
516
+ embed.addFields({ name: `\u{1F52C} Self-Improvement (${siState.status})`, value: siLines.join('\n'), inline: false });
517
+ // ── Scheduled jobs summary ────────────────────────────────────
518
+ const enabledCount = jobDefs.filter(j => j.active).length;
519
+ const disabledCount = jobDefs.length - enabledCount;
520
+ const schedSummary = `${enabledCount} active` + (disabledCount > 0 ? ` \u00b7 ${disabledCount} disabled` : '');
521
+ embed.addFields({ name: '\u{1F4CB} Scheduled', value: schedSummary, inline: true });
522
+ // ── System info ──────────────────────────────────────────────
523
+ const modelLabel = DEFAULT_MODEL_TIER.charAt(0).toUpperCase() + DEFAULT_MODEL_TIER.slice(1);
524
+ const contextTag = ENABLE_1M_CONTEXT ? ' \u00b7 1M context' : '';
525
+ embed.addFields({ name: '\u{2699}\u{FE0F} System', value: `${modelLabel}${contextTag}`, inline: true });
526
+ return embed;
527
+ }
528
+ /** Format a duration in minutes to a compact human string. */
529
+ function formatDuration(minutes) {
530
+ if (minutes < 1)
531
+ return '<1m';
532
+ if (minutes < 60)
533
+ return `${minutes}m`;
534
+ if (minutes < 1440) {
535
+ const h = Math.floor(minutes / 60);
536
+ const m = minutes % 60;
537
+ return m > 0 ? `${h}h${m}m` : `${h}h`;
538
+ }
539
+ const d = Math.floor(minutes / 1440);
540
+ const h = Math.floor((minutes % 1440) / 60);
541
+ return h > 0 ? `${d}d${h}h` : `${d}d`;
542
+ }
543
+ /** Parse a cron expression and return the next run Date. */
544
+ function getNextCronRun(schedule) {
545
+ try {
546
+ // node-cron uses 6-field (with seconds) or 5-field; cron-parser expects 5-field
547
+ const fields = schedule.trim().split(/\s+/);
548
+ // If 6 fields, drop the leading seconds field
549
+ const expr = fields.length === 6 ? fields.slice(1).join(' ') : schedule;
550
+ const interval = cronParser.CronExpressionParser.parse(expr);
551
+ return interval.next().toDate();
552
+ }
553
+ catch {
554
+ return null;
555
+ }
556
+ }
557
+ async function sendOrUpdateStatusEmbed(channel) {
558
+ try {
559
+ const embed = buildStatusEmbed();
560
+ if (statusEmbedMessage) {
561
+ // Edit existing message in-place
562
+ try {
563
+ await statusEmbedMessage.edit({ embeds: [embed] });
564
+ return;
565
+ }
566
+ catch {
567
+ // Message might have been deleted — send a new one
568
+ statusEmbedMessage = null;
569
+ }
570
+ }
571
+ const target = channel ?? cachedDmChannel;
572
+ if (target && 'send' in target) {
573
+ statusEmbedMessage = await target.send({ embeds: [embed] });
574
+ // Pin the status message so it's easy to find
575
+ try {
576
+ await statusEmbedMessage.pin();
577
+ }
578
+ catch { /* may already be pinned or lack perms */ }
579
+ }
580
+ }
581
+ catch (err) {
582
+ logger.error({ err }, 'Failed to update status embed');
583
+ }
584
+ }
585
+ /** Send a fresh embed as a new message (does not edit the previous one). */
586
+ async function sendFreshStatusEmbed(channel) {
587
+ try {
588
+ const embed = buildStatusEmbed();
589
+ if ('send' in channel) {
590
+ // Unpin old status message if it exists
591
+ if (statusEmbedMessage) {
592
+ try {
593
+ await statusEmbedMessage.unpin();
594
+ }
595
+ catch { /* non-fatal */ }
596
+ }
597
+ statusEmbedMessage = await channel.send({ embeds: [embed] });
598
+ try {
599
+ await statusEmbedMessage.pin();
600
+ }
601
+ catch { /* non-fatal */ }
602
+ }
603
+ }
604
+ catch (err) {
605
+ logger.error({ err }, 'Failed to send fresh status embed');
606
+ }
607
+ }
608
+ // Prevent unhandled 'error' events from crashing the process
609
+ client.on(Events.Error, (err) => {
610
+ logger.error({ err }, 'Discord client error — will attempt to reconnect');
611
+ });
612
+ client.once(Events.ClientReady, async (readyClient) => {
613
+ logger.info(`${ASSISTANT_NAME} online as ${readyClient.user.tag}`);
614
+ // Register slash commands (global — takes up to 1hr to propagate, but works in DMs)
615
+ try {
616
+ const rest = new REST().setToken(DISCORD_TOKEN);
617
+ await rest.put(Routes.applicationCommands(readyClient.user.id), { body: slashCommands.map(c => c.toJSON()) });
618
+ logger.info(`Registered ${slashCommands.length} slash commands`);
619
+ }
620
+ catch (err) {
621
+ logger.error({ err }, 'Failed to register slash commands');
622
+ }
623
+ updatePresence();
624
+ // Auto-send status embed to owner's DMs on startup
625
+ try {
626
+ const owner = await client.users.fetch(DISCORD_OWNER_ID, { force: true });
627
+ const dmChannel = await owner.createDM();
628
+ cachedDmChannel = dmChannel;
629
+ await sendOrUpdateStatusEmbed(dmChannel);
630
+ logger.info('Sent startup status embed to owner DMs');
631
+ }
632
+ catch (err) {
633
+ logger.error({ err }, 'Failed to send startup status embed');
634
+ }
635
+ // Event-driven embed updates — debounced to avoid API spam
636
+ cronScheduler.onStatusChange(() => {
637
+ if (statusEmbedDebounce)
638
+ clearTimeout(statusEmbedDebounce);
639
+ statusEmbedDebounce = setTimeout(() => {
640
+ sendOrUpdateStatusEmbed().catch(() => { });
641
+ }, 2000);
642
+ });
643
+ });
644
+ client.on(Events.MessageCreate, async (message) => {
645
+ try {
646
+ // Ignore own messages
647
+ if (message.author.id === client.user?.id)
648
+ return;
649
+ // DM or watched guild channel
650
+ const isDm = message.channel.isDMBased();
651
+ const isWatchedChannel = !isDm && watchedChannels.has(message.channelId);
652
+ if (!isDm && !isWatchedChannel)
653
+ return;
654
+ // Cache the DM channel for cron/heartbeat notifications
655
+ if (isDm)
656
+ cachedDmChannel = message.channel;
657
+ // Owner-only (applies to both DM and watched channels)
658
+ if (DISCORD_OWNER_ID && message.author.id !== DISCORD_OWNER_ID) {
659
+ logger.warn(`Ignored message from non-owner: ${message.author.tag} (${message.author.id})`);
660
+ return;
661
+ }
662
+ // Extract attachments (images and files)
663
+ let text = message.content;
664
+ if (message.attachments.size > 0) {
665
+ const attachmentLines = message.attachments.map(att => {
666
+ if (att.contentType?.startsWith('image/')) {
667
+ return `[Image attached: ${att.name} (${att.url})]`;
668
+ }
669
+ return `[File attached: ${att.name}, ${att.contentType || 'unknown type'}, ${att.url}]`;
670
+ });
671
+ text = attachmentLines.join('\n') + (text ? '\n' + text : '');
672
+ }
673
+ const sessionKey = isWatchedChannel
674
+ ? `discord:channel:${message.channelId}:${message.author.id}`
675
+ : `discord:user:${message.author.id}`;
676
+ // ── Commands (DM only) ──────────────────────────────────────────
677
+ if (isDm && text === '!clear') {
678
+ gateway.clearSession(sessionKey);
679
+ await message.reply('Session cleared.');
680
+ updatePresence(sessionKey);
681
+ return;
682
+ }
683
+ if (isDm && (text === '!help' || text === '!h')) {
684
+ await message.reply(handleHelp());
685
+ return;
686
+ }
687
+ if (isDm && text.startsWith('!model')) {
688
+ const parts = text.split(/\s+/);
689
+ await message.reply(handleModelSwitch(gateway, sessionKey, parts[1]));
690
+ updatePresence(sessionKey);
691
+ return;
692
+ }
693
+ if (isDm && text.startsWith('!verbose')) {
694
+ const parts = text.split(/\s+/);
695
+ const level = parts[1]?.toLowerCase();
696
+ if (level === 'quiet' || level === 'normal' || level === 'detailed') {
697
+ gateway.setSessionVerboseLevel(sessionKey, level);
698
+ await message.reply(`Verbose level set to **${level}**.`);
699
+ }
700
+ else {
701
+ const current = gateway.getSessionVerboseLevel(sessionKey) ?? 'normal';
702
+ await message.reply(`Current verbose level: **${current}**\nOptions: \`!verbose quiet\`, \`!verbose normal\`, \`!verbose detailed\``);
703
+ }
704
+ return;
705
+ }
706
+ if (isDm && text === '!tools') {
707
+ await message.reply(formatToolsList());
708
+ return;
709
+ }
710
+ if (isDm && text === '!heartbeat') {
711
+ const streamer = new DiscordStreamingMessage(message.channel);
712
+ await streamer.start();
713
+ const response = await heartbeat.runManual();
714
+ await streamer.finalize(response);
715
+ // Inject into DM session so follow-up conversation has context
716
+ gateway.injectContext(sessionKey, '!heartbeat', response);
717
+ return;
718
+ }
719
+ if (isDm && text.startsWith('!status')) {
720
+ const parts = text.split(/\s+/);
721
+ const jobName = parts.slice(1).join(' ') || undefined;
722
+ await message.reply(handleUnleashedStatus(jobName));
723
+ return;
724
+ }
725
+ if (isDm && text.startsWith('!project')) {
726
+ const parts = text.split(/\s+/);
727
+ const subCmd = parts[1]?.toLowerCase();
728
+ if (subCmd === 'list' || subCmd === 'clear' || subCmd === 'status') {
729
+ await message.reply(handleProjectCommand(gateway, sessionKey, subCmd, undefined));
730
+ }
731
+ else {
732
+ // !project <name> → set project
733
+ const projectName = parts.slice(1).join(' ');
734
+ await message.reply(handleProjectCommand(gateway, sessionKey, 'set', projectName || undefined));
735
+ }
736
+ updatePresence(sessionKey);
737
+ return;
738
+ }
739
+ if (isDm && text.startsWith('!cron')) {
740
+ const parts = text.split(/\s+/);
741
+ const subCmd = parts[1]?.toLowerCase();
742
+ const jobName = parts.slice(2).join(' ');
743
+ const immediateResult = handleCronCommand(cronScheduler, subCmd, jobName);
744
+ if (immediateResult !== null) {
745
+ await message.reply(immediateResult);
746
+ return;
747
+ }
748
+ // Handle 'run' — async with streaming
749
+ const job = cronScheduler.getJob(jobName);
750
+ if (!job) {
751
+ await message.reply(`Cron job '${jobName}' not found. Use \`!cron list\` to see available jobs.`);
752
+ }
753
+ else if (cronScheduler.isJobRunning(jobName)) {
754
+ await message.reply(`Cron job '${jobName}' is already running.`);
755
+ }
756
+ else if (job.mode === 'unleashed') {
757
+ // Unleashed tasks run in background — don't block the channel
758
+ await message.reply(`Unleashed task "${jobName}" started in background (max ${job.maxHours ?? 6}h). Check the dashboard for progress.`);
759
+ cronScheduler.runManual(jobName).then((result) => {
760
+ message.reply(`**[Unleashed: ${jobName} — done]**\n\n${result.slice(0, 1800)}`).catch(() => { });
761
+ gateway.injectContext(sessionKey, `!cron run ${jobName}`, result);
762
+ }).catch((err) => {
763
+ message.reply(`**[Unleashed: ${jobName} — error]**\n\n${err}`).catch(() => { });
764
+ });
765
+ }
766
+ else {
767
+ const streamer = new DiscordStreamingMessage(message.channel);
768
+ await streamer.start();
769
+ const response = await cronScheduler.runManual(jobName);
770
+ await streamer.finalize(response);
771
+ // Inject into DM session so follow-up conversation has context
772
+ gateway.injectContext(sessionKey, `!cron run ${jobName}`, response);
773
+ }
774
+ return;
775
+ }
776
+ // ── Workflow command (DM only) ──────────────────────────────────
777
+ if (isDm && text.startsWith('!workflow')) {
778
+ const parts = text.split(/\s+/);
779
+ const subCmd = parts[1]?.toLowerCase();
780
+ if (subCmd === 'list' || !subCmd) {
781
+ await message.reply(cronScheduler.listWorkflows());
782
+ return;
783
+ }
784
+ if (subCmd === 'run') {
785
+ const rest = parts.slice(2).join(' ');
786
+ // Parse "name key=val key=val"
787
+ const tokens = rest.split(/\s+/);
788
+ const wfName = tokens[0];
789
+ if (!wfName) {
790
+ await message.reply('Usage: `!workflow run <name> [key=val ...]`');
791
+ return;
792
+ }
793
+ const wf = cronScheduler.getWorkflow(wfName);
794
+ if (!wf) {
795
+ await message.reply(`Workflow '${wfName}' not found. Use \`!workflow list\` to see available workflows.`);
796
+ return;
797
+ }
798
+ if (cronScheduler.isWorkflowRunning(wfName)) {
799
+ await message.reply(`Workflow '${wfName}' is already running.`);
800
+ return;
801
+ }
802
+ // Parse input overrides
803
+ const inputs = {};
804
+ for (const token of tokens.slice(1)) {
805
+ const eq = token.indexOf('=');
806
+ if (eq > 0) {
807
+ inputs[token.slice(0, eq)] = token.slice(eq + 1);
808
+ }
809
+ }
810
+ const streamer = new DiscordStreamingMessage(message.channel);
811
+ await streamer.start();
812
+ const response = await cronScheduler.runWorkflow(wfName, inputs);
813
+ await streamer.finalize(response);
814
+ gateway.injectContext(sessionKey, `!workflow run ${wfName}`, response);
815
+ return;
816
+ }
817
+ await message.reply('Usage: `!workflow list` or `!workflow run <name> [key=val ...]`');
818
+ return;
819
+ }
820
+ // ── Live status embed (DM only) ────────────────────────────────────
821
+ if (isDm && text === '!dashboard') {
822
+ await sendFreshStatusEmbed(message.channel);
823
+ return;
824
+ }
825
+ // ── Self-Improvement command (DM only) ────────────────────────────
826
+ if (isDm && text.startsWith('!self-improve')) {
827
+ const parts = text.split(/\s+/);
828
+ const subCmd = parts[1]?.toLowerCase();
829
+ if (subCmd === 'status' || !subCmd) {
830
+ const result = await gateway.handleSelfImprove('status');
831
+ await message.reply(result);
832
+ return;
833
+ }
834
+ if (subCmd === 'history') {
835
+ const result = await gateway.handleSelfImprove('history');
836
+ await message.reply(result || 'No experiment history yet.');
837
+ return;
838
+ }
839
+ if (subCmd === 'pending') {
840
+ const result = await gateway.handleSelfImprove('pending');
841
+ await message.reply(result);
842
+ return;
843
+ }
844
+ if (subCmd === 'apply') {
845
+ const expId = parts[2];
846
+ if (!expId) {
847
+ await message.reply('Usage: `!self-improve apply <experiment-id>`');
848
+ return;
849
+ }
850
+ const result = await gateway.handleSelfImprove('apply', { experimentId: expId });
851
+ await message.reply(result);
852
+ return;
853
+ }
854
+ if (subCmd === 'deny') {
855
+ const expId = parts[2];
856
+ if (!expId) {
857
+ await message.reply('Usage: `!self-improve deny <experiment-id>`');
858
+ return;
859
+ }
860
+ const result = await gateway.handleSelfImprove('deny', { experimentId: expId });
861
+ await message.reply(result);
862
+ return;
863
+ }
864
+ if (subCmd === 'run') {
865
+ const streamer = new DiscordStreamingMessage(message.channel);
866
+ await streamer.start();
867
+ const result = await gateway.handleSelfImprove('run', {}, async (experiment) => {
868
+ // Send proposal embed for each accepted experiment
869
+ const proposalText = `**Self-Improvement Proposal #${experiment.iteration}**\n\n` +
870
+ `**Area:** ${experiment.area}\n` +
871
+ `**Target:** ${experiment.target}\n` +
872
+ `**Score:** ${(experiment.score * 10).toFixed(1)}/10\n\n` +
873
+ `**Hypothesis:** ${experiment.hypothesis}\n\n` +
874
+ `**Proposed Change:**\n\`\`\`\n${experiment.proposedChange.slice(0, 800)}\n\`\`\``;
875
+ await sendApprovalButtons(message.channel, proposalText.slice(0, 1900), 'si', experiment.id);
876
+ });
877
+ await streamer.finalize(result);
878
+ return;
879
+ }
880
+ await message.reply('**Self-Improvement Commands:**\n' +
881
+ '`!self-improve run` — trigger a self-improvement cycle\n' +
882
+ '`!self-improve status` — show current state and baseline metrics\n' +
883
+ '`!self-improve history [n]` — show last N experiments (default 10)\n' +
884
+ '`!self-improve pending` — list pending approval proposals\n' +
885
+ '`!self-improve apply <id>` — approve a pending change\n' +
886
+ '`!self-improve deny <id>` — deny a pending change');
887
+ return;
888
+ }
889
+ // ── Skill approval shortcuts (DM only) ──────────────────────────
890
+ // Natural language: "approve skill <name>" / "reject skill <name>" (from notification prompts)
891
+ // Explicit: "!skill pending|approve <name>|reject <name>"
892
+ if (isDm) {
893
+ const lc = text.toLowerCase().trim();
894
+ if (lc.startsWith('approve skill ') || lc.startsWith('reject skill ')) {
895
+ const isApprove = lc.startsWith('approve skill ');
896
+ const skillName = text.trim().split(/\s+/)[2];
897
+ if (skillName) {
898
+ const result = await gateway.handleSkill(isApprove ? 'approve' : 'reject', { name: skillName });
899
+ await message.reply(result);
900
+ return;
901
+ }
902
+ }
903
+ if (lc.startsWith('!skill')) {
904
+ const parts = text.split(/\s+/);
905
+ const subCmd = parts[1]?.toLowerCase();
906
+ if (!subCmd || subCmd === 'pending') {
907
+ const result = await gateway.handleSkill('pending');
908
+ await message.reply(result);
909
+ return;
910
+ }
911
+ if (subCmd === 'approve') {
912
+ const name = parts[2];
913
+ if (!name) {
914
+ await message.reply('Usage: `!skill approve <name>`');
915
+ return;
916
+ }
917
+ const result = await gateway.handleSkill('approve', { name });
918
+ await message.reply(result);
919
+ return;
920
+ }
921
+ if (subCmd === 'reject') {
922
+ const name = parts[2];
923
+ if (!name) {
924
+ await message.reply('Usage: `!skill reject <name>`');
925
+ return;
926
+ }
927
+ const result = await gateway.handleSkill('reject', { name });
928
+ await message.reply(result);
929
+ return;
930
+ }
931
+ await message.reply('**Skill Commands:**\n' +
932
+ '`!skill pending` — list skills waiting for approval\n' +
933
+ '`!skill approve <name>` — activate a pending skill\n' +
934
+ '`!skill reject <name>` — discard a pending skill');
935
+ return;
936
+ }
937
+ }
938
+ // ── Team commands (DM only) ─────────────────────────────────────
939
+ if (isDm && text.startsWith('!team')) {
940
+ const parts = text.split(/\s+/);
941
+ const subCmd = parts[1]?.toLowerCase();
942
+ if (subCmd === 'list' || !subCmd) {
943
+ const router = gateway.getTeamRouter();
944
+ const agents = router.listTeamAgents();
945
+ if (agents.length === 0) {
946
+ await message.reply('No team agents configured. Hire one from the dashboard or add a profile to `vault/00-System/agents/`.');
947
+ }
948
+ else {
949
+ const statuses = botManager?.getStatuses() ?? new Map();
950
+ const lines = ['**Team Agents:**\n'];
951
+ for (const a of agents) {
952
+ const bs = statuses.get(a.slug);
953
+ const statusIcon = bs?.status === 'online' ? '\u{1F7E2}' : bs?.status === 'connecting' ? '\u{1F7E1}' : bs?.status === 'error' ? '\u{1F534}' : '\u26AB';
954
+ const statusText = bs?.status ?? 'offline';
955
+ const targets = a.team?.canMessage.join(', ') || 'none';
956
+ lines.push(`- ${statusIcon} **${a.name}** (\`${a.slug}\`) — ${statusText}`);
957
+ const chName = a.team?.channelName;
958
+ const chDisplay = chName ? (Array.isArray(chName) ? chName.map(c => '#' + c).join(', ') : '#' + chName) : 'none';
959
+ lines.push(` Channel: ${chDisplay} · Can message: ${targets}`);
960
+ }
961
+ await message.reply(lines.join('\n'));
962
+ }
963
+ return;
964
+ }
965
+ if (subCmd === 'status') {
966
+ const router = gateway.getTeamRouter();
967
+ const agents = router.listTeamAgents();
968
+ const statuses = botManager?.getStatuses() ?? new Map();
969
+ const onlineCount = Array.from(statuses.values()).filter(s => s.status === 'online').length;
970
+ const msgs = gateway.getTeamBus().getRecentMessages(10);
971
+ const lines = [`**Team Status** — ${agents.length} agent(s), ${onlineCount} online\n`];
972
+ for (const a of agents) {
973
+ const bs = statuses.get(a.slug);
974
+ const icon = bs?.status === 'online' ? '\u2705' : '\u274c';
975
+ const agentMsgs = msgs.filter(m => m.fromAgent === a.slug || m.toAgent === a.slug);
976
+ lines.push(`${icon} **${a.name}**: ${bs?.status ?? 'offline'} · ${agentMsgs.length} recent message(s)`);
977
+ }
978
+ await message.reply(lines.join('\n') || 'No team agents configured.');
979
+ return;
980
+ }
981
+ if (subCmd === 'messages') {
982
+ const count = parseInt(parts[2] || '10', 10);
983
+ const msgs = gateway.getTeamBus().getRecentMessages(Math.min(count, 50));
984
+ if (msgs.length === 0) {
985
+ await message.reply('No inter-agent messages yet.');
986
+ }
987
+ else {
988
+ const lines = msgs.map(m => `\`${m.timestamp.slice(11, 19)}\` **${m.fromAgent}** \u2192 **${m.toAgent}**: ${m.content.slice(0, 100)}`);
989
+ await message.reply(`**Recent Team Messages:**\n\n${lines.join('\n')}`);
990
+ }
991
+ return;
992
+ }
993
+ if (subCmd === 'topology') {
994
+ const { nodes, edges } = gateway.getTeamRouter().getTopology();
995
+ if (nodes.length === 0) {
996
+ await message.reply('No team agents configured.');
997
+ }
998
+ else {
999
+ const lines = ['**Team Topology:**\n'];
1000
+ for (const node of nodes) {
1001
+ const outgoing = edges.filter(e => e.from === node.slug).map(e => e.to);
1002
+ lines.push(`- **${node.name}** \u2192 ${outgoing.length > 0 ? outgoing.join(', ') : '(no outgoing)'}`);
1003
+ }
1004
+ await message.reply(lines.join('\n'));
1005
+ }
1006
+ return;
1007
+ }
1008
+ await message.reply('**Team Commands:**\n' +
1009
+ '`!team setup` — auto-create Discord channels for all team agents\n' +
1010
+ '`!team list` — list all team agents and their channels\n' +
1011
+ '`!team status` — show agent status\n' +
1012
+ '`!team messages [n]` — recent inter-agent messages\n' +
1013
+ '`!team topology` — communication graph');
1014
+ return;
1015
+ }
1016
+ // ── Plan orchestration (DM only) ─────────────────────────────────
1017
+ if (isDm && text.startsWith('!plan ')) {
1018
+ const taskDescription = text.slice(6).trim();
1019
+ if (!taskDescription) {
1020
+ await message.reply('Usage: `!plan <task description>`');
1021
+ return;
1022
+ }
1023
+ await handlePlanCommand(gateway, sessionKey, taskDescription, message.channel);
1024
+ return;
1025
+ }
1026
+ // ── Approval responses (DM only) ────────────────────────────────
1027
+ if (isDm) {
1028
+ const lower = text.toLowerCase();
1029
+ if (['yes', 'no', 'approve', 'deny', 'go', 'skip', 'always'].includes(lower)) {
1030
+ const approvals = gateway.getPendingApprovals();
1031
+ if (approvals.length > 0) {
1032
+ // Pass 'always' as a string so the check-in gate can persist the channel
1033
+ const result = lower === 'always' ? 'always' :
1034
+ (lower === 'yes' || lower === 'approve' || lower === 'go');
1035
+ gateway.resolveApproval(approvals[approvals.length - 1], result);
1036
+ await message.react(lower === 'no' || lower === 'deny' || lower === 'skip' ? '\u274c' : '\u2705');
1037
+ return;
1038
+ }
1039
+ }
1040
+ }
1041
+ // ── Per-message model/mode prefix ──────────────────────────────
1042
+ let effectiveText = text;
1043
+ let oneOffModel;
1044
+ let oneOffMaxTurns;
1045
+ if (text.startsWith('!q ')) {
1046
+ oneOffModel = MODELS.haiku;
1047
+ effectiveText = text.slice(3);
1048
+ }
1049
+ else if (text.startsWith('!d ')) {
1050
+ oneOffModel = MODELS.opus;
1051
+ effectiveText = text.slice(3);
1052
+ }
1053
+ else if (isDm && text.startsWith('!deep ')) {
1054
+ // Deep mode requires approval before running 100 turns
1055
+ const deepMsg = text.slice(6).trim();
1056
+ if (!deepMsg) {
1057
+ await message.reply('Usage: `!deep <message>`');
1058
+ return;
1059
+ }
1060
+ const requestId = `deep-${Date.now()}`;
1061
+ await sendApprovalButtons(message.channel, `**Deep mode** (100 turns) requested for:\n_${deepMsg.slice(0, 200)}_\n\nApprove?`, 'deep', requestId);
1062
+ const approved = await gateway.requestApproval('Pending approval', requestId);
1063
+ if (!approved) {
1064
+ await message.reply('Deep mode cancelled.');
1065
+ return;
1066
+ }
1067
+ oneOffMaxTurns = 100;
1068
+ effectiveText = deepMsg;
1069
+ }
1070
+ // ── Reply context for watched channels ─────────────────────────
1071
+ if (isWatchedChannel && message.reference?.messageId) {
1072
+ try {
1073
+ const referenced = await message.channel.messages.fetch(message.reference.messageId);
1074
+ if (referenced.author.id === client.user?.id) {
1075
+ const refContent = referenced.content.slice(0, 1500);
1076
+ effectiveText = `[Replying to bot message:\n${refContent}]\n\n${effectiveText}`;
1077
+ }
1078
+ }
1079
+ catch { /* referenced message may be deleted */ }
1080
+ }
1081
+ // ── !stop — abort active query (bypasses session lock) ────────────
1082
+ if (isDm && (text === '!stop' || text === '/stop')) {
1083
+ const stopped = gateway.stopSession(sessionKey);
1084
+ await message.reply(stopped ? 'Stopping...' : 'Nothing running to stop.');
1085
+ return;
1086
+ }
1087
+ // ── Show queued indicator if session is busy ─────────────────────
1088
+ if (gateway.isSessionBusy(sessionKey)) {
1089
+ await message.react('\u23f3'); // hourglass
1090
+ }
1091
+ // ── Stream response ─────────────────────────────────────────────
1092
+ const streamer = new DiscordStreamingMessage(message.channel);
1093
+ await streamer.start();
1094
+ try {
1095
+ const response = await gateway.handleMessage(sessionKey, effectiveText, (t) => streamer.update(t), oneOffModel, oneOffMaxTurns, (toolName, toolInput) => { streamer.setToolStatus(friendlyToolName(toolName, toolInput)); return Promise.resolve(); });
1096
+ await streamer.finalize(response);
1097
+ updatePresence(sessionKey);
1098
+ // Track bot message for feedback reactions
1099
+ if (streamer.messageId) {
1100
+ trackBotMessage(streamer.messageId, {
1101
+ sessionKey,
1102
+ userMessage: effectiveText.slice(0, 500),
1103
+ botResponse: response.slice(0, 500),
1104
+ });
1105
+ }
1106
+ }
1107
+ catch (err) {
1108
+ logger.error({ err }, 'Error processing Discord message');
1109
+ await streamer.finalize(`Something went wrong: ${err}`);
1110
+ }
1111
+ }
1112
+ catch (err) {
1113
+ logger.error({ err }, 'Unhandled error in Discord message handler');
1114
+ }
1115
+ });
1116
+ // ── Slash command + button interaction handler ──────────────────────
1117
+ client.on(Events.InteractionCreate, async (interaction) => {
1118
+ try {
1119
+ // ── Autocomplete ────────────────────────────────────────────────
1120
+ if (interaction.isAutocomplete()) {
1121
+ if (interaction.commandName === 'project') {
1122
+ const focused = interaction.options.getFocused().toLowerCase();
1123
+ const projects = getLinkedProjects().map(p => path.basename(p.path));
1124
+ const filtered = projects
1125
+ .filter(name => name.toLowerCase().includes(focused))
1126
+ .slice(0, 25);
1127
+ await interaction.respond(filtered.map(name => ({ name, value: name })));
1128
+ }
1129
+ else if (interaction.commandName === 'cron') {
1130
+ const focused = interaction.options.getFocused().toLowerCase();
1131
+ const jobNames = cronScheduler.getJobNames();
1132
+ const filtered = jobNames
1133
+ .filter(name => name.toLowerCase().includes(focused))
1134
+ .slice(0, 25);
1135
+ await interaction.respond(filtered.map(name => ({ name, value: name })));
1136
+ }
1137
+ else if (interaction.commandName === 'workflow') {
1138
+ const focused = interaction.options.getFocused().toLowerCase();
1139
+ const wfNames = cronScheduler.getWorkflowNames();
1140
+ const filtered = wfNames
1141
+ .filter(name => name.toLowerCase().includes(focused))
1142
+ .slice(0, 25);
1143
+ await interaction.respond(filtered.map(name => ({ name, value: name })));
1144
+ }
1145
+ return;
1146
+ }
1147
+ // ── Slash commands ───────────────────────────────────────────────
1148
+ if (interaction.isChatInputCommand()) {
1149
+ const cmd = interaction;
1150
+ // Owner-only guard
1151
+ if (DISCORD_OWNER_ID && cmd.user.id !== DISCORD_OWNER_ID) {
1152
+ await cmd.reply({ content: 'Owner only.', ephemeral: true });
1153
+ return;
1154
+ }
1155
+ // Cache DM channel for notifications
1156
+ if (cmd.channel?.isDMBased())
1157
+ cachedDmChannel = cmd.channel;
1158
+ const sessionKey = cmd.channel?.isDMBased()
1159
+ ? `discord:user:${cmd.user.id}`
1160
+ : `discord:channel:${cmd.channelId}:${cmd.user.id}`;
1161
+ const name = cmd.commandName;
1162
+ // Simple immediate-response commands
1163
+ if (name === 'help') {
1164
+ await cmd.reply(handleHelp());
1165
+ return;
1166
+ }
1167
+ if (name === 'clear') {
1168
+ gateway.clearSession(sessionKey);
1169
+ await cmd.reply('Session cleared.');
1170
+ updatePresence(sessionKey);
1171
+ return;
1172
+ }
1173
+ if (name === 'tools') {
1174
+ await cmd.reply(formatToolsList());
1175
+ return;
1176
+ }
1177
+ if (name === 'status') {
1178
+ const jobArg = cmd.options.getString('job') ?? undefined;
1179
+ await cmd.reply(handleUnleashedStatus(jobArg));
1180
+ return;
1181
+ }
1182
+ if (name === 'model') {
1183
+ const tier = cmd.options.getString('tier', true);
1184
+ await cmd.reply(handleModelSwitch(gateway, sessionKey, tier));
1185
+ updatePresence(sessionKey);
1186
+ return;
1187
+ }
1188
+ if (name === 'verbose') {
1189
+ const level = cmd.options.getString('level', true);
1190
+ gateway.setSessionVerboseLevel(sessionKey, level);
1191
+ await cmd.reply({ content: `Verbose level set to **${level}**.`, ephemeral: true });
1192
+ return;
1193
+ }
1194
+ if (name === 'project') {
1195
+ const action = cmd.options.getString('action', true);
1196
+ const projName = cmd.options.getString('name') ?? undefined;
1197
+ await cmd.reply(handleProjectCommand(gateway, sessionKey, action, projName));
1198
+ updatePresence(sessionKey);
1199
+ return;
1200
+ }
1201
+ // Team command
1202
+ if (name === 'team') {
1203
+ const action = cmd.options.getString('action', true);
1204
+ if (action === 'list') {
1205
+ const router = gateway.getTeamRouter();
1206
+ const agents = router.listTeamAgents();
1207
+ if (agents.length === 0) {
1208
+ await cmd.reply({ content: 'No team agents configured.', ephemeral: true });
1209
+ }
1210
+ else {
1211
+ const statuses = botManager?.getStatuses() ?? new Map();
1212
+ const lines = agents.map(a => {
1213
+ const bs = statuses.get(a.slug);
1214
+ const icon = bs?.status === 'online' ? '\u{1F7E2}' : bs?.status === 'connecting' ? '\u{1F7E1}' : bs?.status === 'error' ? '\u{1F534}' : '\u26AB';
1215
+ return `${icon} **${a.name}** (\`${a.slug}\`) \u2014 ${bs?.status ?? 'offline'}`;
1216
+ });
1217
+ await cmd.reply({ content: `**Team Agents:**\n${lines.join('\n')}`, ephemeral: true });
1218
+ }
1219
+ return;
1220
+ }
1221
+ if (action === 'status') {
1222
+ const router = gateway.getTeamRouter();
1223
+ const agents = router.listTeamAgents();
1224
+ const statuses = botManager?.getStatuses() ?? new Map();
1225
+ const onlineCount = Array.from(statuses.values()).filter(s => s.status === 'online').length;
1226
+ const msgs = gateway.getTeamBus().getRecentMessages(10);
1227
+ const lines = [`**Team Status** \u2014 ${agents.length} agent(s), ${onlineCount} online\n`];
1228
+ for (const a of agents) {
1229
+ const bs = statuses.get(a.slug);
1230
+ const icon = bs?.status === 'online' ? '\u2705' : '\u274c';
1231
+ const agentMsgs = msgs.filter(m => m.fromAgent === a.slug || m.toAgent === a.slug);
1232
+ lines.push(`${icon} **${a.name}**: ${bs?.status ?? 'offline'} \u00b7 ${agentMsgs.length} recent message(s)`);
1233
+ }
1234
+ await cmd.reply({ content: lines.join('\n'), ephemeral: true });
1235
+ return;
1236
+ }
1237
+ if (action === 'messages') {
1238
+ const msgs = gateway.getTeamBus().getRecentMessages(10);
1239
+ if (msgs.length === 0) {
1240
+ await cmd.reply({ content: 'No inter-agent messages yet.', ephemeral: true });
1241
+ }
1242
+ else {
1243
+ const lines = msgs.map(m => `\`${m.timestamp.slice(11, 19)}\` **${m.fromAgent}** \u2192 **${m.toAgent}**: ${m.content.slice(0, 100)}`);
1244
+ await cmd.reply({ content: lines.join('\n'), ephemeral: true });
1245
+ }
1246
+ return;
1247
+ }
1248
+ if (action === 'topology') {
1249
+ const { nodes, edges } = gateway.getTeamRouter().getTopology();
1250
+ if (nodes.length === 0) {
1251
+ await cmd.reply({ content: 'No team agents configured.', ephemeral: true });
1252
+ }
1253
+ else {
1254
+ const lines = nodes.map(node => {
1255
+ const outgoing = edges.filter(e => e.from === node.slug).map(e => e.to);
1256
+ return `**${node.name}** \u2192 ${outgoing.length > 0 ? outgoing.join(', ') : '(none)'}`;
1257
+ });
1258
+ await cmd.reply({ content: `**Topology:**\n${lines.join('\n')}`, ephemeral: true });
1259
+ }
1260
+ return;
1261
+ }
1262
+ await cmd.reply({ content: 'Unknown team action.', ephemeral: true });
1263
+ return;
1264
+ }
1265
+ // Cron command
1266
+ if (name === 'cron') {
1267
+ const action = cmd.options.getString('action', true);
1268
+ const jobName = cmd.options.getString('job') ?? '';
1269
+ const immediateResult = handleCronCommand(cronScheduler, action, jobName);
1270
+ if (immediateResult !== null) {
1271
+ await cmd.reply(immediateResult);
1272
+ return;
1273
+ }
1274
+ // Handle 'run' — async with deferred reply
1275
+ const job = cronScheduler.getJob(jobName);
1276
+ if (!job) {
1277
+ await cmd.reply(`Cron job '${jobName}' not found. Use \`/cron list\` to see available jobs.`);
1278
+ return;
1279
+ }
1280
+ if (cronScheduler.isJobRunning(jobName)) {
1281
+ await cmd.reply(`Cron job '${jobName}' is already running.`);
1282
+ return;
1283
+ }
1284
+ if (job.mode === 'unleashed') {
1285
+ await cmd.reply(`Unleashed task "${jobName}" started in background (max ${job.maxHours ?? 6}h). Check the dashboard for progress.`);
1286
+ cronScheduler.runManual(jobName).then((result) => {
1287
+ cmd.followUp(`**[Unleashed: ${jobName} — done]**\n\n${result.slice(0, 1800)}`).catch(() => { });
1288
+ gateway.injectContext(sessionKey, `!cron run ${jobName}`, result);
1289
+ }).catch((err) => {
1290
+ cmd.followUp(`**[Unleashed: ${jobName} — error]**\n\n${err}`).catch(() => { });
1291
+ });
1292
+ return;
1293
+ }
1294
+ await cmd.deferReply();
1295
+ const response = await cronScheduler.runManual(jobName);
1296
+ const chunks = chunkText(response || `*(cron job '${jobName}' completed — no output)*`, 1900);
1297
+ await cmd.editReply(chunks[0]);
1298
+ for (let i = 1; i < chunks.length; i++) {
1299
+ await cmd.followUp(chunks[i]);
1300
+ }
1301
+ gateway.injectContext(sessionKey, `!cron run ${jobName}`, response);
1302
+ return;
1303
+ }
1304
+ // Workflow command
1305
+ if (name === 'workflow') {
1306
+ const action = cmd.options.getString('action', true);
1307
+ const wfName = cmd.options.getString('name') ?? '';
1308
+ if (action === 'list') {
1309
+ await cmd.reply(cronScheduler.listWorkflows());
1310
+ return;
1311
+ }
1312
+ if (action === 'run') {
1313
+ if (!wfName) {
1314
+ await cmd.reply('Specify a workflow name.');
1315
+ return;
1316
+ }
1317
+ const wf = cronScheduler.getWorkflow(wfName);
1318
+ if (!wf) {
1319
+ await cmd.reply(`Workflow '${wfName}' not found.`);
1320
+ return;
1321
+ }
1322
+ if (cronScheduler.isWorkflowRunning(wfName)) {
1323
+ await cmd.reply(`Workflow '${wfName}' is already running.`);
1324
+ return;
1325
+ }
1326
+ // Parse input overrides from the inputs string
1327
+ const inputsStr = cmd.options.getString('inputs') ?? '';
1328
+ const inputs = {};
1329
+ for (const token of inputsStr.split(/\s+/).filter(Boolean)) {
1330
+ const eq = token.indexOf('=');
1331
+ if (eq > 0) {
1332
+ inputs[token.slice(0, eq)] = token.slice(eq + 1);
1333
+ }
1334
+ }
1335
+ await cmd.deferReply();
1336
+ const response = await cronScheduler.runWorkflow(wfName, inputs);
1337
+ const chunks = chunkText(response || `*(workflow '${wfName}' completed — no output)*`, 1900);
1338
+ await cmd.editReply(chunks[0]);
1339
+ for (let i = 1; i < chunks.length; i++) {
1340
+ await cmd.followUp(chunks[i]);
1341
+ }
1342
+ gateway.injectContext(sessionKey, `!workflow run ${wfName}`, response);
1343
+ return;
1344
+ }
1345
+ return;
1346
+ }
1347
+ // Self-improve command
1348
+ if (name === 'self-improve') {
1349
+ const subCmd = cmd.options.getSubcommand();
1350
+ if (subCmd === 'status') {
1351
+ const result = await gateway.handleSelfImprove('status');
1352
+ await cmd.reply({ content: result, ephemeral: true });
1353
+ return;
1354
+ }
1355
+ if (subCmd === 'history') {
1356
+ const result = await gateway.handleSelfImprove('history');
1357
+ await cmd.reply({ content: result || 'No history yet.', ephemeral: true });
1358
+ return;
1359
+ }
1360
+ if (subCmd === 'pending') {
1361
+ const result = await gateway.handleSelfImprove('pending');
1362
+ await cmd.reply({ content: result, ephemeral: true });
1363
+ return;
1364
+ }
1365
+ if (subCmd === 'run') {
1366
+ await cmd.deferReply();
1367
+ const result = await gateway.handleSelfImprove('run', {}, async (experiment) => {
1368
+ const proposalText = `**Self-Improvement Proposal #${experiment.iteration}**\n\n` +
1369
+ `**Area:** ${experiment.area}\n` +
1370
+ `**Target:** ${experiment.target}\n` +
1371
+ `**Score:** ${(experiment.score * 10).toFixed(1)}/10\n\n` +
1372
+ `**Hypothesis:** ${experiment.hypothesis}\n\n` +
1373
+ `**Proposed Change:**\n\`\`\`\n${experiment.proposedChange.slice(0, 800)}\n\`\`\``;
1374
+ if (cmd.channel) {
1375
+ await sendApprovalButtons(cmd.channel, proposalText.slice(0, 1900), 'si', experiment.id);
1376
+ }
1377
+ });
1378
+ const chunks = chunkText(result, 1900);
1379
+ await cmd.editReply(chunks[0]);
1380
+ for (let i = 1; i < chunks.length; i++) {
1381
+ await cmd.followUp(chunks[i]);
1382
+ }
1383
+ return;
1384
+ }
1385
+ return;
1386
+ }
1387
+ // Dashboard — fresh status embed
1388
+ if (name === 'dashboard') {
1389
+ if (cmd.channel) {
1390
+ await cmd.reply({ content: 'Refreshing status...', ephemeral: true });
1391
+ await sendFreshStatusEmbed(cmd.channel);
1392
+ }
1393
+ else {
1394
+ await cmd.reply({ content: 'Could not access channel.', ephemeral: true });
1395
+ }
1396
+ return;
1397
+ }
1398
+ // Heartbeat command
1399
+ if (name === 'heartbeat') {
1400
+ await cmd.deferReply();
1401
+ const response = await heartbeat.runManual();
1402
+ const chunks = chunkText(response, 1900);
1403
+ await cmd.editReply(chunks[0]);
1404
+ for (let i = 1; i < chunks.length; i++) {
1405
+ await cmd.followUp(chunks[i]);
1406
+ }
1407
+ gateway.injectContext(sessionKey, '!heartbeat', response);
1408
+ return;
1409
+ }
1410
+ // Plan command — uses same approval-gated function as !plan
1411
+ if (name === 'plan') {
1412
+ const task = cmd.options.getString('task', true);
1413
+ await cmd.deferReply();
1414
+ await cmd.editReply(`Planning: _${task.slice(0, 100)}_...`);
1415
+ // Route through the shared handlePlanCommand (it handles approval + progress)
1416
+ // We need the channel for buttons, so get it from the interaction
1417
+ if (cmd.channel) {
1418
+ await handlePlanCommand(gateway, sessionKey, task, cmd.channel);
1419
+ }
1420
+ else {
1421
+ await cmd.editReply('Could not access channel for plan approval.');
1422
+ }
1423
+ return;
1424
+ }
1425
+ // Chat commands: /deep (with approval), /quick, /opus
1426
+ if (name === 'deep' || name === 'quick' || name === 'opus') {
1427
+ const msg = cmd.options.getString('message', true);
1428
+ const oneOffModel = name === 'quick' ? MODELS.haiku : name === 'opus' ? MODELS.opus : undefined;
1429
+ const oneOffMaxTurns = name === 'deep' ? 100 : undefined;
1430
+ // /deep requires approval before running 100 turns
1431
+ if (name === 'deep' && cmd.channel) {
1432
+ await cmd.deferReply();
1433
+ await cmd.editReply(`**Deep mode** (100 turns) requested for:\n_${msg.slice(0, 200)}_`);
1434
+ const requestId = `deep-${Date.now()}`;
1435
+ await sendApprovalButtons(cmd.channel, 'Approve deep mode?', 'deep', requestId);
1436
+ const approved = await gateway.requestApproval('Pending approval', requestId);
1437
+ if (!approved) {
1438
+ await cmd.followUp('Deep mode cancelled.');
1439
+ return;
1440
+ }
1441
+ }
1442
+ else {
1443
+ await cmd.deferReply();
1444
+ }
1445
+ try {
1446
+ const response = await gateway.handleMessage(sessionKey, msg, async () => { }, oneOffModel, oneOffMaxTurns);
1447
+ const chunks = chunkText(response || '*(no response)*', 1900);
1448
+ if (name === 'deep') {
1449
+ // Deep mode already has a deferred reply, use followUp
1450
+ await cmd.followUp(chunks[0]);
1451
+ for (let i = 1; i < chunks.length; i++) {
1452
+ await cmd.followUp(chunks[i]);
1453
+ }
1454
+ }
1455
+ else {
1456
+ await cmd.editReply(chunks[0]);
1457
+ for (let i = 1; i < chunks.length; i++) {
1458
+ await cmd.followUp(chunks[i]);
1459
+ }
1460
+ }
1461
+ }
1462
+ catch (err) {
1463
+ logger.error({ err }, `/${name} command failed`);
1464
+ const errMsg = `Something went wrong: ${err}`;
1465
+ if (name === 'deep') {
1466
+ await cmd.followUp(errMsg);
1467
+ }
1468
+ else {
1469
+ await cmd.editReply(errMsg);
1470
+ }
1471
+ }
1472
+ return;
1473
+ }
1474
+ return;
1475
+ }
1476
+ // ── Modal submissions (revision feedback) ─────────────────────
1477
+ if (interaction.isModalSubmit()) {
1478
+ const modal = interaction;
1479
+ const modalId = modal.customId; // e.g. "revise_modal_plan-1234567890"
1480
+ if (modalId.startsWith('revise_modal_')) {
1481
+ const requestId = modalId.replace('revise_modal_', '');
1482
+ const feedback = modal.fields.getTextInputValue('revision_feedback');
1483
+ await modal.deferUpdate();
1484
+ // Disable buttons on the original approval message
1485
+ try {
1486
+ if (modal.message) {
1487
+ const originalContent = modal.message.content ?? '';
1488
+ const rawComponents = modal.message.components.map((row) => ({
1489
+ type: 1,
1490
+ components: (row.components ?? []).map((comp) => ({
1491
+ type: comp.type ?? 2,
1492
+ style: comp.style,
1493
+ label: comp.label,
1494
+ custom_id: comp.customId ?? comp.custom_id,
1495
+ disabled: true,
1496
+ })),
1497
+ }));
1498
+ await modal.editReply({
1499
+ content: originalContent + `\n\n\u270f\ufe0f **REVISING** by ${modal.user.username}: ${feedback.slice(0, 200)}`,
1500
+ components: rawComponents,
1501
+ });
1502
+ }
1503
+ }
1504
+ catch { /* non-fatal */ }
1505
+ // Resolve the approval gate with the revision feedback string
1506
+ gateway.resolveApproval(requestId, feedback);
1507
+ return;
1508
+ }
1509
+ }
1510
+ // ── Button interactions ──────────────────────────────────────────
1511
+ if (!interaction.isButton())
1512
+ return;
1513
+ const button = interaction;
1514
+ // Owner-only
1515
+ if (DISCORD_OWNER_ID && button.user.id !== DISCORD_OWNER_ID) {
1516
+ await button.reply({ content: 'Only the owner can use these buttons.', ephemeral: true });
1517
+ return;
1518
+ }
1519
+ const customId = button.customId; // e.g. "plan_plan-123_approve", "plan_plan-123_revise"
1520
+ const isApprove = customId.endsWith('_approve');
1521
+ const isDeny = customId.endsWith('_deny');
1522
+ const isRevise = customId.endsWith('_revise');
1523
+ if (!isApprove && !isDeny && !isRevise)
1524
+ return;
1525
+ // ── Revise button → show modal for feedback ────────────────────
1526
+ if (isRevise) {
1527
+ const parts = customId.split('_');
1528
+ const requestId = parts.slice(1, -1).join('_');
1529
+ // Show a text input modal to collect revision feedback
1530
+ await button.showModal({
1531
+ title: 'Revise Plan',
1532
+ custom_id: `revise_modal_${requestId}`,
1533
+ components: [{
1534
+ type: 1, // ActionRow
1535
+ components: [{
1536
+ type: 4, // TextInput
1537
+ custom_id: 'revision_feedback',
1538
+ label: 'What would you like to change?',
1539
+ style: 2, // Paragraph
1540
+ placeholder: 'e.g., "Split step 3 into two separate steps" or "Add error handling"',
1541
+ required: true,
1542
+ max_length: 1000,
1543
+ }],
1544
+ }],
1545
+ });
1546
+ return;
1547
+ }
1548
+ const action = isApprove ? 'approved' : 'denied';
1549
+ const emoji = isApprove ? '\u2705' : '\u274c';
1550
+ // Acknowledge immediately — Discord requires response within 3 seconds
1551
+ await button.deferUpdate();
1552
+ // Update the original message: disable buttons and show decision
1553
+ try {
1554
+ const originalContent = button.message.content ?? '';
1555
+ const updatedContent = originalContent + `\n\n${emoji} **${action.toUpperCase()}** by ${button.user.username}`;
1556
+ // Disable buttons via raw API data — avoids discord.js component type issues
1557
+ const rawComponents = button.message.components.map((row) => ({
1558
+ type: 1,
1559
+ components: (row.components ?? []).map((comp) => ({
1560
+ type: comp.type ?? 2,
1561
+ style: comp.style,
1562
+ label: comp.label,
1563
+ custom_id: comp.customId ?? comp.custom_id,
1564
+ disabled: true,
1565
+ })),
1566
+ }));
1567
+ await button.editReply({
1568
+ content: updatedContent.slice(0, 2000),
1569
+ components: rawComponents,
1570
+ });
1571
+ }
1572
+ catch (err) {
1573
+ logger.error({ err }, 'Failed to update button message');
1574
+ }
1575
+ // ── Plan/Deep approval buttons → resolve the gateway approval gate
1576
+ if (customId.startsWith('plan_') || customId.startsWith('deep_')) {
1577
+ // Extract requestId: "plan_{requestId}_approve" → requestId
1578
+ const parts = customId.split('_');
1579
+ // Remove prefix (plan/deep) and suffix (approve/deny), join middle parts
1580
+ const requestId = parts.slice(1, -1).join('_');
1581
+ gateway.resolveApproval(requestId, isApprove);
1582
+ return;
1583
+ }
1584
+ // ── Self-improvement approval buttons
1585
+ if (customId.startsWith('si_')) {
1586
+ const parts = customId.split('_');
1587
+ const experimentId = parts.slice(1, -1).join('_');
1588
+ try {
1589
+ const result = isApprove
1590
+ ? await gateway.handleSelfImprove('apply', { experimentId })
1591
+ : await gateway.handleSelfImprove('deny', { experimentId });
1592
+ await button.followUp({ content: result, ephemeral: true });
1593
+ }
1594
+ catch (err) {
1595
+ await button.followUp({ content: `Error: ${err}`, ephemeral: true });
1596
+ }
1597
+ return;
1598
+ }
1599
+ // ── Other buttons — route the decision to the agent as a message
1600
+ const sessionKey = `discord:channel:${button.channelId}:${button.user.id}`;
1601
+ const originalContent = button.message.content ?? '';
1602
+ // Build context message for the agent
1603
+ const agentMessage = `[Button clicked: ${action}]\n\nOriginal request:\n${originalContent}\n\nNate ${action} this request. ${isApprove ? 'Proceed as requested.' : 'Skip this request and log that it was denied.'}`;
1604
+ // Process through gateway
1605
+ const streamer = new DiscordStreamingMessage(button.channel);
1606
+ await streamer.start();
1607
+ try {
1608
+ const response = await gateway.handleMessage(sessionKey, agentMessage, (t) => streamer.update(t));
1609
+ await streamer.finalize(response);
1610
+ }
1611
+ catch (err) {
1612
+ logger.error({ err }, 'Error processing button interaction');
1613
+ await streamer.finalize(`Something went wrong processing the ${action}: ${err}`);
1614
+ }
1615
+ }
1616
+ catch (err) {
1617
+ logger.error({ err }, 'Unhandled error in Discord interaction handler');
1618
+ }
1619
+ });
1620
+ // ── Reaction-based feedback handler ─────────────────────────────────
1621
+ client.on(Events.MessageReactionAdd, async (reaction, user) => {
1622
+ try {
1623
+ // Ignore bot's own reactions
1624
+ if (user.id === client.user?.id)
1625
+ return;
1626
+ // Owner-only
1627
+ if (DISCORD_OWNER_ID && user.id !== DISCORD_OWNER_ID)
1628
+ return;
1629
+ // Fetch partial reaction if needed
1630
+ if (reaction.partial) {
1631
+ try {
1632
+ await reaction.fetch();
1633
+ }
1634
+ catch {
1635
+ return; // Message may have been deleted
1636
+ }
1637
+ }
1638
+ // Check if this is a tracked bot message
1639
+ const messageId = reaction.message.id;
1640
+ const context = botMessageMap.get(messageId);
1641
+ if (!context)
1642
+ return;
1643
+ // Map emoji to rating
1644
+ const emojiName = reaction.emoji.name ?? '';
1645
+ const rating = emojiToRating(emojiName);
1646
+ if (!rating)
1647
+ return;
1648
+ // Log feedback
1649
+ try {
1650
+ const store = await getFeedbackStore();
1651
+ if (store) {
1652
+ store.logFeedback({
1653
+ sessionKey: context.sessionKey,
1654
+ channel: 'discord',
1655
+ messageSnippet: context.userMessage,
1656
+ responseSnippet: context.botResponse,
1657
+ rating,
1658
+ });
1659
+ logger.info({ rating, messageId }, 'Feedback logged via Discord reaction');
1660
+ }
1661
+ }
1662
+ catch (err) {
1663
+ logger.warn({ err }, 'Failed to log reaction feedback');
1664
+ }
1665
+ }
1666
+ catch (err) {
1667
+ logger.error({ err }, 'Unhandled error in Discord reaction handler');
1668
+ }
1669
+ });
1670
+ // ── Register notification sender ──────────────────────────────────
1671
+ // Cache the owner's DM channel from successful interactions so
1672
+ // cron/heartbeat notifications don't depend on a fresh API fetch.
1673
+ let cachedDmChannel = null;
1674
+ async function discordNotify(text, context) {
1675
+ // Route to agent bot if available
1676
+ if (context?.agentSlug && botManager?.hasBot(context.agentSlug)) {
1677
+ try {
1678
+ await botManager.sendAsAgent(context.agentSlug, text);
1679
+ logger.info({ agent: context.agentSlug }, 'Notification sent via agent bot');
1680
+ return;
1681
+ }
1682
+ catch (err) {
1683
+ logger.warn({ err, agent: context.agentSlug }, 'Agent bot send failed — falling back to main bot');
1684
+ }
1685
+ }
1686
+ // Main bot: try cached DM channel first
1687
+ let channel = cachedDmChannel;
1688
+ if (!channel || !('send' in channel)) {
1689
+ try {
1690
+ const user = await client.users.fetch(DISCORD_OWNER_ID, { force: true });
1691
+ channel = await user.createDM();
1692
+ cachedDmChannel = channel;
1693
+ }
1694
+ catch (err) {
1695
+ logger.error({ err }, 'Failed to open DM channel for notification');
1696
+ throw err;
1697
+ }
1698
+ }
1699
+ // Send as embed for cron messages, plain text for others
1700
+ const embed = formatCronEmbed(text);
1701
+ const sendContent = async (ch) => {
1702
+ if (embed) {
1703
+ const sentMsg = await ch.send({ embeds: [embed] });
1704
+ trackBotMessage(sentMsg.id, {
1705
+ sessionKey: 'cron:notification',
1706
+ userMessage: '(scheduled notification)',
1707
+ botResponse: text.slice(0, 500),
1708
+ });
1709
+ }
1710
+ else {
1711
+ const chunks = chunkText(text, 1900);
1712
+ let sentCount = 0;
1713
+ for (const chunk of chunks) {
1714
+ const sentMsg = await ch.send(chunk);
1715
+ trackBotMessage(sentMsg.id, {
1716
+ sessionKey: 'cron:notification',
1717
+ userMessage: '(scheduled notification)',
1718
+ botResponse: chunk.slice(0, 500),
1719
+ });
1720
+ sentCount++;
1721
+ }
1722
+ return sentCount;
1723
+ }
1724
+ return 1;
1725
+ };
1726
+ try {
1727
+ await sendContent(channel);
1728
+ }
1729
+ catch (err) {
1730
+ // Channel might be stale — clear cache, wait briefly, retry once
1731
+ cachedDmChannel = null;
1732
+ logger.warn({ err }, 'Discord notification failed — retrying once');
1733
+ try {
1734
+ await new Promise(r => setTimeout(r, 2000));
1735
+ const user = await client.users.fetch(DISCORD_OWNER_ID, { force: true });
1736
+ channel = await user.createDM();
1737
+ cachedDmChannel = channel;
1738
+ await sendContent(channel);
1739
+ }
1740
+ catch (retryErr) {
1741
+ logger.error({ err: retryErr }, 'Discord notification retry failed');
1742
+ throw retryErr;
1743
+ }
1744
+ }
1745
+ }
1746
+ // Register sender only after Discord client is ready
1747
+ client.once(Events.ClientReady, () => {
1748
+ dispatcher.register('discord', discordNotify);
1749
+ });
1750
+ logger.info('Starting Discord bot...');
1751
+ await client.login(DISCORD_TOKEN);
1752
+ }
1753
+ // ── Plan orchestration helper ─────────────────────────────────────────
1754
+ async function handlePlanCommand(gateway, sessionKey, taskDescription, channel) {
1755
+ const streamer = new DiscordStreamingMessage(channel);
1756
+ await streamer.start();
1757
+ await streamer.update('Planning...');
1758
+ let progressTimer = null;
1759
+ try {
1760
+ const result = await gateway.handlePlan(sessionKey, taskDescription, async (updates) => {
1761
+ // Build progress display (truncate descriptions to fit Discord limit)
1762
+ const lines = [
1763
+ `**Plan:** ${taskDescription.slice(0, 100)}`,
1764
+ '',
1765
+ ...updates.map((u, i) => {
1766
+ const num = `[${i + 1}/${updates.length}]`;
1767
+ const desc = u.description.slice(0, 60);
1768
+ switch (u.status) {
1769
+ case 'done': return `${num} ${desc} \u2713 (${Math.round((u.durationMs ?? 0) / 1000)}s)`;
1770
+ case 'running': return `${num} ${desc} \u23f3 running...`;
1771
+ case 'failed': return `${num} ${desc} \u2717 failed`;
1772
+ default: return `${num} ${desc} \u25cb waiting`;
1773
+ }
1774
+ }),
1775
+ ];
1776
+ await streamer.update(lines.join('\n').slice(0, 1800));
1777
+ // Start progress timer on first running step
1778
+ if (!progressTimer && updates.some(u => u.status === 'running')) {
1779
+ progressTimer = setInterval(async () => {
1780
+ // Re-render with live elapsed times (static snapshot — no orchestrator ref needed)
1781
+ await streamer.update(lines.join('\n').slice(0, 1800));
1782
+ }, 5000);
1783
+ }
1784
+ },
1785
+ // Approval gate — show plan and wait for user confirmation
1786
+ async (_planSummary, steps) => {
1787
+ // Show plan preview as a new message (previous streamer may be finalized from a revision round)
1788
+ const planPreview = `**Plan:** ${taskDescription.slice(0, 100)}\n\n` +
1789
+ steps.map((s, i) => `${i + 1}. **${s.id}** — ${s.description.slice(0, 60)}`).join('\n');
1790
+ if ('send' in channel) {
1791
+ await sendChunked(channel, planPreview);
1792
+ }
1793
+ // Send approval buttons
1794
+ const requestId = `plan-${Date.now()}`;
1795
+ await sendApprovalButtons(channel, 'Approve this plan?', 'plan', requestId, { showRevise: true });
1796
+ // Wait for the user to click approve/deny/revise
1797
+ const approvalResult = await gateway.requestApproval('Pending approval', requestId);
1798
+ if (typeof approvalResult === 'string') {
1799
+ // Revision — post status and return feedback so orchestrator re-generates
1800
+ if ('send' in channel) {
1801
+ await channel.send(`\u2728 *Revising plan...*`);
1802
+ }
1803
+ return approvalResult;
1804
+ }
1805
+ if (approvalResult) {
1806
+ // Start a new streamer for execution progress
1807
+ const newStreamer = new DiscordStreamingMessage(channel);
1808
+ await newStreamer.start();
1809
+ await newStreamer.update('Executing plan...');
1810
+ // Swap the streamer reference for progress updates
1811
+ Object.assign(streamer, {
1812
+ message: newStreamer.message,
1813
+ lastEdit: newStreamer.lastEdit,
1814
+ pendingText: '',
1815
+ lastFlushedText: '',
1816
+ isFinal: false,
1817
+ });
1818
+ }
1819
+ return approvalResult;
1820
+ });
1821
+ await streamer.finalize(result);
1822
+ }
1823
+ catch (err) {
1824
+ logger.error({ err }, 'Plan execution failed');
1825
+ await streamer.finalize(`Plan failed: ${err}`);
1826
+ }
1827
+ finally {
1828
+ if (progressTimer)
1829
+ clearInterval(progressTimer);
1830
+ }
1831
+ }
1832
+ //# sourceMappingURL=discord.js.map