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.
- package/.env.example +44 -0
- package/LICENSE +21 -0
- package/README.md +795 -0
- package/dist/agent/agent-manager.d.ts +69 -0
- package/dist/agent/agent-manager.js +441 -0
- package/dist/agent/assistant.d.ts +225 -0
- package/dist/agent/assistant.js +3888 -0
- package/dist/agent/auto-update.d.ts +32 -0
- package/dist/agent/auto-update.js +186 -0
- package/dist/agent/daily-planner.d.ts +24 -0
- package/dist/agent/daily-planner.js +379 -0
- package/dist/agent/execution-advisor.d.ts +10 -0
- package/dist/agent/execution-advisor.js +272 -0
- package/dist/agent/hooks.d.ts +45 -0
- package/dist/agent/hooks.js +564 -0
- package/dist/agent/insight-engine.d.ts +66 -0
- package/dist/agent/insight-engine.js +225 -0
- package/dist/agent/intent-classifier.d.ts +48 -0
- package/dist/agent/intent-classifier.js +214 -0
- package/dist/agent/link-extractor.d.ts +19 -0
- package/dist/agent/link-extractor.js +90 -0
- package/dist/agent/mcp-bridge.d.ts +62 -0
- package/dist/agent/mcp-bridge.js +435 -0
- package/dist/agent/metacognition.d.ts +66 -0
- package/dist/agent/metacognition.js +221 -0
- package/dist/agent/orchestrator.d.ts +81 -0
- package/dist/agent/orchestrator.js +790 -0
- package/dist/agent/profiles.d.ts +22 -0
- package/dist/agent/profiles.js +91 -0
- package/dist/agent/prompt-cache.d.ts +24 -0
- package/dist/agent/prompt-cache.js +68 -0
- package/dist/agent/prompt-evolver.d.ts +28 -0
- package/dist/agent/prompt-evolver.js +279 -0
- package/dist/agent/role-scaffolds.d.ts +28 -0
- package/dist/agent/role-scaffolds.js +433 -0
- package/dist/agent/safe-restart.d.ts +41 -0
- package/dist/agent/safe-restart.js +150 -0
- package/dist/agent/self-improve.d.ts +66 -0
- package/dist/agent/self-improve.js +1706 -0
- package/dist/agent/session-event-log.d.ts +114 -0
- package/dist/agent/session-event-log.js +233 -0
- package/dist/agent/skill-extractor.d.ts +72 -0
- package/dist/agent/skill-extractor.js +435 -0
- package/dist/agent/source-mods.d.ts +61 -0
- package/dist/agent/source-mods.js +230 -0
- package/dist/agent/source-preflight.d.ts +25 -0
- package/dist/agent/source-preflight.js +100 -0
- package/dist/agent/stall-guard.d.ts +62 -0
- package/dist/agent/stall-guard.js +109 -0
- package/dist/agent/strategic-planner.d.ts +60 -0
- package/dist/agent/strategic-planner.js +352 -0
- package/dist/agent/team-bus.d.ts +89 -0
- package/dist/agent/team-bus.js +556 -0
- package/dist/agent/team-router.d.ts +26 -0
- package/dist/agent/team-router.js +37 -0
- package/dist/agent/tool-loop-detector.d.ts +59 -0
- package/dist/agent/tool-loop-detector.js +242 -0
- package/dist/agent/workflow-runner.d.ts +36 -0
- package/dist/agent/workflow-runner.js +317 -0
- package/dist/agent/workflow-variables.d.ts +16 -0
- package/dist/agent/workflow-variables.js +62 -0
- package/dist/channels/discord-agent-bot.d.ts +101 -0
- package/dist/channels/discord-agent-bot.js +881 -0
- package/dist/channels/discord-bot-manager.d.ts +80 -0
- package/dist/channels/discord-bot-manager.js +262 -0
- package/dist/channels/discord-utils.d.ts +51 -0
- package/dist/channels/discord-utils.js +293 -0
- package/dist/channels/discord.d.ts +12 -0
- package/dist/channels/discord.js +1832 -0
- package/dist/channels/slack-agent-bot.d.ts +73 -0
- package/dist/channels/slack-agent-bot.js +320 -0
- package/dist/channels/slack-bot-manager.d.ts +66 -0
- package/dist/channels/slack-bot-manager.js +236 -0
- package/dist/channels/slack-utils.d.ts +39 -0
- package/dist/channels/slack-utils.js +189 -0
- package/dist/channels/slack.d.ts +11 -0
- package/dist/channels/slack.js +196 -0
- package/dist/channels/telegram.d.ts +10 -0
- package/dist/channels/telegram.js +235 -0
- package/dist/channels/webhook.d.ts +9 -0
- package/dist/channels/webhook.js +78 -0
- package/dist/channels/whatsapp.d.ts +11 -0
- package/dist/channels/whatsapp.js +181 -0
- package/dist/cli/chat.d.ts +14 -0
- package/dist/cli/chat.js +220 -0
- package/dist/cli/cron.d.ts +17 -0
- package/dist/cli/cron.js +552 -0
- package/dist/cli/dashboard.d.ts +15 -0
- package/dist/cli/dashboard.js +17677 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +2474 -0
- package/dist/cli/routes/delegations.d.ts +19 -0
- package/dist/cli/routes/delegations.js +154 -0
- package/dist/cli/routes/digest.d.ts +17 -0
- package/dist/cli/routes/digest.js +375 -0
- package/dist/cli/routes/goals.d.ts +14 -0
- package/dist/cli/routes/goals.js +258 -0
- package/dist/cli/routes/workflows.d.ts +18 -0
- package/dist/cli/routes/workflows.js +97 -0
- package/dist/cli/setup.d.ts +8 -0
- package/dist/cli/setup.js +619 -0
- package/dist/cli/tunnel.d.ts +35 -0
- package/dist/cli/tunnel.js +141 -0
- package/dist/config.d.ts +145 -0
- package/dist/config.js +278 -0
- package/dist/events/bus.d.ts +43 -0
- package/dist/events/bus.js +136 -0
- package/dist/gateway/cron-scheduler.d.ts +166 -0
- package/dist/gateway/cron-scheduler.js +1767 -0
- package/dist/gateway/delivery-queue.d.ts +30 -0
- package/dist/gateway/delivery-queue.js +110 -0
- package/dist/gateway/heartbeat-scheduler.d.ts +99 -0
- package/dist/gateway/heartbeat-scheduler.js +1298 -0
- package/dist/gateway/heartbeat.d.ts +3 -0
- package/dist/gateway/heartbeat.js +3 -0
- package/dist/gateway/lanes.d.ts +24 -0
- package/dist/gateway/lanes.js +76 -0
- package/dist/gateway/notifications.d.ts +29 -0
- package/dist/gateway/notifications.js +75 -0
- package/dist/gateway/router.d.ts +210 -0
- package/dist/gateway/router.js +1330 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1015 -0
- package/dist/memory/chunker.d.ts +28 -0
- package/dist/memory/chunker.js +226 -0
- package/dist/memory/consolidation.d.ts +44 -0
- package/dist/memory/consolidation.js +171 -0
- package/dist/memory/context-assembler.d.ts +50 -0
- package/dist/memory/context-assembler.js +149 -0
- package/dist/memory/embeddings.d.ts +38 -0
- package/dist/memory/embeddings.js +180 -0
- package/dist/memory/graph-store.d.ts +66 -0
- package/dist/memory/graph-store.js +613 -0
- package/dist/memory/mmr.d.ts +21 -0
- package/dist/memory/mmr.js +75 -0
- package/dist/memory/search.d.ts +26 -0
- package/dist/memory/search.js +67 -0
- package/dist/memory/store.d.ts +530 -0
- package/dist/memory/store.js +2022 -0
- package/dist/security/integrity.d.ts +24 -0
- package/dist/security/integrity.js +58 -0
- package/dist/security/patterns.d.ts +34 -0
- package/dist/security/patterns.js +110 -0
- package/dist/security/scanner.d.ts +32 -0
- package/dist/security/scanner.js +263 -0
- package/dist/tools/admin-tools.d.ts +12 -0
- package/dist/tools/admin-tools.js +1278 -0
- package/dist/tools/external-tools.d.ts +11 -0
- package/dist/tools/external-tools.js +1327 -0
- package/dist/tools/goal-tools.d.ts +9 -0
- package/dist/tools/goal-tools.js +159 -0
- package/dist/tools/mcp-server.d.ts +13 -0
- package/dist/tools/mcp-server.js +141 -0
- package/dist/tools/memory-tools.d.ts +10 -0
- package/dist/tools/memory-tools.js +568 -0
- package/dist/tools/session-tools.d.ts +6 -0
- package/dist/tools/session-tools.js +146 -0
- package/dist/tools/shared.d.ts +216 -0
- package/dist/tools/shared.js +340 -0
- package/dist/tools/team-tools.d.ts +6 -0
- package/dist/tools/team-tools.js +447 -0
- package/dist/tools/tool-meta.d.ts +34 -0
- package/dist/tools/tool-meta.js +133 -0
- package/dist/tools/vault-tools.d.ts +8 -0
- package/dist/tools/vault-tools.js +457 -0
- package/dist/types.d.ts +716 -0
- package/dist/types.js +16 -0
- package/dist/vault-migrations/0001-add-execution-framework.d.ts +10 -0
- package/dist/vault-migrations/0001-add-execution-framework.js +47 -0
- package/dist/vault-migrations/0002-add-agentic-communication.d.ts +12 -0
- package/dist/vault-migrations/0002-add-agentic-communication.js +79 -0
- package/dist/vault-migrations/0003-update-execution-pipeline-narration.d.ts +11 -0
- package/dist/vault-migrations/0003-update-execution-pipeline-narration.js +73 -0
- package/dist/vault-migrations/helpers.d.ts +14 -0
- package/dist/vault-migrations/helpers.js +44 -0
- package/dist/vault-migrations/runner.d.ts +14 -0
- package/dist/vault-migrations/runner.js +139 -0
- package/dist/vault-migrations/types.d.ts +42 -0
- package/dist/vault-migrations/types.js +9 -0
- package/install.sh +320 -0
- package/package.json +84 -0
- package/scripts/postinstall.js +125 -0
- package/vault/00-System/AGENTS.md +66 -0
- package/vault/00-System/CRON.md +71 -0
- package/vault/00-System/HEARTBEAT.md +58 -0
- package/vault/00-System/MEMORY.md +16 -0
- package/vault/00-System/SOUL.md +96 -0
- package/vault/05-Tasks/TASKS.md +19 -0
- package/vault/06-Templates/_Daily-Template.md +28 -0
- 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
|