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,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Discord bot manager.
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates the lifecycle of agent bot clients. Agents with a
|
|
5
|
+
* `discordToken` in their profile get their own dedicated discord.js Client.
|
|
6
|
+
* Bots discover their channels on connect (by name, explicit ID, or fallback
|
|
7
|
+
* to all visible).
|
|
8
|
+
*/
|
|
9
|
+
import type { Gateway } from '../gateway/router.js';
|
|
10
|
+
import type { CronScheduler } from '../gateway/heartbeat.js';
|
|
11
|
+
import { type AgentBotStatus } from './discord-agent-bot.js';
|
|
12
|
+
export interface BotManagerConfig {
|
|
13
|
+
gateway: Gateway;
|
|
14
|
+
ownerId: string;
|
|
15
|
+
cronScheduler?: CronScheduler;
|
|
16
|
+
statusFilePath?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface BotStatus {
|
|
19
|
+
slug: string;
|
|
20
|
+
status: AgentBotStatus;
|
|
21
|
+
botTag?: string;
|
|
22
|
+
avatarUrl?: string;
|
|
23
|
+
channelIds: string[];
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare class BotManager {
|
|
27
|
+
private bots;
|
|
28
|
+
private gateway;
|
|
29
|
+
private ownerId;
|
|
30
|
+
private cronScheduler?;
|
|
31
|
+
private statusFilePath;
|
|
32
|
+
private pollInterval?;
|
|
33
|
+
private statusInterval?;
|
|
34
|
+
constructor(config: BotManagerConfig);
|
|
35
|
+
/**
|
|
36
|
+
* Scan all agents for discordToken, start bots, return owned channel IDs.
|
|
37
|
+
* Channel IDs are resolved AFTER bots connect (auto-discovery).
|
|
38
|
+
*/
|
|
39
|
+
startAll(): Promise<string[]>;
|
|
40
|
+
startBot(slug: string): Promise<void>;
|
|
41
|
+
stopBot(slug: string): Promise<void>;
|
|
42
|
+
stopAll(): Promise<void>;
|
|
43
|
+
getStatuses(): Map<string, BotStatus>;
|
|
44
|
+
/**
|
|
45
|
+
* Get all channel IDs managed by agent bots (main bot should NOT watch these).
|
|
46
|
+
* Includes both exclusive channels and team chat channels — the main bot
|
|
47
|
+
* should not respond in either type.
|
|
48
|
+
*/
|
|
49
|
+
getOwnedChannelIds(): string[];
|
|
50
|
+
/** Get channel IDs that are shared team chat channels (multiple agents listen). */
|
|
51
|
+
getTeamChatChannelIds(): string[];
|
|
52
|
+
/** Get the primary channel ID for a specific agent bot (for team message delivery). */
|
|
53
|
+
getChannelForAgent(slug: string): string | null;
|
|
54
|
+
/** Reverse lookup: which agent slug owns a given channel ID? */
|
|
55
|
+
getAgentForChannel(channelId: string): string | null;
|
|
56
|
+
/** Get the owner ID (used for building session keys). */
|
|
57
|
+
getOwnerId(): string;
|
|
58
|
+
/** Check if an agent has a running bot. */
|
|
59
|
+
hasBot(slug: string): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Send a notification through an agent's bot (DM to owner).
|
|
62
|
+
* Uses embed formatting for cron-style messages, plain text for others.
|
|
63
|
+
* Throws if the bot is unavailable — callers should fall back to main bot.
|
|
64
|
+
*/
|
|
65
|
+
sendAsAgent(slug: string, text: string): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Deliver a team message to an agent's bot — posts the message visibly
|
|
68
|
+
* in the bot's channel and triggers the agent to process and respond.
|
|
69
|
+
* Returns the agent's response text, or null if delivery failed.
|
|
70
|
+
*/
|
|
71
|
+
deliverTeamMessage(toSlug: string, fromName: string, fromSlug: string, content: string): Promise<string | null>;
|
|
72
|
+
/**
|
|
73
|
+
* Poll for new/removed agents with discordToken at the given interval.
|
|
74
|
+
*/
|
|
75
|
+
startPolling(intervalMs: number): void;
|
|
76
|
+
private pollForChanges;
|
|
77
|
+
/** Write status to disk so the dashboard can read it. */
|
|
78
|
+
private startStatusWriter;
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=discord-bot-manager.d.ts.map
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Discord bot manager.
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates the lifecycle of agent bot clients. Agents with a
|
|
5
|
+
* `discordToken` in their profile get their own dedicated discord.js Client.
|
|
6
|
+
* Bots discover their channels on connect (by name, explicit ID, or fallback
|
|
7
|
+
* to all visible).
|
|
8
|
+
*/
|
|
9
|
+
import { writeFileSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import pino from 'pino';
|
|
12
|
+
import { AgentBotClient } from './discord-agent-bot.js';
|
|
13
|
+
import { formatCronEmbed } from './discord-utils.js';
|
|
14
|
+
const logger = pino({ name: 'clementine.bot-manager' });
|
|
15
|
+
export class BotManager {
|
|
16
|
+
bots = new Map();
|
|
17
|
+
gateway;
|
|
18
|
+
ownerId;
|
|
19
|
+
cronScheduler;
|
|
20
|
+
statusFilePath;
|
|
21
|
+
pollInterval;
|
|
22
|
+
statusInterval;
|
|
23
|
+
constructor(config) {
|
|
24
|
+
this.gateway = config.gateway;
|
|
25
|
+
this.ownerId = config.ownerId;
|
|
26
|
+
this.cronScheduler = config.cronScheduler;
|
|
27
|
+
this.statusFilePath = config.statusFilePath ??
|
|
28
|
+
path.join(process.env.CLEMENTINE_HOME || path.join(process.env.HOME || '', '.clementine'), '.bot-status.json');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Scan all agents for discordToken, start bots, return owned channel IDs.
|
|
32
|
+
* Channel IDs are resolved AFTER bots connect (auto-discovery).
|
|
33
|
+
*/
|
|
34
|
+
async startAll() {
|
|
35
|
+
const mgr = this.gateway.getAgentManager();
|
|
36
|
+
const allAgents = mgr.listAll();
|
|
37
|
+
logger.info({ agentCount: allAgents.length }, 'Scanning agents for discordToken');
|
|
38
|
+
for (const agent of allAgents) {
|
|
39
|
+
logger.info({ slug: agent.slug, hasToken: Boolean(agent.discordToken) }, 'Checking agent');
|
|
40
|
+
if (!agent.discordToken)
|
|
41
|
+
continue;
|
|
42
|
+
try {
|
|
43
|
+
await this.startBot(agent.slug);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
logger.error({ err, slug: agent.slug }, 'Failed to start agent bot');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Start status file writer
|
|
50
|
+
this.startStatusWriter();
|
|
51
|
+
// Return owned channel IDs (resolved after bot connect)
|
|
52
|
+
return this.getOwnedChannelIds();
|
|
53
|
+
}
|
|
54
|
+
async startBot(slug) {
|
|
55
|
+
// If already running, stop first
|
|
56
|
+
if (this.bots.has(slug)) {
|
|
57
|
+
await this.stopBot(slug);
|
|
58
|
+
}
|
|
59
|
+
const mgr = this.gateway.getAgentManager();
|
|
60
|
+
const profile = mgr.get(slug);
|
|
61
|
+
if (!profile) {
|
|
62
|
+
throw new Error(`Agent '${slug}' not found`);
|
|
63
|
+
}
|
|
64
|
+
if (!profile.discordToken) {
|
|
65
|
+
throw new Error(`Agent '${slug}' has no discordToken`);
|
|
66
|
+
}
|
|
67
|
+
// Build channel IDs from explicit config (auto-discovery happens on connect)
|
|
68
|
+
const explicitChannelIds = profile.discordChannelId
|
|
69
|
+
? [profile.discordChannelId]
|
|
70
|
+
: undefined;
|
|
71
|
+
const bot = new AgentBotClient({
|
|
72
|
+
slug,
|
|
73
|
+
token: profile.discordToken,
|
|
74
|
+
ownerId: this.ownerId,
|
|
75
|
+
profile,
|
|
76
|
+
channelIds: explicitChannelIds,
|
|
77
|
+
cronScheduler: this.cronScheduler,
|
|
78
|
+
allowedUsers: profile.team?.allowedUsers,
|
|
79
|
+
}, this.gateway);
|
|
80
|
+
await bot.start();
|
|
81
|
+
this.bots.set(slug, bot);
|
|
82
|
+
logger.info({ slug }, 'Agent bot started');
|
|
83
|
+
}
|
|
84
|
+
async stopBot(slug) {
|
|
85
|
+
const bot = this.bots.get(slug);
|
|
86
|
+
if (!bot)
|
|
87
|
+
return;
|
|
88
|
+
await bot.stop();
|
|
89
|
+
this.bots.delete(slug);
|
|
90
|
+
}
|
|
91
|
+
async stopAll() {
|
|
92
|
+
const slugs = [...this.bots.keys()];
|
|
93
|
+
await Promise.all(slugs.map(slug => this.stopBot(slug)));
|
|
94
|
+
if (this.pollInterval) {
|
|
95
|
+
clearInterval(this.pollInterval);
|
|
96
|
+
this.pollInterval = undefined;
|
|
97
|
+
}
|
|
98
|
+
if (this.statusInterval) {
|
|
99
|
+
clearInterval(this.statusInterval);
|
|
100
|
+
this.statusInterval = undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
getStatuses() {
|
|
104
|
+
const result = new Map();
|
|
105
|
+
for (const [slug, bot] of this.bots) {
|
|
106
|
+
const s = bot.getStatus();
|
|
107
|
+
result.set(slug, {
|
|
108
|
+
slug,
|
|
109
|
+
status: s.status,
|
|
110
|
+
botTag: s.botTag,
|
|
111
|
+
avatarUrl: s.avatarUrl,
|
|
112
|
+
channelIds: bot.getChannelIds(),
|
|
113
|
+
error: s.error,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Get all channel IDs managed by agent bots (main bot should NOT watch these).
|
|
120
|
+
* Includes both exclusive channels and team chat channels — the main bot
|
|
121
|
+
* should not respond in either type.
|
|
122
|
+
*/
|
|
123
|
+
getOwnedChannelIds() {
|
|
124
|
+
const ids = [];
|
|
125
|
+
for (const bot of this.bots.values()) {
|
|
126
|
+
ids.push(...bot.getChannelIds());
|
|
127
|
+
}
|
|
128
|
+
return ids;
|
|
129
|
+
}
|
|
130
|
+
/** Get channel IDs that are shared team chat channels (multiple agents listen). */
|
|
131
|
+
getTeamChatChannelIds() {
|
|
132
|
+
const ids = [];
|
|
133
|
+
for (const bot of this.bots.values()) {
|
|
134
|
+
if (bot.isTeamChat()) {
|
|
135
|
+
ids.push(...bot.getChannelIds());
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return [...new Set(ids)]; // deduplicate
|
|
139
|
+
}
|
|
140
|
+
/** Get the primary channel ID for a specific agent bot (for team message delivery). */
|
|
141
|
+
getChannelForAgent(slug) {
|
|
142
|
+
const bot = this.bots.get(slug);
|
|
143
|
+
if (!bot)
|
|
144
|
+
return null;
|
|
145
|
+
const channels = bot.getChannelIds();
|
|
146
|
+
return channels[0] ?? null;
|
|
147
|
+
}
|
|
148
|
+
/** Reverse lookup: which agent slug owns a given channel ID? */
|
|
149
|
+
getAgentForChannel(channelId) {
|
|
150
|
+
for (const [slug, bot] of this.bots) {
|
|
151
|
+
if (bot.getChannelIds().includes(channelId))
|
|
152
|
+
return slug;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
/** Get the owner ID (used for building session keys). */
|
|
157
|
+
getOwnerId() {
|
|
158
|
+
return this.ownerId;
|
|
159
|
+
}
|
|
160
|
+
/** Check if an agent has a running bot. */
|
|
161
|
+
hasBot(slug) {
|
|
162
|
+
return this.bots.has(slug);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Send a notification through an agent's bot (DM to owner).
|
|
166
|
+
* Uses embed formatting for cron-style messages, plain text for others.
|
|
167
|
+
* Throws if the bot is unavailable — callers should fall back to main bot.
|
|
168
|
+
*/
|
|
169
|
+
async sendAsAgent(slug, text) {
|
|
170
|
+
const bot = this.bots.get(slug);
|
|
171
|
+
if (!bot)
|
|
172
|
+
throw new Error(`No bot for agent '${slug}'`);
|
|
173
|
+
const status = bot.getStatus();
|
|
174
|
+
if (status.status !== 'online')
|
|
175
|
+
throw new Error(`Bot '${slug}' is ${status.status}`);
|
|
176
|
+
const embed = formatCronEmbed(text);
|
|
177
|
+
await bot.sendNotification(text, embed ?? undefined);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Deliver a team message to an agent's bot — posts the message visibly
|
|
181
|
+
* in the bot's channel and triggers the agent to process and respond.
|
|
182
|
+
* Returns the agent's response text, or null if delivery failed.
|
|
183
|
+
*/
|
|
184
|
+
async deliverTeamMessage(toSlug, fromName, fromSlug, content) {
|
|
185
|
+
const bot = this.bots.get(toSlug);
|
|
186
|
+
if (!bot)
|
|
187
|
+
return null;
|
|
188
|
+
try {
|
|
189
|
+
return await bot.receiveTeamMessage(fromName, fromSlug, content);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
logger.error({ err, toSlug, fromSlug }, 'Failed to deliver team message via bot');
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Poll for new/removed agents with discordToken at the given interval.
|
|
198
|
+
*/
|
|
199
|
+
startPolling(intervalMs) {
|
|
200
|
+
if (this.pollInterval)
|
|
201
|
+
clearInterval(this.pollInterval);
|
|
202
|
+
this.pollInterval = setInterval(async () => {
|
|
203
|
+
try {
|
|
204
|
+
await this.pollForChanges();
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
logger.error({ err }, 'Bot polling error');
|
|
208
|
+
}
|
|
209
|
+
}, intervalMs);
|
|
210
|
+
}
|
|
211
|
+
async pollForChanges() {
|
|
212
|
+
const mgr = this.gateway.getAgentManager();
|
|
213
|
+
mgr.invalidateCache();
|
|
214
|
+
const allAgents = mgr.listAll();
|
|
215
|
+
// Find agents that should have bots (just need a discordToken)
|
|
216
|
+
const shouldHaveBot = new Set();
|
|
217
|
+
for (const agent of allAgents) {
|
|
218
|
+
if (agent.discordToken) {
|
|
219
|
+
shouldHaveBot.add(agent.slug);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// Start new bots
|
|
223
|
+
for (const slug of shouldHaveBot) {
|
|
224
|
+
if (!this.bots.has(slug)) {
|
|
225
|
+
logger.info({ slug }, 'Detected new agent with discordToken — starting bot');
|
|
226
|
+
try {
|
|
227
|
+
await this.startBot(slug);
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
logger.error({ err, slug }, 'Failed to start new agent bot');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Stop removed bots
|
|
235
|
+
for (const slug of this.bots.keys()) {
|
|
236
|
+
if (!shouldHaveBot.has(slug)) {
|
|
237
|
+
logger.info({ slug }, 'Agent no longer has discordToken — stopping bot');
|
|
238
|
+
await this.stopBot(slug);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** Write status to disk so the dashboard can read it. */
|
|
243
|
+
startStatusWriter() {
|
|
244
|
+
if (this.statusInterval)
|
|
245
|
+
clearInterval(this.statusInterval);
|
|
246
|
+
const writeStatus = () => {
|
|
247
|
+
try {
|
|
248
|
+
const statuses = {};
|
|
249
|
+
for (const [slug, status] of this.getStatuses()) {
|
|
250
|
+
statuses[slug] = status;
|
|
251
|
+
}
|
|
252
|
+
writeFileSync(this.statusFilePath, JSON.stringify(statuses, null, 2));
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// Non-fatal
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
writeStatus();
|
|
259
|
+
this.statusInterval = setInterval(writeStatus, 10_000);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
//# sourceMappingURL=discord-bot-manager.js.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Shared Discord utilities.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from discord.ts so agent bot clients can reuse streaming,
|
|
5
|
+
* chunking, and sanitization without importing the monolith.
|
|
6
|
+
*/
|
|
7
|
+
import { EmbedBuilder, type Message } from 'discord.js';
|
|
8
|
+
export declare const STREAM_EDIT_INTERVAL = 400;
|
|
9
|
+
export declare const THINKING_INDICATOR = "\u2728 *thinking...*";
|
|
10
|
+
export declare const DISCORD_MSG_LIMIT = 2000;
|
|
11
|
+
export declare function sanitizeResponse(text: string): string;
|
|
12
|
+
export declare function chunkText(text: string, maxLen?: number): string[];
|
|
13
|
+
export declare function sendChunked(channel: Message['channel'], text: string): Promise<void>;
|
|
14
|
+
export declare function friendlyToolName(name: string, input?: Record<string, unknown>): string;
|
|
15
|
+
export declare class DiscordStreamingMessage {
|
|
16
|
+
private message;
|
|
17
|
+
private lastEdit;
|
|
18
|
+
private pendingText;
|
|
19
|
+
private lastFlushedText;
|
|
20
|
+
private isFinal;
|
|
21
|
+
private channel;
|
|
22
|
+
private flushTimer;
|
|
23
|
+
private progressTimer;
|
|
24
|
+
private toolStatus;
|
|
25
|
+
private startTime;
|
|
26
|
+
private toolCallCount;
|
|
27
|
+
private lastTextTime;
|
|
28
|
+
/** The message ID of the final bot response (available after finalize). */
|
|
29
|
+
messageId: string | null;
|
|
30
|
+
constructor(channel: Message['channel']);
|
|
31
|
+
start(): Promise<void>;
|
|
32
|
+
/** Update the tool activity status line shown during streaming. */
|
|
33
|
+
setToolStatus(status: string): void;
|
|
34
|
+
update(text: string): Promise<void>;
|
|
35
|
+
finalize(text: string): Promise<void>;
|
|
36
|
+
/** Format elapsed milliseconds as human-readable duration. */
|
|
37
|
+
private formatElapsed;
|
|
38
|
+
private flush;
|
|
39
|
+
}
|
|
40
|
+
export type CronEmbedType = 'success' | 'progress' | 'error';
|
|
41
|
+
/**
|
|
42
|
+
* Detect whether a notification message looks like a cron result.
|
|
43
|
+
* Returns the embed type if so, or null for non-cron messages.
|
|
44
|
+
*/
|
|
45
|
+
export declare function detectCronType(text: string): CronEmbedType | null;
|
|
46
|
+
/**
|
|
47
|
+
* Format a cron notification as a Discord embed.
|
|
48
|
+
* Returns null if the text doesn't look like a cron message.
|
|
49
|
+
*/
|
|
50
|
+
export declare function formatCronEmbed(text: string): EmbedBuilder | null;
|
|
51
|
+
//# sourceMappingURL=discord-utils.d.ts.map
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Shared Discord utilities.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from discord.ts so agent bot clients can reuse streaming,
|
|
5
|
+
* chunking, and sanitization without importing the monolith.
|
|
6
|
+
*/
|
|
7
|
+
import { EmbedBuilder } from 'discord.js';
|
|
8
|
+
export const STREAM_EDIT_INTERVAL = 400;
|
|
9
|
+
export const THINKING_INDICATOR = '\u2728 *thinking...*';
|
|
10
|
+
export const DISCORD_MSG_LIMIT = 2000;
|
|
11
|
+
// ── Credential sanitisation ───────────────────────────────────────────
|
|
12
|
+
export function sanitizeResponse(text) {
|
|
13
|
+
// Discord tokens
|
|
14
|
+
text = text.replace(/[MN][A-Za-z0-9]{23,}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}/g, '[REDACTED_TOKEN]');
|
|
15
|
+
// API keys (Anthropic/OpenAI style)
|
|
16
|
+
text = text.replace(/sk-[A-Za-z0-9]{20,}/g, '[REDACTED_KEY]');
|
|
17
|
+
// GitHub PATs
|
|
18
|
+
text = text.replace(/ghp_[A-Za-z0-9]{36}/g, '[REDACTED_TOKEN]');
|
|
19
|
+
// Slack bot tokens
|
|
20
|
+
text = text.replace(/xoxb-[0-9]+-[A-Za-z0-9-]+/g, '[REDACTED_TOKEN]');
|
|
21
|
+
// Generic key/secret/token/password values
|
|
22
|
+
text = text.replace(/((?:token|key|secret|password)[=: ]{1,3})\S{20,}/gi, '$1[REDACTED]');
|
|
23
|
+
return text;
|
|
24
|
+
}
|
|
25
|
+
// ── Chunked sending ───────────────────────────────────────────────────
|
|
26
|
+
export function chunkText(text, maxLen = 1900) {
|
|
27
|
+
if (text.length <= maxLen)
|
|
28
|
+
return [text];
|
|
29
|
+
const chunks = [];
|
|
30
|
+
let remaining = text;
|
|
31
|
+
while (remaining) {
|
|
32
|
+
if (remaining.length <= maxLen) {
|
|
33
|
+
chunks.push(remaining);
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
let splitAt = remaining.lastIndexOf('\n', maxLen);
|
|
37
|
+
if (splitAt === -1)
|
|
38
|
+
splitAt = remaining.lastIndexOf(' ', maxLen);
|
|
39
|
+
if (splitAt === -1)
|
|
40
|
+
splitAt = maxLen;
|
|
41
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
42
|
+
remaining = remaining.slice(splitAt).replace(/^[\n ]+/, '');
|
|
43
|
+
}
|
|
44
|
+
return chunks;
|
|
45
|
+
}
|
|
46
|
+
export async function sendChunked(channel, text) {
|
|
47
|
+
if (!('send' in channel))
|
|
48
|
+
return;
|
|
49
|
+
if (!text) {
|
|
50
|
+
await channel.send('*(no response)*');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
text = sanitizeResponse(text);
|
|
54
|
+
for (const chunk of chunkText(text, 1900)) {
|
|
55
|
+
await channel.send(chunk);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// ── Streaming message (posts as the bot user) ─────────────────────────
|
|
59
|
+
// ── Human-friendly tool names ──────────────────────────────────────────
|
|
60
|
+
const TOOL_LABELS = {
|
|
61
|
+
Read: '\ud83d\udcd6 Reading',
|
|
62
|
+
Write: '\ud83d\udcdd Writing',
|
|
63
|
+
Edit: '\u270f\ufe0f Editing',
|
|
64
|
+
Bash: '\u2699\ufe0f Running command',
|
|
65
|
+
Grep: '\ud83d\udd0d Searching',
|
|
66
|
+
Glob: '\ud83d\udcc2 Finding files',
|
|
67
|
+
Agent: '\ud83e\udd16 Delegating',
|
|
68
|
+
WebSearch: '\ud83c\udf10 Web search',
|
|
69
|
+
WebFetch: '\ud83c\udf10 Fetching',
|
|
70
|
+
};
|
|
71
|
+
export function friendlyToolName(name, input) {
|
|
72
|
+
// Check direct match first
|
|
73
|
+
if (TOOL_LABELS[name]) {
|
|
74
|
+
// Add context from input where helpful
|
|
75
|
+
if (name === 'Read' && input?.file_path) {
|
|
76
|
+
const fp = String(input.file_path);
|
|
77
|
+
const short = fp.length > 40 ? '...' + fp.slice(-37) : fp;
|
|
78
|
+
return `${TOOL_LABELS[name]} ${short}`;
|
|
79
|
+
}
|
|
80
|
+
if (name === 'Bash' && input?.command) {
|
|
81
|
+
const cmd = String(input.command).slice(0, 40);
|
|
82
|
+
return `${TOOL_LABELS[name]}: ${cmd}`;
|
|
83
|
+
}
|
|
84
|
+
if (name === 'Grep' && input?.pattern) {
|
|
85
|
+
return `${TOOL_LABELS[name]} for "${String(input.pattern).slice(0, 30)}"`;
|
|
86
|
+
}
|
|
87
|
+
return TOOL_LABELS[name];
|
|
88
|
+
}
|
|
89
|
+
// MCP tools: strip prefix (e.g., "mcp__clementine__memory_search" → "memory_search")
|
|
90
|
+
const short = name.includes('__') ? name.split('__').pop() : name;
|
|
91
|
+
return `\ud83d\udd27 ${short.replace(/_/g, ' ')}`;
|
|
92
|
+
}
|
|
93
|
+
export class DiscordStreamingMessage {
|
|
94
|
+
message = null;
|
|
95
|
+
lastEdit = 0;
|
|
96
|
+
pendingText = '';
|
|
97
|
+
lastFlushedText = '';
|
|
98
|
+
isFinal = false;
|
|
99
|
+
channel;
|
|
100
|
+
flushTimer = null;
|
|
101
|
+
progressTimer = null;
|
|
102
|
+
toolStatus = '';
|
|
103
|
+
startTime = Date.now();
|
|
104
|
+
toolCallCount = 0;
|
|
105
|
+
lastTextTime = 0;
|
|
106
|
+
/** The message ID of the final bot response (available after finalize). */
|
|
107
|
+
messageId = null;
|
|
108
|
+
constructor(channel) {
|
|
109
|
+
this.channel = channel;
|
|
110
|
+
}
|
|
111
|
+
async start() {
|
|
112
|
+
if (!('send' in this.channel))
|
|
113
|
+
return;
|
|
114
|
+
this.message = await this.channel.send(THINKING_INDICATOR);
|
|
115
|
+
this.lastEdit = Date.now();
|
|
116
|
+
// Periodic refresh keeps elapsed time display current during long silent stretches
|
|
117
|
+
this.progressTimer = setInterval(() => {
|
|
118
|
+
if (!this.isFinal && this.toolCallCount > 3)
|
|
119
|
+
this.flush().catch(() => { });
|
|
120
|
+
}, 30_000);
|
|
121
|
+
}
|
|
122
|
+
/** Update the tool activity status line shown during streaming. */
|
|
123
|
+
setToolStatus(status) {
|
|
124
|
+
this.toolStatus = status;
|
|
125
|
+
this.toolCallCount++;
|
|
126
|
+
// Trigger a flush so the status is actually displayed during long tool chains
|
|
127
|
+
// where no text tokens are being emitted
|
|
128
|
+
const elapsed = Date.now() - this.lastEdit;
|
|
129
|
+
if (elapsed >= STREAM_EDIT_INTERVAL) {
|
|
130
|
+
this.flush().catch(() => { });
|
|
131
|
+
}
|
|
132
|
+
else if (!this.flushTimer) {
|
|
133
|
+
this.flushTimer = setTimeout(() => {
|
|
134
|
+
this.flushTimer = null;
|
|
135
|
+
this.flush().catch(() => { });
|
|
136
|
+
}, STREAM_EDIT_INTERVAL - elapsed);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async update(text) {
|
|
140
|
+
this.pendingText = text;
|
|
141
|
+
this.lastTextTime = Date.now();
|
|
142
|
+
const elapsed = Date.now() - this.lastEdit;
|
|
143
|
+
if (elapsed >= STREAM_EDIT_INTERVAL) {
|
|
144
|
+
await this.flush();
|
|
145
|
+
}
|
|
146
|
+
else if (!this.flushTimer) {
|
|
147
|
+
this.flushTimer = setTimeout(() => {
|
|
148
|
+
this.flushTimer = null;
|
|
149
|
+
this.flush().catch(() => { });
|
|
150
|
+
}, STREAM_EDIT_INTERVAL - elapsed);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async finalize(text) {
|
|
154
|
+
this.isFinal = true;
|
|
155
|
+
if (this.flushTimer) {
|
|
156
|
+
clearTimeout(this.flushTimer);
|
|
157
|
+
this.flushTimer = null;
|
|
158
|
+
}
|
|
159
|
+
if (this.progressTimer) {
|
|
160
|
+
clearInterval(this.progressTimer);
|
|
161
|
+
this.progressTimer = null;
|
|
162
|
+
}
|
|
163
|
+
if (!text)
|
|
164
|
+
text = '*(no response)*';
|
|
165
|
+
text = sanitizeResponse(text);
|
|
166
|
+
if (this.message) {
|
|
167
|
+
if (text.length <= 1900) {
|
|
168
|
+
await this.message.edit(text);
|
|
169
|
+
this.messageId = this.message.id;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
await this.message.delete().catch(() => { });
|
|
173
|
+
await sendChunked(this.channel, text);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
await sendChunked(this.channel, text);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Format elapsed milliseconds as human-readable duration. */
|
|
181
|
+
formatElapsed(ms) {
|
|
182
|
+
const s = Math.floor(ms / 1000);
|
|
183
|
+
if (s < 60)
|
|
184
|
+
return `${s}s`;
|
|
185
|
+
const m = Math.floor(s / 60);
|
|
186
|
+
const rem = s % 60;
|
|
187
|
+
return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
|
|
188
|
+
}
|
|
189
|
+
async flush() {
|
|
190
|
+
if (!this.message || this.isFinal)
|
|
191
|
+
return;
|
|
192
|
+
// Enhanced status when tools have been running 60s+ with no text output
|
|
193
|
+
const silenceDuration = Date.now() - (this.lastTextTime || this.startTime);
|
|
194
|
+
const showProgress = this.toolCallCount > 3 && silenceDuration > 60_000;
|
|
195
|
+
// Skip flush if nothing changed — but always allow when showing progress (elapsed time updates)
|
|
196
|
+
if (!showProgress) {
|
|
197
|
+
if (!this.pendingText && !this.toolStatus)
|
|
198
|
+
return;
|
|
199
|
+
if (this.pendingText === this.lastFlushedText && !this.toolStatus)
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
let display = this.pendingText;
|
|
203
|
+
let statusLine;
|
|
204
|
+
if (showProgress) {
|
|
205
|
+
const elapsed = this.formatElapsed(Date.now() - this.startTime);
|
|
206
|
+
const current = this.toolStatus ? ` \u2014 ${this.toolStatus}` : '';
|
|
207
|
+
statusLine = `\n\n*\ud83d\udd27 Working... (${this.toolCallCount} steps, ${elapsed})${current}*`;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
statusLine = this.toolStatus ? `\n\n*${this.toolStatus}*` : '\n\n\u270d\ufe0f *typing...*';
|
|
211
|
+
}
|
|
212
|
+
if (display.length > 1900) {
|
|
213
|
+
display = display.slice(0, 1900) + '\n\n*...streaming...*';
|
|
214
|
+
}
|
|
215
|
+
else if (display) {
|
|
216
|
+
display = display + statusLine;
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
// No text yet — show tool status or progress as the main content
|
|
220
|
+
if (showProgress) {
|
|
221
|
+
const elapsed = this.formatElapsed(Date.now() - this.startTime);
|
|
222
|
+
const current = this.toolStatus ? ` \u2014 ${this.toolStatus}` : '';
|
|
223
|
+
display = `\u2728 *Working... (${this.toolCallCount} steps, ${elapsed})${current}*`;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
display = this.toolStatus ? `\u2728 *${this.toolStatus}*` : THINKING_INDICATOR;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
await this.message.edit(display);
|
|
231
|
+
this.lastFlushedText = this.pendingText;
|
|
232
|
+
this.lastEdit = Date.now();
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// Discord rate limit or message deleted — ignore
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// ── Cron embed formatting ─────────────────────────────────────────────
|
|
240
|
+
const CRON_PREFIX_RE = /^(✅|⏳|❌)\s/;
|
|
241
|
+
/**
|
|
242
|
+
* Detect whether a notification message looks like a cron result.
|
|
243
|
+
* Returns the embed type if so, or null for non-cron messages.
|
|
244
|
+
*/
|
|
245
|
+
export function detectCronType(text) {
|
|
246
|
+
if (text.startsWith('✅'))
|
|
247
|
+
return 'success';
|
|
248
|
+
if (text.startsWith('⏳'))
|
|
249
|
+
return 'progress';
|
|
250
|
+
if (text.includes('failed:') || text.startsWith('❌'))
|
|
251
|
+
return 'error';
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
const EMBED_COLORS = {
|
|
255
|
+
success: 0x2ecc71, // green
|
|
256
|
+
progress: 0x3498db, // blue
|
|
257
|
+
error: 0xe74c3c, // red
|
|
258
|
+
};
|
|
259
|
+
/**
|
|
260
|
+
* Format a cron notification as a Discord embed.
|
|
261
|
+
* Returns null if the text doesn't look like a cron message.
|
|
262
|
+
*/
|
|
263
|
+
export function formatCronEmbed(text) {
|
|
264
|
+
const type = detectCronType(text);
|
|
265
|
+
if (!type)
|
|
266
|
+
return null;
|
|
267
|
+
// Extract job name from patterns like "✅ Unleashed task **jobName** completed:"
|
|
268
|
+
// or "⏳ **jobName** — phase N complete:" or "jobName failed: ..."
|
|
269
|
+
let title = 'Cron Result';
|
|
270
|
+
const boldMatch = text.match(/\*\*([^*]+)\*\*/);
|
|
271
|
+
if (boldMatch) {
|
|
272
|
+
title = boldMatch[1];
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
// "jobName failed: ..." pattern
|
|
276
|
+
const failMatch = text.match(/^(.+?)\s+failed:/);
|
|
277
|
+
if (failMatch)
|
|
278
|
+
title = failMatch[1];
|
|
279
|
+
}
|
|
280
|
+
// Strip the emoji prefix and job name prefix for the description body
|
|
281
|
+
let description = text.replace(CRON_PREFIX_RE, '').trim();
|
|
282
|
+
// Truncate to Discord embed limit
|
|
283
|
+
if (description.length > 4096) {
|
|
284
|
+
description = description.slice(0, 4080) + '\n\n_(truncated)_';
|
|
285
|
+
}
|
|
286
|
+
const embed = new EmbedBuilder()
|
|
287
|
+
.setTitle(title)
|
|
288
|
+
.setDescription(description)
|
|
289
|
+
.setColor(EMBED_COLORS[type])
|
|
290
|
+
.setTimestamp();
|
|
291
|
+
return embed;
|
|
292
|
+
}
|
|
293
|
+
//# sourceMappingURL=discord-utils.js.map
|