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,881 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Discord agent bot client.
|
|
3
|
+
*
|
|
4
|
+
* A discord.js Client wrapper for a single agent.
|
|
5
|
+
* Handles: DMs + guild channel messages → gateway → stream response.
|
|
6
|
+
* Slash commands: /plan, /deep, /quick, /opus, /model, /clear, /help.
|
|
7
|
+
*
|
|
8
|
+
* Channel discovery (in priority order):
|
|
9
|
+
* 1. Explicit `discordChannelId` from agent config
|
|
10
|
+
* 2. Auto-discover by matching `channelName` in the guild
|
|
11
|
+
* 3. Falls back to listening in ALL text channels the bot can see
|
|
12
|
+
*
|
|
13
|
+
* DMs are always enabled for the owner.
|
|
14
|
+
*/
|
|
15
|
+
import { ActionRowBuilder, ActivityType, ChannelType, Client, EmbedBuilder, Events, GatewayIntentBits, ModalBuilder, Partials, REST, Routes, SlashCommandBuilder, TextInputBuilder, TextInputStyle, } from 'discord.js';
|
|
16
|
+
import pino from 'pino';
|
|
17
|
+
import { chunkText, sendChunked, DiscordStreamingMessage, friendlyToolName, sanitizeResponse } from './discord-utils.js';
|
|
18
|
+
import { MODELS } from '../config.js';
|
|
19
|
+
import * as cronParser from 'cron-parser';
|
|
20
|
+
const logger = pino({ name: 'clementine.agent-bot' });
|
|
21
|
+
/** Format a duration in minutes to a compact human string. */
|
|
22
|
+
function formatAgentDuration(minutes) {
|
|
23
|
+
if (minutes < 1)
|
|
24
|
+
return '<1m';
|
|
25
|
+
if (minutes < 60)
|
|
26
|
+
return `${minutes}m`;
|
|
27
|
+
if (minutes < 1440) {
|
|
28
|
+
const h = Math.floor(minutes / 60);
|
|
29
|
+
const m = minutes % 60;
|
|
30
|
+
return m > 0 ? `${h}h${m}m` : `${h}h`;
|
|
31
|
+
}
|
|
32
|
+
const d = Math.floor(minutes / 1440);
|
|
33
|
+
const h = Math.floor((minutes % 1440) / 60);
|
|
34
|
+
return h > 0 ? `${d}d${h}h` : `${d}d`;
|
|
35
|
+
}
|
|
36
|
+
// ── Slash commands shared by all agent bots ──────────────────────────
|
|
37
|
+
const agentSlashCommands = [
|
|
38
|
+
new SlashCommandBuilder().setName('plan').setDescription('Break a task into parallel steps')
|
|
39
|
+
.addStringOption(o => o.setName('task').setDescription('What to plan').setRequired(true)),
|
|
40
|
+
new SlashCommandBuilder().setName('deep').setDescription('Extended mode (100 turns) for heavy tasks')
|
|
41
|
+
.addStringOption(o => o.setName('message').setDescription('Your message').setRequired(true)),
|
|
42
|
+
new SlashCommandBuilder().setName('quick').setDescription('Quick reply using Haiku model')
|
|
43
|
+
.addStringOption(o => o.setName('message').setDescription('Your message').setRequired(true)),
|
|
44
|
+
new SlashCommandBuilder().setName('opus').setDescription('Deep reply using Opus model')
|
|
45
|
+
.addStringOption(o => o.setName('message').setDescription('Your message').setRequired(true)),
|
|
46
|
+
new SlashCommandBuilder().setName('model').setDescription('Switch default model')
|
|
47
|
+
.addStringOption(o => o.setName('tier').setDescription('Model tier').setRequired(true)
|
|
48
|
+
.addChoices({ name: 'Haiku', value: 'haiku' }, { name: 'Sonnet', value: 'sonnet' }, { name: 'Opus', value: 'opus' })),
|
|
49
|
+
new SlashCommandBuilder().setName('clear').setDescription('Reset conversation session'),
|
|
50
|
+
new SlashCommandBuilder().setName('help').setDescription('Show all available commands'),
|
|
51
|
+
];
|
|
52
|
+
export class AgentBotClient {
|
|
53
|
+
client;
|
|
54
|
+
config;
|
|
55
|
+
gateway;
|
|
56
|
+
status = 'offline';
|
|
57
|
+
errorMessage;
|
|
58
|
+
/** Resolved channel IDs (set on ready, after auto-discovery). */
|
|
59
|
+
resolvedChannelIds = [];
|
|
60
|
+
/** Pinned status embed message (edited in-place on state changes). */
|
|
61
|
+
statusEmbedMessage = null;
|
|
62
|
+
statusEmbedDebounce = null;
|
|
63
|
+
/** Check if a user is authorized to interact with this agent bot. */
|
|
64
|
+
isAuthorized(userId) {
|
|
65
|
+
if (this.config.ownerId && userId === this.config.ownerId)
|
|
66
|
+
return true;
|
|
67
|
+
if (this.config.allowedUsers?.includes(userId))
|
|
68
|
+
return true;
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
/** Check if a user is the owner (not just an allowed member). */
|
|
72
|
+
isOwner(userId) {
|
|
73
|
+
return !!(this.config.ownerId && userId === this.config.ownerId);
|
|
74
|
+
}
|
|
75
|
+
/** Return the session key prefix for channel sessions based on user role. */
|
|
76
|
+
channelPrefix(userId) {
|
|
77
|
+
return this.isOwner(userId) ? 'discord:channel' : 'discord:member';
|
|
78
|
+
}
|
|
79
|
+
/** Return the session key prefix for DM sessions based on user role. */
|
|
80
|
+
dmPrefix(userId) {
|
|
81
|
+
return this.isOwner(userId) ? 'discord:agent' : 'discord:member-dm';
|
|
82
|
+
}
|
|
83
|
+
constructor(config, gateway) {
|
|
84
|
+
this.config = config;
|
|
85
|
+
this.gateway = gateway;
|
|
86
|
+
this.client = new Client({
|
|
87
|
+
intents: [
|
|
88
|
+
GatewayIntentBits.Guilds,
|
|
89
|
+
GatewayIntentBits.GuildMessages,
|
|
90
|
+
GatewayIntentBits.MessageContent,
|
|
91
|
+
GatewayIntentBits.DirectMessages,
|
|
92
|
+
],
|
|
93
|
+
partials: [Partials.Channel], // Required for DM events
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async start() {
|
|
97
|
+
this.status = 'connecting';
|
|
98
|
+
this.client.once(Events.ClientReady, async (readyClient) => {
|
|
99
|
+
this.status = 'online';
|
|
100
|
+
this.errorMessage = undefined;
|
|
101
|
+
// Resolve channels and pre-register them as "seen" in the gateway
|
|
102
|
+
// so the new-channel check-in gate doesn't fire for known agent channels
|
|
103
|
+
this.resolvedChannelIds = this.discoverChannels();
|
|
104
|
+
for (const chId of this.resolvedChannelIds) {
|
|
105
|
+
this.gateway.markChannelSeen(`discord:channel:${chId}`);
|
|
106
|
+
}
|
|
107
|
+
// Register slash commands for this bot
|
|
108
|
+
try {
|
|
109
|
+
const rest = new REST().setToken(this.config.token);
|
|
110
|
+
await rest.put(Routes.applicationCommands(readyClient.user.id), {
|
|
111
|
+
body: agentSlashCommands.map(c => c.toJSON()),
|
|
112
|
+
});
|
|
113
|
+
logger.info({ slug: this.config.slug, count: agentSlashCommands.length }, `Registered ${agentSlashCommands.length} slash commands`);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
logger.error({ err, slug: this.config.slug }, 'Failed to register slash commands');
|
|
117
|
+
}
|
|
118
|
+
logger.info({ slug: this.config.slug, botTag: readyClient.user.tag, channels: this.resolvedChannelIds }, `Agent bot online: ${this.config.profile.name}`);
|
|
119
|
+
// Set presence to show the agent's role
|
|
120
|
+
readyClient.user.setPresence({
|
|
121
|
+
status: 'online',
|
|
122
|
+
activities: [{
|
|
123
|
+
name: this.config.profile.description.slice(0, 128),
|
|
124
|
+
type: ActivityType.Custom,
|
|
125
|
+
}],
|
|
126
|
+
});
|
|
127
|
+
// Send startup status to owner's DMs
|
|
128
|
+
await this.sendStartupStatus();
|
|
129
|
+
// Send status embed to the agent's primary channel (if available)
|
|
130
|
+
if (this.config.cronScheduler && this.resolvedChannelIds.length > 0) {
|
|
131
|
+
await this.sendOrUpdateStatusEmbed();
|
|
132
|
+
// Auto-update status embed on state changes (debounced)
|
|
133
|
+
this.config.cronScheduler.onStatusChange(() => {
|
|
134
|
+
if (this.statusEmbedDebounce)
|
|
135
|
+
clearTimeout(this.statusEmbedDebounce);
|
|
136
|
+
this.statusEmbedDebounce = setTimeout(() => {
|
|
137
|
+
this.sendOrUpdateStatusEmbed().catch(() => { });
|
|
138
|
+
}, 2000);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
this.client.on(Events.InteractionCreate, async (interaction) => {
|
|
143
|
+
try {
|
|
144
|
+
await this.handleInteraction(interaction);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
logger.error({ err, slug: this.config.slug }, 'Unhandled error in agent bot interaction handler');
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
this.client.on(Events.MessageCreate, async (message) => {
|
|
151
|
+
try {
|
|
152
|
+
await this.handleMessage(message);
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
logger.error({ err, slug: this.config.slug }, 'Unhandled error in agent bot message handler');
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
this.client.on(Events.Error, (err) => {
|
|
159
|
+
this.status = 'error';
|
|
160
|
+
this.errorMessage = String(err);
|
|
161
|
+
logger.error({ err, slug: this.config.slug }, 'Agent bot error');
|
|
162
|
+
});
|
|
163
|
+
try {
|
|
164
|
+
await this.client.login(this.config.token);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
this.status = 'error';
|
|
168
|
+
this.errorMessage = String(err);
|
|
169
|
+
logger.error({ err, slug: this.config.slug }, 'Agent bot login failed');
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async stop() {
|
|
174
|
+
try {
|
|
175
|
+
this.client.destroy();
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// ignore
|
|
179
|
+
}
|
|
180
|
+
this.status = 'offline';
|
|
181
|
+
logger.info({ slug: this.config.slug }, 'Agent bot stopped');
|
|
182
|
+
}
|
|
183
|
+
getStatus() {
|
|
184
|
+
return {
|
|
185
|
+
status: this.status,
|
|
186
|
+
botTag: this.client.user?.tag,
|
|
187
|
+
avatarUrl: this.client.user?.displayAvatarURL({ size: 128, extension: 'png' }),
|
|
188
|
+
error: this.errorMessage,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
getChannelIds() {
|
|
192
|
+
return this.resolvedChannelIds;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Discover which channels this bot should listen in.
|
|
196
|
+
*
|
|
197
|
+
* Priority:
|
|
198
|
+
* 1. Explicit channelIds from config (e.g. discordChannelId in agent.md)
|
|
199
|
+
* 2. Match by channelName in any guild the bot is in
|
|
200
|
+
* 3. All text channels the bot can see (fallback for simple setups)
|
|
201
|
+
*/
|
|
202
|
+
discoverChannels() {
|
|
203
|
+
// 1. Explicit IDs
|
|
204
|
+
if (this.config.channelIds && this.config.channelIds.length > 0) {
|
|
205
|
+
logger.info({ slug: this.config.slug, channelIds: this.config.channelIds }, 'Using explicit channel IDs');
|
|
206
|
+
return this.config.channelIds;
|
|
207
|
+
}
|
|
208
|
+
// 2. Match by channelName (supports single string or array of names)
|
|
209
|
+
const channelNameConfig = this.config.profile.team?.channelName;
|
|
210
|
+
if (channelNameConfig) {
|
|
211
|
+
const channelNames = Array.isArray(channelNameConfig) ? channelNameConfig : [channelNameConfig];
|
|
212
|
+
const matched = [];
|
|
213
|
+
for (const guild of this.client.guilds.cache.values()) {
|
|
214
|
+
for (const channel of guild.channels.cache.values()) {
|
|
215
|
+
if (channel.type === ChannelType.GuildText && channelNames.includes(channel.name)) {
|
|
216
|
+
matched.push(channel.id);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (matched.length > 0) {
|
|
221
|
+
logger.info({ slug: this.config.slug, channelNames, matched }, 'Auto-discovered channels by name');
|
|
222
|
+
return matched;
|
|
223
|
+
}
|
|
224
|
+
logger.warn({ slug: this.config.slug, channelNames }, 'No channels found matching channelName(s) — falling back to all visible text channels');
|
|
225
|
+
}
|
|
226
|
+
// 3. Fallback: all text channels the bot can see
|
|
227
|
+
const all = [];
|
|
228
|
+
for (const guild of this.client.guilds.cache.values()) {
|
|
229
|
+
for (const channel of guild.channels.cache.values()) {
|
|
230
|
+
if (channel.type === ChannelType.GuildText) {
|
|
231
|
+
all.push(channel.id);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
logger.info({ slug: this.config.slug, count: all.length }, 'Fallback: listening in all visible text channels');
|
|
236
|
+
return all;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Send a notification to the owner's DMs on behalf of this agent bot.
|
|
240
|
+
* Used by BotManager.sendAsAgent() for cron result routing.
|
|
241
|
+
*/
|
|
242
|
+
async sendNotification(text, embed) {
|
|
243
|
+
if (this.status !== 'online')
|
|
244
|
+
throw new Error(`Bot ${this.config.slug} is not online`);
|
|
245
|
+
const owner = await this.client.users.fetch(this.config.ownerId, { force: true });
|
|
246
|
+
const dmChannel = await owner.createDM();
|
|
247
|
+
if (embed) {
|
|
248
|
+
await dmChannel.send({ embeds: [embed] });
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
const { chunkText } = await import('./discord-utils.js');
|
|
252
|
+
for (const chunk of chunkText(text, 1900)) {
|
|
253
|
+
await dmChannel.send(chunk);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/** Send a startup status embed to the owner's DMs. */
|
|
258
|
+
async sendStartupStatus() {
|
|
259
|
+
if (!this.config.ownerId)
|
|
260
|
+
return;
|
|
261
|
+
try {
|
|
262
|
+
const owner = await this.client.users.fetch(this.config.ownerId, { force: true });
|
|
263
|
+
const dmChannel = await owner.createDM();
|
|
264
|
+
// Use the rich agent status embed if cronScheduler is available
|
|
265
|
+
const embed = this.config.cronScheduler
|
|
266
|
+
? this.buildAgentStatusEmbed()
|
|
267
|
+
: new EmbedBuilder()
|
|
268
|
+
.setColor(0x22c55e)
|
|
269
|
+
.setTitle(`${this.config.profile.name} is online`)
|
|
270
|
+
.setDescription(this.config.profile.description)
|
|
271
|
+
.addFields({ name: 'Model', value: this.config.profile.model || 'sonnet', inline: true }, { name: 'Tier', value: String(this.config.profile.tier), inline: true })
|
|
272
|
+
.setFooter({ text: `Agent bot \u00b7 ${this.client.user?.tag ?? 'unknown'}` })
|
|
273
|
+
.setTimestamp();
|
|
274
|
+
await dmChannel.send({ embeds: [embed] });
|
|
275
|
+
logger.info({ slug: this.config.slug }, 'Sent startup status embed to owner DMs');
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
logger.error({ err, slug: this.config.slug }, 'Failed to send startup status embed');
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// ── Agent-scoped status embed ──────────────────────────────────────
|
|
282
|
+
buildAgentStatusEmbed() {
|
|
283
|
+
const now = new Date();
|
|
284
|
+
const slug = this.config.slug;
|
|
285
|
+
const profile = this.config.profile;
|
|
286
|
+
const cs = this.config.cronScheduler;
|
|
287
|
+
// Get agent-scoped job definitions
|
|
288
|
+
const allJobs = cs?.getJobDefinitions() ?? [];
|
|
289
|
+
const myJobs = allJobs.filter(j => j.agentSlug === slug);
|
|
290
|
+
const activeJobs = myJobs.filter(j => j.active);
|
|
291
|
+
// Agent-scoped today stats from run log
|
|
292
|
+
let myOk = 0, myErrors = 0, mySkipped = 0, myTotal = 0;
|
|
293
|
+
if (cs) {
|
|
294
|
+
const midnight = new Date();
|
|
295
|
+
midnight.setHours(0, 0, 0, 0);
|
|
296
|
+
const midnightISO = midnight.toISOString();
|
|
297
|
+
for (const job of myJobs) {
|
|
298
|
+
const entries = cs.runLog.readRecent(job.name, 50);
|
|
299
|
+
for (const e of entries) {
|
|
300
|
+
if (e.startedAt < midnightISO)
|
|
301
|
+
break;
|
|
302
|
+
myTotal++;
|
|
303
|
+
if (e.status === 'ok')
|
|
304
|
+
myOk++;
|
|
305
|
+
else if (e.status === 'error')
|
|
306
|
+
myErrors++;
|
|
307
|
+
else if (e.status === 'skipped')
|
|
308
|
+
mySkipped++;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// Running jobs for this agent
|
|
313
|
+
const runningJobs = cs?.getRunningJobs() ?? [];
|
|
314
|
+
const myRunning = runningJobs.filter(j => myJobs.some(mj => mj.name === j));
|
|
315
|
+
// Health color
|
|
316
|
+
const healthColor = myErrors > 0 ? 0xE74C3C
|
|
317
|
+
: myRunning.length > 0 ? 0xF39C12
|
|
318
|
+
: 0x2ECC71;
|
|
319
|
+
const embed = new EmbedBuilder()
|
|
320
|
+
.setTitle(`${profile.name} Status`)
|
|
321
|
+
.setDescription(profile.description)
|
|
322
|
+
.setColor(healthColor)
|
|
323
|
+
.setTimestamp(now)
|
|
324
|
+
.setFooter({ text: `Auto-updates \u00b7 !dashboard to refresh \u00b7 ${slug}` });
|
|
325
|
+
if (profile.avatar) {
|
|
326
|
+
embed.setThumbnail(profile.avatar);
|
|
327
|
+
}
|
|
328
|
+
// ── Active work
|
|
329
|
+
if (myRunning.length > 0) {
|
|
330
|
+
const runningItems = myRunning.map(j => {
|
|
331
|
+
// Strip agent slug prefix from job name
|
|
332
|
+
const display = j.startsWith(`${slug}:`) ? j.slice(slug.length + 1) : j;
|
|
333
|
+
return `\u23F3 ${display}`;
|
|
334
|
+
});
|
|
335
|
+
embed.addFields({
|
|
336
|
+
name: `\u2699\uFE0F Active (${runningItems.length})`,
|
|
337
|
+
value: runningItems.join('\n'),
|
|
338
|
+
inline: false,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
// ── Today's stats
|
|
342
|
+
const statsLine = `\u2705 ${myOk} passed` +
|
|
343
|
+
(myErrors > 0 ? ` \u00b7 \u274C ${myErrors} failed` : '') +
|
|
344
|
+
(mySkipped > 0 ? ` \u00b7 \u23ED ${mySkipped} skipped` : '');
|
|
345
|
+
embed.addFields({ name: `\u{1F4CA} Today (${myTotal} runs)`, value: statsLine, inline: false });
|
|
346
|
+
// ── Last run results per job
|
|
347
|
+
if (cs && myJobs.length > 0) {
|
|
348
|
+
const lastRunLines = [];
|
|
349
|
+
for (const job of activeJobs) {
|
|
350
|
+
const recent = cs.runLog.readRecent(job.name, 1);
|
|
351
|
+
const display = job.name.startsWith(`${slug}:`) ? job.name.slice(slug.length + 1) : job.name;
|
|
352
|
+
if (recent.length > 0) {
|
|
353
|
+
const r = recent[0];
|
|
354
|
+
const icon = r.status === 'ok' ? '\u2705' : r.status === 'error' ? '\u274C' : '\u23ED';
|
|
355
|
+
const ago = Math.round((Date.now() - new Date(r.finishedAt).getTime()) / 60000);
|
|
356
|
+
lastRunLines.push(`${icon} ${display} \u2014 ${formatAgentDuration(ago)} ago`);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
lastRunLines.push(`\u2B1C ${display} \u2014 never run`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (lastRunLines.length > 0) {
|
|
363
|
+
embed.addFields({ name: '\u{1F4DD} Last Runs', value: lastRunLines.join('\n'), inline: false });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// ── Next runs for this agent
|
|
367
|
+
const upcoming = [];
|
|
368
|
+
for (const job of activeJobs) {
|
|
369
|
+
try {
|
|
370
|
+
const fields = job.schedule.trim().split(/\s+/);
|
|
371
|
+
const expr = fields.length === 6 ? fields.slice(1).join(' ') : job.schedule;
|
|
372
|
+
const interval = cronParser.CronExpressionParser.parse(expr);
|
|
373
|
+
const next = interval.next().toDate();
|
|
374
|
+
upcoming.push({ name: job.name, nextMs: next.getTime() });
|
|
375
|
+
}
|
|
376
|
+
catch { /* skip */ }
|
|
377
|
+
}
|
|
378
|
+
upcoming.sort((a, b) => a.nextMs - b.nextMs);
|
|
379
|
+
if (upcoming.length > 0) {
|
|
380
|
+
const nextLines = upcoming.slice(0, 5).map(u => {
|
|
381
|
+
const diffMs = u.nextMs - now.getTime();
|
|
382
|
+
const diffMin = Math.round(diffMs / 60000);
|
|
383
|
+
const display = u.name.startsWith(`${slug}:`) ? u.name.slice(slug.length + 1) : u.name;
|
|
384
|
+
return `\`${formatAgentDuration(diffMin).padStart(4)}\` ${display}`;
|
|
385
|
+
});
|
|
386
|
+
if (upcoming.length > 5)
|
|
387
|
+
nextLines.push(`_+${upcoming.length - 5} more_`);
|
|
388
|
+
embed.addFields({ name: '\u{1F4C5} Next Runs', value: nextLines.join('\n'), inline: false });
|
|
389
|
+
}
|
|
390
|
+
// ── Config + Schedule summary
|
|
391
|
+
const disabledCount = myJobs.length - activeJobs.length;
|
|
392
|
+
const configParts = [
|
|
393
|
+
`Model: ${profile.model || 'sonnet'}`,
|
|
394
|
+
`Tier: ${profile.tier}`,
|
|
395
|
+
`Jobs: ${activeJobs.length} active` + (disabledCount > 0 ? `, ${disabledCount} disabled` : ''),
|
|
396
|
+
];
|
|
397
|
+
embed.addFields({ name: '\u{1F527} Config', value: configParts.join(' \u00b7 '), inline: false });
|
|
398
|
+
return embed;
|
|
399
|
+
}
|
|
400
|
+
async sendOrUpdateStatusEmbed() {
|
|
401
|
+
try {
|
|
402
|
+
const embed = this.buildAgentStatusEmbed();
|
|
403
|
+
if (this.statusEmbedMessage) {
|
|
404
|
+
try {
|
|
405
|
+
await this.statusEmbedMessage.edit({ embeds: [embed] });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
this.statusEmbedMessage = null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// Send to the agent's primary channel
|
|
413
|
+
if (this.resolvedChannelIds.length > 0) {
|
|
414
|
+
const channelId = this.resolvedChannelIds[0];
|
|
415
|
+
const channel = this.client.channels.cache.get(channelId);
|
|
416
|
+
if (channel && 'send' in channel) {
|
|
417
|
+
this.statusEmbedMessage = await channel.send({ embeds: [embed] });
|
|
418
|
+
try {
|
|
419
|
+
await this.statusEmbedMessage.pin();
|
|
420
|
+
}
|
|
421
|
+
catch { /* may lack perms */ }
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
logger.error({ err, slug: this.config.slug }, 'Failed to update agent status embed');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Receive an inter-agent team message. Posts an embed showing the incoming
|
|
431
|
+
* message, then triggers the agent to process and respond in-channel.
|
|
432
|
+
*/
|
|
433
|
+
/** Track recent team message content hashes to prevent duplicate embeds. */
|
|
434
|
+
recentTeamMessageHashes = new Map();
|
|
435
|
+
async receiveTeamMessage(fromName, fromSlug, content) {
|
|
436
|
+
if (this.resolvedChannelIds.length === 0) {
|
|
437
|
+
logger.warn({ slug: this.config.slug }, 'No channels to deliver team message to');
|
|
438
|
+
return '(no channels available)';
|
|
439
|
+
}
|
|
440
|
+
const channelId = this.resolvedChannelIds[0];
|
|
441
|
+
const channel = this.client.channels.cache.get(channelId);
|
|
442
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
443
|
+
logger.warn({ slug: this.config.slug, channelId }, 'Channel not found for team message delivery');
|
|
444
|
+
return '(channel not found)';
|
|
445
|
+
}
|
|
446
|
+
// Dedup: reject identical messages within 5 minutes (prevents embed spam)
|
|
447
|
+
const { createHash } = await import('node:crypto');
|
|
448
|
+
const contentHash = createHash('sha256').update(`${fromSlug}:${content.trim()}`).digest('hex').slice(0, 12);
|
|
449
|
+
const now = Date.now();
|
|
450
|
+
const lastSeen = this.recentTeamMessageHashes.get(contentHash) ?? 0;
|
|
451
|
+
if (now - lastSeen < 300_000) {
|
|
452
|
+
logger.info({ slug: this.config.slug, from: fromSlug }, 'Duplicate team message suppressed (already posted)');
|
|
453
|
+
return '(duplicate message suppressed — already delivered recently)';
|
|
454
|
+
}
|
|
455
|
+
this.recentTeamMessageHashes.set(contentHash, now);
|
|
456
|
+
// Prune old entries
|
|
457
|
+
if (this.recentTeamMessageHashes.size > 50) {
|
|
458
|
+
for (const [key, ts] of this.recentTeamMessageHashes) {
|
|
459
|
+
if (now - ts > 300_000)
|
|
460
|
+
this.recentTeamMessageHashes.delete(key);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
// Post the incoming message as an embed so it's visible in the channel
|
|
464
|
+
const embed = new EmbedBuilder()
|
|
465
|
+
.setColor(0x5865F2) // Discord blurple
|
|
466
|
+
.setAuthor({ name: `${fromName} via team message` })
|
|
467
|
+
.setDescription(content.length > 4096 ? content.slice(0, 4093) + '...' : content)
|
|
468
|
+
.setTimestamp();
|
|
469
|
+
await channel.send({ embeds: [embed] });
|
|
470
|
+
// Run the task through the unleashed pipeline — gives the agent full
|
|
471
|
+
// multi-phase autonomous execution instead of the 5-minute chat timeout.
|
|
472
|
+
const streamer = new DiscordStreamingMessage(channel);
|
|
473
|
+
await streamer.start();
|
|
474
|
+
try {
|
|
475
|
+
const response = await this.gateway.handleTeamTask(fromName, fromSlug, content, this.config.profile, async (token) => {
|
|
476
|
+
await streamer.update(token);
|
|
477
|
+
});
|
|
478
|
+
await streamer.finalize(response);
|
|
479
|
+
logger.info({ slug: this.config.slug, from: fromSlug }, 'Processed team message');
|
|
480
|
+
return response;
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
logger.error({ err, slug: this.config.slug }, 'Failed to process team message');
|
|
484
|
+
const errMsg = `Something went wrong processing a team message: ${sanitizeResponse(String(err))}`;
|
|
485
|
+
await streamer.finalize(errMsg);
|
|
486
|
+
return errMsg;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// ── Slash command + button interaction handler ──────────────────────
|
|
490
|
+
async handleInteraction(interaction) {
|
|
491
|
+
// ── Slash commands ──────────────────────────────────────────
|
|
492
|
+
if (interaction.isChatInputCommand()) {
|
|
493
|
+
const cmd = interaction;
|
|
494
|
+
// Access control: owner + allowedUsers
|
|
495
|
+
if (!this.isAuthorized(cmd.user.id)) {
|
|
496
|
+
await cmd.reply({ content: 'You don\'t have access to this agent.', ephemeral: true });
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const cmdPrefix = this.channelPrefix(cmd.user.id);
|
|
500
|
+
const cmdDmPrefix = this.dmPrefix(cmd.user.id);
|
|
501
|
+
const sessionKey = cmd.channel?.isDMBased()
|
|
502
|
+
? `${cmdDmPrefix}:${this.config.slug}:${cmd.user.id}`
|
|
503
|
+
: `${cmdPrefix}:${cmd.channelId}:${cmd.user.id}`;
|
|
504
|
+
// Set agent profile for this session
|
|
505
|
+
this.gateway.setSessionProfile(sessionKey, this.config.slug);
|
|
506
|
+
const name = cmd.commandName;
|
|
507
|
+
// /help
|
|
508
|
+
if (name === 'help') {
|
|
509
|
+
const agentName = this.config.profile.name;
|
|
510
|
+
await cmd.reply([
|
|
511
|
+
`**${agentName} Commands**`,
|
|
512
|
+
'`/plan <task>` — Break a task into parallel steps',
|
|
513
|
+
'`/deep <msg>` — Extended mode (100 turns)',
|
|
514
|
+
'`/quick <msg>` — Quick reply (Haiku) · `/opus <msg>` — Deep reply (Opus)',
|
|
515
|
+
'`/model [haiku|sonnet|opus]` — Switch default model',
|
|
516
|
+
'`/clear` — Reset conversation · `/help` — This message',
|
|
517
|
+
].join('\n'));
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
// /clear
|
|
521
|
+
if (name === 'clear') {
|
|
522
|
+
this.gateway.clearSession(sessionKey);
|
|
523
|
+
await cmd.reply('Session cleared.');
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
// /model
|
|
527
|
+
if (name === 'model') {
|
|
528
|
+
const tier = cmd.options.getString('tier', true);
|
|
529
|
+
const t = tier.toLowerCase();
|
|
530
|
+
if (t in MODELS) {
|
|
531
|
+
this.gateway.setSessionModel(sessionKey, MODELS[t]);
|
|
532
|
+
await cmd.reply(`Model switched to **${t}** (\`${MODELS[t]}\`).`);
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
const current = this.gateway.getSessionModel(sessionKey) ?? 'default';
|
|
536
|
+
await cmd.reply(`Current model: \`${current}\`\nOptions: /model haiku, /model sonnet, /model opus`);
|
|
537
|
+
}
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
// /plan — with approval buttons
|
|
541
|
+
if (name === 'plan') {
|
|
542
|
+
const task = cmd.options.getString('task', true);
|
|
543
|
+
await cmd.deferReply();
|
|
544
|
+
await cmd.editReply(`Planning: _${task.slice(0, 100)}_...`);
|
|
545
|
+
if (!cmd.channel) {
|
|
546
|
+
await cmd.editReply('Could not access channel for plan.');
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
const streamer = new DiscordStreamingMessage(cmd.channel);
|
|
550
|
+
await streamer.start();
|
|
551
|
+
await streamer.update('Planning...');
|
|
552
|
+
try {
|
|
553
|
+
const result = await this.gateway.handlePlan(sessionKey, task, async (updates) => {
|
|
554
|
+
const lines = [
|
|
555
|
+
`**Plan:** ${task.slice(0, 100)}`,
|
|
556
|
+
'',
|
|
557
|
+
...updates.map((u, i) => {
|
|
558
|
+
const num = `[${i + 1}/${updates.length}]`;
|
|
559
|
+
const desc = u.description.slice(0, 60);
|
|
560
|
+
switch (u.status) {
|
|
561
|
+
case 'done': return `${num} ${desc} \u2713 (${Math.round((u.durationMs ?? 0) / 1000)}s)`;
|
|
562
|
+
case 'running': return `${num} ${desc} \u23f3 running...`;
|
|
563
|
+
case 'failed': return `${num} ${desc} \u2717 failed`;
|
|
564
|
+
default: return `${num} ${desc} \u25cb waiting`;
|
|
565
|
+
}
|
|
566
|
+
}),
|
|
567
|
+
];
|
|
568
|
+
await streamer.update(lines.join('\n').slice(0, 1800));
|
|
569
|
+
}, async (_planSummary, steps) => {
|
|
570
|
+
const planPreview = `**Plan:** ${task.slice(0, 100)}\n\n` +
|
|
571
|
+
steps.map((s, i) => `${i + 1}. **${s.id}** — ${s.description.slice(0, 60)}`).join('\n');
|
|
572
|
+
if ('send' in cmd.channel) {
|
|
573
|
+
await sendChunked(cmd.channel, planPreview);
|
|
574
|
+
}
|
|
575
|
+
// Send approval buttons
|
|
576
|
+
const requestId = `plan-${Date.now()}`;
|
|
577
|
+
const buttons = [
|
|
578
|
+
{ type: 2, style: 3, label: 'Approve', custom_id: `plan_${requestId}_approve` },
|
|
579
|
+
{ type: 2, style: 1, label: 'Revise', custom_id: `plan_${requestId}_revise` },
|
|
580
|
+
{ type: 2, style: 4, label: 'Cancel', custom_id: `plan_${requestId}_deny` },
|
|
581
|
+
];
|
|
582
|
+
if ('send' in cmd.channel) {
|
|
583
|
+
await cmd.channel.send({
|
|
584
|
+
content: 'Approve this plan?',
|
|
585
|
+
components: [{ type: 1, components: buttons }],
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
const approvalResult = await this.gateway.requestApproval('Pending approval', requestId);
|
|
589
|
+
if (typeof approvalResult === 'string') {
|
|
590
|
+
if ('send' in cmd.channel) {
|
|
591
|
+
await cmd.channel.send('\u2728 *Revising plan...*');
|
|
592
|
+
}
|
|
593
|
+
return approvalResult;
|
|
594
|
+
}
|
|
595
|
+
if (approvalResult) {
|
|
596
|
+
const newStreamer = new DiscordStreamingMessage(cmd.channel);
|
|
597
|
+
await newStreamer.start();
|
|
598
|
+
await newStreamer.update('Executing plan...');
|
|
599
|
+
Object.assign(streamer, {
|
|
600
|
+
message: newStreamer.message,
|
|
601
|
+
lastEdit: newStreamer.lastEdit,
|
|
602
|
+
pendingText: '',
|
|
603
|
+
lastFlushedText: '',
|
|
604
|
+
isFinal: false,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
return approvalResult;
|
|
608
|
+
});
|
|
609
|
+
await streamer.finalize(result);
|
|
610
|
+
}
|
|
611
|
+
catch (err) {
|
|
612
|
+
logger.error({ err, slug: this.config.slug }, '/plan command failed');
|
|
613
|
+
await streamer.finalize(`Plan failed: ${err}`);
|
|
614
|
+
}
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
// /deep, /quick, /opus — chat with model override
|
|
618
|
+
if (name === 'deep' || name === 'quick' || name === 'opus') {
|
|
619
|
+
const msg = cmd.options.getString('message', true);
|
|
620
|
+
const oneOffModel = name === 'quick' ? MODELS.haiku : name === 'opus' ? MODELS.opus : undefined;
|
|
621
|
+
const oneOffMaxTurns = name === 'deep' ? 100 : undefined;
|
|
622
|
+
await cmd.deferReply();
|
|
623
|
+
try {
|
|
624
|
+
const response = await this.gateway.handleMessage(sessionKey, msg, async () => { }, oneOffModel, oneOffMaxTurns);
|
|
625
|
+
const chunks = chunkText(response || '*(no response)*', 1900);
|
|
626
|
+
await cmd.editReply(chunks[0]);
|
|
627
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
628
|
+
await cmd.followUp(chunks[i]);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
logger.error({ err, slug: this.config.slug }, `/${name} command failed`);
|
|
633
|
+
await cmd.editReply(`Something went wrong: ${err}`);
|
|
634
|
+
}
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
// ── Button interactions (plan approve/deny/revise) ──────────
|
|
640
|
+
if (interaction.isButton()) {
|
|
641
|
+
const button = interaction;
|
|
642
|
+
const customId = button.customId;
|
|
643
|
+
// Access control: owner + allowedUsers
|
|
644
|
+
if (!this.isAuthorized(button.user.id)) {
|
|
645
|
+
await button.reply({ content: 'You don\'t have access to this agent.', ephemeral: true });
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
// Plan approval buttons: plan_{requestId}_{action}
|
|
649
|
+
const planMatch = customId.match(/^plan_(.+)_(approve|deny|revise)$/);
|
|
650
|
+
if (planMatch) {
|
|
651
|
+
const [, requestId, action] = planMatch;
|
|
652
|
+
if (action === 'approve') {
|
|
653
|
+
await button.deferUpdate();
|
|
654
|
+
this.gateway.resolveApproval(requestId, true);
|
|
655
|
+
}
|
|
656
|
+
else if (action === 'deny') {
|
|
657
|
+
await button.deferUpdate();
|
|
658
|
+
this.gateway.resolveApproval(requestId, false);
|
|
659
|
+
}
|
|
660
|
+
else if (action === 'revise') {
|
|
661
|
+
// Show modal for revision feedback
|
|
662
|
+
const modal = new ModalBuilder()
|
|
663
|
+
.setCustomId(`revise_modal_${requestId}`)
|
|
664
|
+
.setTitle('Revise Plan');
|
|
665
|
+
const input = new TextInputBuilder()
|
|
666
|
+
.setCustomId('revision_feedback')
|
|
667
|
+
.setLabel('What should be changed?')
|
|
668
|
+
.setStyle(TextInputStyle.Paragraph)
|
|
669
|
+
.setRequired(true);
|
|
670
|
+
modal.addComponents(new ActionRowBuilder().addComponents(input));
|
|
671
|
+
await button.showModal(modal);
|
|
672
|
+
}
|
|
673
|
+
// Disable buttons after click
|
|
674
|
+
try {
|
|
675
|
+
if (button.message) {
|
|
676
|
+
const rawComponents = button.message.components.map((row) => ({
|
|
677
|
+
type: 1,
|
|
678
|
+
components: (row.components ?? []).map((comp) => ({
|
|
679
|
+
type: comp.type ?? 2,
|
|
680
|
+
style: comp.style,
|
|
681
|
+
label: comp.label,
|
|
682
|
+
custom_id: comp.customId ?? comp.custom_id,
|
|
683
|
+
disabled: true,
|
|
684
|
+
})),
|
|
685
|
+
}));
|
|
686
|
+
await button.editReply({
|
|
687
|
+
content: button.message.content + `\n\n${action === 'approve' ? '\u2705 Approved' : action === 'deny' ? '\u274c Cancelled' : '\u270f\ufe0f Revising'}`,
|
|
688
|
+
components: rawComponents,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
catch { /* non-fatal */ }
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
// ── Modal submissions (revision feedback) ────────────────────
|
|
697
|
+
if (interaction.isModalSubmit()) {
|
|
698
|
+
const modal = interaction;
|
|
699
|
+
if (modal.customId.startsWith('revise_modal_')) {
|
|
700
|
+
const requestId = modal.customId.replace('revise_modal_', '');
|
|
701
|
+
const feedback = modal.fields.getTextInputValue('revision_feedback');
|
|
702
|
+
await modal.deferUpdate();
|
|
703
|
+
this.gateway.resolveApproval(requestId, feedback);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
/** Check if this bot participates in a shared team chat channel. */
|
|
708
|
+
isTeamChat() {
|
|
709
|
+
return this.config.profile.team?.teamChat === true;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Check if this agent is being addressed in a team chat message.
|
|
713
|
+
* Matches: @mention, agent name, agent slug, or broadcast keywords.
|
|
714
|
+
*/
|
|
715
|
+
isAddressedInTeamChat(message) {
|
|
716
|
+
// Direct @mention of this bot
|
|
717
|
+
if (this.client.user && message.mentions.users.has(this.client.user.id)) {
|
|
718
|
+
return true;
|
|
719
|
+
}
|
|
720
|
+
// @everyone and @here Discord mentions address all agents
|
|
721
|
+
if (message.mentions.everyone) {
|
|
722
|
+
return true;
|
|
723
|
+
}
|
|
724
|
+
const content = message.content.toLowerCase();
|
|
725
|
+
// Broadcast keywords — address all agents at once
|
|
726
|
+
const broadcastPatterns = [
|
|
727
|
+
/\b@?team\b/,
|
|
728
|
+
/\beveryone\b/,
|
|
729
|
+
/\ball\s+agents?\b/,
|
|
730
|
+
/\bthe\s+team\b/,
|
|
731
|
+
];
|
|
732
|
+
if (broadcastPatterns.some(p => p.test(content))) {
|
|
733
|
+
return true;
|
|
734
|
+
}
|
|
735
|
+
// Individual agent name or slug at word boundaries
|
|
736
|
+
const name = this.config.profile.name.toLowerCase();
|
|
737
|
+
const slug = this.config.slug.toLowerCase();
|
|
738
|
+
const namePattern = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
|
|
739
|
+
const slugPattern = new RegExp(`\\b${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
|
|
740
|
+
return namePattern.test(content) || slugPattern.test(content);
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Collect recent messages from other bots in the same channel for context.
|
|
744
|
+
* Returns a formatted string of the last N messages from other agents.
|
|
745
|
+
*/
|
|
746
|
+
async gatherTeamChatContext(message, limit = 10) {
|
|
747
|
+
try {
|
|
748
|
+
const channel = message.channel;
|
|
749
|
+
if (channel.isDMBased())
|
|
750
|
+
return '';
|
|
751
|
+
const recent = await channel.messages.fetch({ limit: limit + 1, before: message.id });
|
|
752
|
+
const contextLines = [];
|
|
753
|
+
for (const msg of recent.sort((a, b) => a.createdTimestamp - b.createdTimestamp).values()) {
|
|
754
|
+
const authorName = msg.author.bot ? msg.author.username : 'Owner';
|
|
755
|
+
const preview = msg.content.slice(0, 300);
|
|
756
|
+
if (preview) {
|
|
757
|
+
contextLines.push(`[${authorName}]: ${preview}`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
if (contextLines.length === 0)
|
|
761
|
+
return '';
|
|
762
|
+
return `\n\n[Recent team chat context]\n${contextLines.join('\n')}\n[End context]`;
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
return ''; // Non-fatal — proceed without context
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
async handleMessage(message) {
|
|
769
|
+
// Ignore own messages
|
|
770
|
+
if (message.author.id === this.client.user?.id)
|
|
771
|
+
return;
|
|
772
|
+
const isDm = message.channel.isDMBased();
|
|
773
|
+
const isWatchedChannel = !isDm && this.resolvedChannelIds.includes(message.channelId);
|
|
774
|
+
// Respond in DMs or watched channels
|
|
775
|
+
if (!isDm && !isWatchedChannel)
|
|
776
|
+
return;
|
|
777
|
+
const isTeamChatChannel = isWatchedChannel && this.isTeamChat();
|
|
778
|
+
// In team chat: ignore all bot messages (prevents loops).
|
|
779
|
+
// In solo channels: ignore all bot messages (original behavior).
|
|
780
|
+
if (message.author.bot)
|
|
781
|
+
return;
|
|
782
|
+
// Access control: owner + allowedUsers
|
|
783
|
+
if (!this.isAuthorized(message.author.id)) {
|
|
784
|
+
logger.debug({ slug: this.config.slug, author: message.author.tag }, 'Ignored message from unauthorized user');
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
// In team chat: respond to all if respondToAll is set, otherwise only when addressed
|
|
788
|
+
const respondToAll = this.config.profile.team?.respondToAll === true;
|
|
789
|
+
if (isTeamChatChannel && !respondToAll && !this.isAddressedInTeamChat(message)) {
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
// Extract attachments
|
|
793
|
+
let text = message.content;
|
|
794
|
+
if (message.attachments.size > 0) {
|
|
795
|
+
const attachmentLines = message.attachments.map(att => {
|
|
796
|
+
if (att.contentType?.startsWith('image/')) {
|
|
797
|
+
return `[Image attached: ${att.name} (${att.url})]`;
|
|
798
|
+
}
|
|
799
|
+
return `[File attached: ${att.name}, ${att.contentType || 'unknown type'}, ${att.url}]`;
|
|
800
|
+
});
|
|
801
|
+
text = attachmentLines.join('\n') + (text ? '\n' + text : '');
|
|
802
|
+
}
|
|
803
|
+
if (!text)
|
|
804
|
+
return;
|
|
805
|
+
// !dashboard command — show agent-scoped status embed
|
|
806
|
+
if (text === '!dashboard') {
|
|
807
|
+
if (this.config.cronScheduler) {
|
|
808
|
+
// Unpin old, send fresh, pin new
|
|
809
|
+
if (this.statusEmbedMessage) {
|
|
810
|
+
try {
|
|
811
|
+
await this.statusEmbedMessage.unpin();
|
|
812
|
+
}
|
|
813
|
+
catch { /* non-fatal */ }
|
|
814
|
+
}
|
|
815
|
+
const embed = this.buildAgentStatusEmbed();
|
|
816
|
+
if ('send' in message.channel) {
|
|
817
|
+
this.statusEmbedMessage = await message.channel.send({ embeds: [embed] });
|
|
818
|
+
try {
|
|
819
|
+
await this.statusEmbedMessage.pin();
|
|
820
|
+
}
|
|
821
|
+
catch { /* non-fatal */ }
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
await message.reply('Status dashboard unavailable (no scheduler connected).');
|
|
826
|
+
}
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
// !clear command
|
|
830
|
+
if (text === '!clear') {
|
|
831
|
+
const prefix = this.channelPrefix(message.author.id);
|
|
832
|
+
const dmPfx = this.dmPrefix(message.author.id);
|
|
833
|
+
const sessionKey = isDm
|
|
834
|
+
? `${dmPfx}:${this.config.slug}:${message.author.id}`
|
|
835
|
+
: `${prefix}:${message.channelId}:${message.author.id}`;
|
|
836
|
+
this.gateway.clearSession(sessionKey);
|
|
837
|
+
await message.reply('Session cleared.');
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
// In team chat, use agent-scoped session key so each agent has its own
|
|
841
|
+
// conversation memory in the shared channel
|
|
842
|
+
const prefix = this.channelPrefix(message.author.id);
|
|
843
|
+
const dmPfx = this.dmPrefix(message.author.id);
|
|
844
|
+
const sessionKey = isDm
|
|
845
|
+
? `${dmPfx}:${this.config.slug}:${message.author.id}`
|
|
846
|
+
: isTeamChatChannel
|
|
847
|
+
? `${prefix}:${message.channelId}:${this.config.slug}:${message.author.id}`
|
|
848
|
+
: `${prefix}:${message.channelId}:${message.author.id}`;
|
|
849
|
+
// Set the agent profile for this session
|
|
850
|
+
this.gateway.setSessionProfile(sessionKey, this.config.slug);
|
|
851
|
+
// Show queued indicator if session is busy
|
|
852
|
+
if (this.gateway.isSessionBusy(sessionKey)) {
|
|
853
|
+
await message.react('\u23f3'); // hourglass
|
|
854
|
+
}
|
|
855
|
+
// In team chat, gather recent messages from other agents as context
|
|
856
|
+
if (isTeamChatChannel) {
|
|
857
|
+
const teamContext = await this.gatherTeamChatContext(message);
|
|
858
|
+
if (teamContext) {
|
|
859
|
+
text += teamContext;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
// Stream response as the bot's own identity
|
|
863
|
+
const streamer = new DiscordStreamingMessage(message.channel);
|
|
864
|
+
await streamer.start();
|
|
865
|
+
try {
|
|
866
|
+
const response = await this.gateway.handleMessage(sessionKey, text, async (token) => {
|
|
867
|
+
await streamer.update(token);
|
|
868
|
+
}, undefined, // model
|
|
869
|
+
undefined, // maxTurns
|
|
870
|
+
async (toolName, toolInput) => {
|
|
871
|
+
streamer.setToolStatus(friendlyToolName(toolName, toolInput));
|
|
872
|
+
});
|
|
873
|
+
await streamer.finalize(response);
|
|
874
|
+
}
|
|
875
|
+
catch (err) {
|
|
876
|
+
logger.error({ err, slug: this.config.slug }, 'Agent bot message handling error');
|
|
877
|
+
await streamer.finalize(`Something went wrong: ${sanitizeResponse(String(err))}`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
//# sourceMappingURL=discord-agent-bot.js.map
|