@hoverlover/cc-discord 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.claude/settings.template.json +94 -0
  2. package/.env.example +41 -0
  3. package/.env.relay.example +46 -0
  4. package/.env.worker.example +40 -0
  5. package/README.md +313 -0
  6. package/hooks/check-discord-messages.ts +204 -0
  7. package/hooks/cleanup-attachment.ts +47 -0
  8. package/hooks/safe-bash.ts +157 -0
  9. package/hooks/steer-send.ts +108 -0
  10. package/hooks/track-activity.ts +220 -0
  11. package/memory/README.md +60 -0
  12. package/memory/core/MemoryCoordinator.ts +703 -0
  13. package/memory/core/MemoryStore.ts +72 -0
  14. package/memory/core/session-key.ts +14 -0
  15. package/memory/core/types.ts +59 -0
  16. package/memory/index.ts +19 -0
  17. package/memory/providers/sqlite/SqliteMemoryStore.ts +838 -0
  18. package/memory/providers/sqlite/index.ts +1 -0
  19. package/package.json +45 -0
  20. package/prompts/autoreply-system.md +32 -0
  21. package/prompts/channel-system.md +22 -0
  22. package/prompts/orchestrator-system.md +56 -0
  23. package/scripts/channel-agent.sh +159 -0
  24. package/scripts/generate-settings.sh +17 -0
  25. package/scripts/load-env.sh +79 -0
  26. package/scripts/migrate-memory-to-channel-keys.ts +148 -0
  27. package/scripts/orchestrator.sh +325 -0
  28. package/scripts/parse-claude-stream.ts +349 -0
  29. package/scripts/start-orchestrator.sh +82 -0
  30. package/scripts/start-relay.sh +17 -0
  31. package/scripts/start.sh +175 -0
  32. package/server/attachment.ts +182 -0
  33. package/server/busy-notify.ts +69 -0
  34. package/server/config.ts +121 -0
  35. package/server/db.ts +249 -0
  36. package/server/index.ts +311 -0
  37. package/server/memory.ts +88 -0
  38. package/server/messages.ts +111 -0
  39. package/server/trace-thread.ts +340 -0
  40. package/server/typing.ts +101 -0
  41. package/tools/memory-inspect.ts +94 -0
  42. package/tools/memory-smoke.ts +173 -0
  43. package/tools/send-discord +2 -0
  44. package/tools/send-discord.ts +82 -0
  45. package/tools/wait-for-discord-messages +2 -0
  46. package/tools/wait-for-discord-messages.ts +369 -0
package/server/db.ts ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Database initialization and query helpers for the relay server.
3
+ */
4
+
5
+ import { Database as DatabaseSync } from "bun:sqlite";
6
+ import { mkdirSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { DATA_DIR } from "./config.ts";
9
+
10
+ mkdirSync(DATA_DIR, { recursive: true });
11
+
12
+ export const db = new DatabaseSync(join(DATA_DIR, "messages.db"));
13
+
14
+ db.exec(`
15
+ CREATE TABLE IF NOT EXISTS messages (
16
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
18
+ session_id TEXT NOT NULL,
19
+ from_agent TEXT NOT NULL,
20
+ to_agent TEXT NOT NULL,
21
+ message_type TEXT NOT NULL,
22
+ content TEXT NOT NULL,
23
+ source TEXT NOT NULL DEFAULT 'discord',
24
+ external_id TEXT,
25
+ channel_id TEXT,
26
+ read INTEGER DEFAULT 0
27
+ );
28
+
29
+ CREATE TABLE IF NOT EXISTS agent_activity (
30
+ session_id TEXT NOT NULL,
31
+ agent_id TEXT NOT NULL,
32
+ status TEXT NOT NULL DEFAULT 'idle',
33
+ activity_type TEXT,
34
+ activity_summary TEXT,
35
+ started_at TEXT,
36
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
37
+ PRIMARY KEY (session_id, agent_id)
38
+ );
39
+
40
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
41
+ CREATE INDEX IF NOT EXISTS idx_messages_to_agent ON messages(to_agent);
42
+ CREATE INDEX IF NOT EXISTS idx_messages_read ON messages(read);
43
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_source_external
44
+ ON messages(source, external_id);
45
+ CREATE INDEX IF NOT EXISTS idx_agent_activity_status
46
+ ON agent_activity(status);
47
+
48
+ CREATE TABLE IF NOT EXISTS channel_models (
49
+ channel_id TEXT PRIMARY KEY,
50
+ model TEXT NOT NULL,
51
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
52
+ updated_by TEXT
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS trace_threads (
56
+ channel_id TEXT PRIMARY KEY,
57
+ thread_id TEXT NOT NULL,
58
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
59
+ );
60
+
61
+ CREATE TABLE IF NOT EXISTS trace_events (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
64
+ session_id TEXT NOT NULL,
65
+ agent_id TEXT NOT NULL,
66
+ channel_id TEXT,
67
+ event_type TEXT NOT NULL,
68
+ tool_name TEXT,
69
+ summary TEXT,
70
+ posted INTEGER DEFAULT 0
71
+ );
72
+
73
+ CREATE INDEX IF NOT EXISTS idx_trace_events_pending
74
+ ON trace_events(posted, created_at);
75
+ `);
76
+
77
+ export const insertStmt = db.prepare(`
78
+ INSERT INTO messages (
79
+ session_id,
80
+ from_agent,
81
+ to_agent,
82
+ message_type,
83
+ content,
84
+ source,
85
+ external_id,
86
+ channel_id,
87
+ read
88
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
89
+ `);
90
+
91
+ export function getChannelModel(channelId: string) {
92
+ try {
93
+ const row = db.prepare("SELECT model FROM channel_models WHERE channel_id = ?").get(channelId) as any;
94
+ return row?.model || null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ export function setChannelModel(channelId: string, model: string, updatedBy: string | null) {
101
+ db.prepare(`
102
+ INSERT INTO channel_models (channel_id, model, updated_at, updated_by)
103
+ VALUES (?, ?, CURRENT_TIMESTAMP, ?)
104
+ ON CONFLICT(channel_id) DO UPDATE SET
105
+ model = excluded.model,
106
+ updated_at = excluded.updated_at,
107
+ updated_by = excluded.updated_by
108
+ `).run(channelId, model, updatedBy || null);
109
+ }
110
+
111
+ export function clearChannelModel(channelId: string) {
112
+ db.prepare("DELETE FROM channel_models WHERE channel_id = ?").run(channelId);
113
+ }
114
+
115
+ export function getCurrentAgentActivity(sessionId: string, defaultAgentId: string, agentIdOverride?: string | null) {
116
+ const targetAgent = agentIdOverride || defaultAgentId;
117
+ try {
118
+ return db
119
+ .prepare(`
120
+ SELECT status, activity_type, activity_summary, started_at, updated_at
121
+ FROM agent_activity
122
+ WHERE session_id = ? AND agent_id = ?
123
+ LIMIT 1
124
+ `)
125
+ .get(sessionId, targetAgent);
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ // ── Trace thread helpers ────────────────────────────────────────────
132
+
133
+ export function getTraceThreadId(channelId: string): string | null {
134
+ try {
135
+ const row = db.prepare("SELECT thread_id FROM trace_threads WHERE channel_id = ?").get(channelId) as any;
136
+ return row?.thread_id || null;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ export function setTraceThreadId(channelId: string, threadId: string) {
143
+ db.prepare(`
144
+ INSERT INTO trace_threads (channel_id, thread_id, created_at)
145
+ VALUES (?, ?, CURRENT_TIMESTAMP)
146
+ ON CONFLICT(channel_id) DO UPDATE SET
147
+ thread_id = excluded.thread_id,
148
+ created_at = excluded.created_at
149
+ `).run(channelId, threadId);
150
+ }
151
+
152
+ export interface TraceEvent {
153
+ id: number;
154
+ created_at: string;
155
+ session_id: string;
156
+ agent_id: string;
157
+ channel_id: string | null;
158
+ event_type: string;
159
+ tool_name: string | null;
160
+ summary: string | null;
161
+ }
162
+
163
+ export function getPendingTraceEvents(limit: number = 50): TraceEvent[] {
164
+ try {
165
+ return db
166
+ .prepare("SELECT * FROM trace_events WHERE posted = 0 ORDER BY created_at, id LIMIT ?")
167
+ .all(limit) as TraceEvent[];
168
+ } catch {
169
+ return [];
170
+ }
171
+ }
172
+
173
+ export function markTraceEventsPosted(ids: number[]) {
174
+ if (!ids.length) return;
175
+ try {
176
+ const placeholders = ids.map(() => "?").join(",");
177
+ db.prepare(`UPDATE trace_events SET posted = 1 WHERE id IN (${placeholders})`).run(...ids);
178
+ } catch {
179
+ // fail-open
180
+ }
181
+ }
182
+
183
+ export function insertTraceEvent(
184
+ sessionId: string,
185
+ agentId: string,
186
+ channelId: string | null,
187
+ eventType: string,
188
+ toolName: string | null,
189
+ summary: string | null,
190
+ ) {
191
+ try {
192
+ db.prepare(`
193
+ INSERT INTO trace_events (session_id, agent_id, channel_id, event_type, tool_name, summary)
194
+ VALUES (?, ?, ?, ?, ?, ?)
195
+ `).run(sessionId, agentId, channelId, eventType, toolName, summary);
196
+ } catch {
197
+ // fail-open
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Get health status for all agents in a session.
203
+ * Returns each agent's last heartbeat time, status, and whether it
204
+ * has unread messages waiting (a sign it might be stuck).
205
+ */
206
+ export function getAgentHealthAll(sessionId: string, staleThresholdSeconds: number = 900) {
207
+ try {
208
+ const agents = db
209
+ .prepare(`
210
+ SELECT
211
+ a.agent_id,
212
+ a.status,
213
+ a.activity_type,
214
+ a.activity_summary,
215
+ a.updated_at,
216
+ CAST((julianday('now') - julianday(a.updated_at)) * 86400 AS INTEGER) as seconds_since_heartbeat,
217
+ COALESCE(m.unread_count, 0) as unread_count,
218
+ m.oldest_unread_at
219
+ FROM agent_activity a
220
+ LEFT JOIN (
221
+ SELECT
222
+ to_agent,
223
+ COUNT(*) as unread_count,
224
+ MIN(created_at) as oldest_unread_at
225
+ FROM messages
226
+ WHERE session_id = ? AND read = 0
227
+ GROUP BY to_agent
228
+ ) m ON m.to_agent = a.agent_id
229
+ WHERE a.session_id = ?
230
+ ORDER BY a.agent_id
231
+ `)
232
+ .all(sessionId, sessionId);
233
+
234
+ return agents.map((a: any) => ({
235
+ agentId: a.agent_id,
236
+ status: a.status,
237
+ activityType: a.activity_type,
238
+ activitySummary: a.activity_summary,
239
+ lastHeartbeat: a.updated_at,
240
+ secondsSinceHeartbeat: a.seconds_since_heartbeat,
241
+ unreadCount: a.unread_count,
242
+ oldestUnreadAt: a.oldest_unread_at,
243
+ healthy: a.seconds_since_heartbeat < staleThresholdSeconds,
244
+ stuck: a.seconds_since_heartbeat >= staleThresholdSeconds && a.unread_count > 0,
245
+ }));
246
+ } catch {
247
+ return [];
248
+ }
249
+ }
@@ -0,0 +1,311 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder } from "discord.js";
4
+ import express, { type NextFunction, type Request, type Response } from "express";
5
+ import { cleanupOldAttachments } from "./attachment.ts";
6
+ import { maybeNotifyBusyQueued } from "./busy-notify.ts";
7
+ import {
8
+ ALLOWED_CHANNEL_IDS,
9
+ ALLOWED_DISCORD_USER_IDS,
10
+ BUSY_NOTIFY_COOLDOWN_MS,
11
+ BUSY_NOTIFY_ON_QUEUE,
12
+ DEFAULT_CHANNEL_ID,
13
+ DISCORD_BOT_TOKEN,
14
+ DISCORD_SESSION_ID,
15
+ IGNORED_CHANNEL_IDS,
16
+ MESSAGE_ROUTING_MODE,
17
+ RELAY_ALLOW_NO_AUTH,
18
+ RELAY_API_TOKEN,
19
+ RELAY_HOST,
20
+ RELAY_PORT,
21
+ THINKING_FALLBACK_ENABLED,
22
+ TYPING_INTERVAL_MS,
23
+ TYPING_MAX_MS,
24
+ validateConfig,
25
+ } from "./config.ts";
26
+ import { clearChannelModel, db, getAgentHealthAll, getChannelModel, setChannelModel } from "./db.ts";
27
+ import { memoryStore } from "./memory.ts";
28
+ import { persistInboundDiscordMessage, persistOutboundDiscordMessage } from "./messages.ts";
29
+ import { startTraceFlushLoop, stopTraceFlushLoop } from "./trace-thread.ts";
30
+ import { startTypingIndicator, stopAllTypingSessions, stopTypingIndicator } from "./typing.ts";
31
+
32
+ validateConfig();
33
+
34
+ // Run attachment cleanup every 10 minutes; also once at startup
35
+ setInterval(cleanupOldAttachments, 10 * 60 * 1000);
36
+ cleanupOldAttachments();
37
+
38
+ const client = new Client({
39
+ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
40
+ });
41
+
42
+ function isAllowedChannel(channelId: string): boolean {
43
+ if (!channelId) return false;
44
+ if (IGNORED_CHANNEL_IDS.has(channelId)) return false;
45
+ if (ALLOWED_CHANNEL_IDS.length > 0) return ALLOWED_CHANNEL_IDS.includes(channelId);
46
+ return true;
47
+ }
48
+
49
+ function isAllowedUser(userId: string | undefined): boolean {
50
+ if (!userId) return false;
51
+ if (ALLOWED_DISCORD_USER_IDS.length === 0) return true;
52
+ return ALLOWED_DISCORD_USER_IDS.includes(userId);
53
+ }
54
+
55
+ function requireAuth(req: Request, res: Response): boolean {
56
+ if (RELAY_ALLOW_NO_AUTH) return true;
57
+ const token = req.header("x-api-token") || req.header("authorization")?.replace(/^Bearer\s+/i, "");
58
+ if (!token || token !== RELAY_API_TOKEN) {
59
+ res.status(401).json({ success: false, error: "Unauthorized" });
60
+ return false;
61
+ }
62
+ return true;
63
+ }
64
+
65
+ // ── Discord client events ──────────────────────────────────────────────────────
66
+
67
+ client.once("clientReady", async () => {
68
+ console.log(`[Relay] Discord bot ready as ${client.user?.tag}`);
69
+ console.log(
70
+ `[Relay] Listening on channel(s): ${ALLOWED_CHANNEL_IDS.length > 0 ? ALLOWED_CHANNEL_IDS.join(", ") : DEFAULT_CHANNEL_ID}`,
71
+ );
72
+ console.log(
73
+ `[Relay] User allowlist: ${ALLOWED_DISCORD_USER_IDS.length > 0 ? ALLOWED_DISCORD_USER_IDS.join(", ") : "disabled (all users in allowed channels)"}`,
74
+ );
75
+ console.log(`[Relay] API auth: ${RELAY_ALLOW_NO_AUTH ? "disabled (RELAY_ALLOW_NO_AUTH=true)" : "required"}`);
76
+ console.log(`[Relay] Message routing: ${MESSAGE_ROUTING_MODE} mode`);
77
+ console.log(
78
+ `[Relay] Busy queue notify: ${BUSY_NOTIFY_ON_QUEUE ? `on (cooldown=${BUSY_NOTIFY_COOLDOWN_MS}ms)` : "off"}`,
79
+ );
80
+ console.log(
81
+ `[Relay] Typing: interval=${TYPING_INTERVAL_MS}ms, max=${TYPING_MAX_MS}ms, fallback=${THINKING_FALLBACK_ENABLED ? "on" : "off"}`,
82
+ );
83
+
84
+ // Register /model slash command
85
+ try {
86
+ const modelCommand = new SlashCommandBuilder()
87
+ .setName("model")
88
+ .setDescription("Get or set the Claude model for this channel")
89
+ .addStringOption((option) =>
90
+ option
91
+ .setName("name")
92
+ .setDescription(
93
+ "Model name or alias (e.g. claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5, or full model ID)",
94
+ )
95
+ .setRequired(false),
96
+ );
97
+
98
+ const rest = new REST({ version: "10" }).setToken(DISCORD_BOT_TOKEN!);
99
+ await rest.put(Routes.applicationCommands(client.user!.id), { body: [modelCommand.toJSON()] });
100
+ console.log("[Relay] Registered /model slash command");
101
+ } catch (err: unknown) {
102
+ console.error("[Relay] Failed to register slash commands:", (err as Error).message);
103
+ }
104
+
105
+ // Start live trace thread flush loop
106
+ startTraceFlushLoop(client);
107
+ });
108
+
109
+ client.on("messageCreate", async (message) => {
110
+ if (!message) return;
111
+ if (message.author?.bot) return;
112
+ if (!isAllowedChannel(message.channelId)) return;
113
+ if (!isAllowedUser(message.author?.id)) {
114
+ console.log(`[Relay] Ignoring message from unauthorized user ${message.author?.id}`);
115
+ return;
116
+ }
117
+ startTypingIndicator(client, message.channelId, persistOutboundDiscordMessage);
118
+ maybeNotifyBusyQueued(message, client, persistOutboundDiscordMessage);
119
+ await persistInboundDiscordMessage(message);
120
+ });
121
+
122
+ client.on("interactionCreate", async (interaction) => {
123
+ if (!interaction.isChatInputCommand()) return;
124
+ if (interaction.commandName !== "model") return;
125
+
126
+ const modelArg = interaction.options.getString("name");
127
+
128
+ if (!modelArg) {
129
+ const current = getChannelModel(interaction.channelId);
130
+ await interaction.reply(
131
+ current ? `Current model for this channel: \`${current}\`` : "No model set for this channel (using default).",
132
+ );
133
+ return;
134
+ }
135
+
136
+ if (modelArg === "clear" || modelArg === "reset" || modelArg === "default") {
137
+ clearChannelModel(interaction.channelId);
138
+ await interaction.reply("Model override cleared for this channel. Using default model.");
139
+ console.log(`[Relay] Model cleared for channel ${interaction.channelId} by ${interaction.user?.tag}`);
140
+ return;
141
+ }
142
+
143
+ setChannelModel(interaction.channelId, modelArg, interaction.user?.tag || interaction.user?.id || null);
144
+ await interaction.reply(`Model for this channel set to: \`${modelArg}\``);
145
+ console.log(`[Relay] Model set for channel ${interaction.channelId}: ${modelArg} by ${interaction.user?.tag}`);
146
+ });
147
+
148
+ client.on("error", (err) => {
149
+ console.error("[Relay] Discord client error:", err.message);
150
+ });
151
+
152
+ // ── Express HTTP API ───────────────────────────────────────────────────────────
153
+
154
+ const app = express();
155
+ app.use(express.json({ limit: "1mb" }));
156
+
157
+ // Handle malformed JSON bodies cleanly
158
+ app.use((err: any, _req: Request, res: Response, next: NextFunction) => {
159
+ if (
160
+ err?.type === "entity.parse.failed" ||
161
+ (err instanceof SyntaxError && (err as any)?.status === 400 && "body" in err)
162
+ ) {
163
+ res.status(400).json({ success: false, error: "Invalid JSON body" });
164
+ return;
165
+ }
166
+ next(err);
167
+ });
168
+
169
+ app.get("/health", (_req: Request, res: Response) => {
170
+ res.json({
171
+ ok: true,
172
+ discordReady: Boolean(client.user),
173
+ defaultChannelId: DEFAULT_CHANNEL_ID,
174
+ sessionId: DISCORD_SESSION_ID,
175
+ });
176
+ });
177
+
178
+ app.get("/api/channels", async (req: Request, res: Response) => {
179
+ try {
180
+ if (!requireAuth(req, res)) return;
181
+ if (!client.user) {
182
+ res.status(503).json({ success: false, error: "Discord client not ready yet" });
183
+ return;
184
+ }
185
+
186
+ const channels: any[] = [];
187
+ for (const [, guild] of client.guilds.cache) {
188
+ const guildChannels = await guild.channels.fetch();
189
+ for (const [, channel] of guildChannels) {
190
+ if (!channel || !channel.isTextBased() || channel.isThread() || channel.isVoiceBased()) continue;
191
+ if (IGNORED_CHANNEL_IDS.has(channel.id)) continue;
192
+ if (ALLOWED_CHANNEL_IDS.length > 0 && !ALLOWED_CHANNEL_IDS.includes(channel.id)) continue;
193
+ channels.push({
194
+ id: channel.id,
195
+ name: channel.name,
196
+ guildId: guild.id,
197
+ guildName: guild.name,
198
+ type: channel.type,
199
+ model: getChannelModel(channel.id),
200
+ });
201
+ }
202
+ }
203
+
204
+ res.json({ success: true, channels });
205
+ } catch (err: unknown) {
206
+ console.error("[Relay] /api/channels failed:", err);
207
+ res.status(500).json({ success: false, error: (err as Error).message });
208
+ }
209
+ });
210
+
211
+ app.get("/api/agent-health", (req: Request, res: Response) => {
212
+ try {
213
+ if (!requireAuth(req, res)) return;
214
+ const staleThreshold = Number(req.query.stale_threshold) || 900; // default 15 min
215
+ const agents = getAgentHealthAll(DISCORD_SESSION_ID, staleThreshold);
216
+ const stuckAgents = agents.filter((a: any) => a.stuck);
217
+ res.json({
218
+ success: true,
219
+ sessionId: DISCORD_SESSION_ID,
220
+ staleThresholdSeconds: staleThreshold,
221
+ agents,
222
+ stuckAgents: stuckAgents.map((a: any) => a.agentId),
223
+ anyStuck: stuckAgents.length > 0,
224
+ });
225
+ } catch (err: unknown) {
226
+ console.error("[Relay] /api/agent-health failed:", err);
227
+ res.status(500).json({ success: false, error: (err as Error).message });
228
+ }
229
+ });
230
+
231
+ app.post("/api/send", async (req: Request, res: Response) => {
232
+ try {
233
+ if (!requireAuth(req, res)) return;
234
+ if (!client.user) {
235
+ res.status(503).json({ success: false, error: "Discord client not ready yet" });
236
+ return;
237
+ }
238
+
239
+ const { content, channelId, replyTo, fromAgent } = req.body || {};
240
+ const text = String(content || "").trim();
241
+ const targetChannelId = channelId || DEFAULT_CHANNEL_ID;
242
+
243
+ if (!text) {
244
+ res.status(400).json({ success: false, error: "Missing content" });
245
+ return;
246
+ }
247
+
248
+ const channel = await client.channels.fetch(targetChannelId);
249
+ if (!channel || !channel.isTextBased()) {
250
+ res.status(400).json({ success: false, error: `Channel ${targetChannelId} not found or not text-based` });
251
+ return;
252
+ }
253
+
254
+ let sent: any;
255
+ if (replyTo && channel.messages?.fetch) {
256
+ const original = await channel.messages.fetch(replyTo);
257
+ sent = await original.reply(text);
258
+ } else {
259
+ if (!("send" in channel) || typeof channel.send !== "function") {
260
+ res.status(400).json({ success: false, error: `Channel ${targetChannelId} does not support sending messages` });
261
+ return;
262
+ }
263
+ sent = await channel.send(text);
264
+ }
265
+
266
+ persistOutboundDiscordMessage({ content: text, channelId: targetChannelId, externalId: sent.id, fromAgent });
267
+ stopTypingIndicator(client, targetChannelId, persistOutboundDiscordMessage, "reply-sent");
268
+
269
+ res.json({ success: true, messageId: sent.id, channelId: targetChannelId });
270
+ } catch (err: unknown) {
271
+ console.error("[Relay] /api/send failed:", err);
272
+ res.status(500).json({ success: false, error: (err as Error).message });
273
+ }
274
+ });
275
+
276
+ // ── Server startup ─────────────────────────────────────────────────────────────
277
+
278
+ const server = app.listen(RELAY_PORT, RELAY_HOST, () => {
279
+ console.log(`[Relay] HTTP API running at http://${RELAY_HOST}:${RELAY_PORT}`);
280
+ });
281
+
282
+ client.login(DISCORD_BOT_TOKEN).catch((err: Error) => {
283
+ console.error("[Relay] Failed to login to Discord:", err.message);
284
+ process.exit(1);
285
+ });
286
+
287
+ function shutdown(signal: string) {
288
+ console.log(`\n[Relay] Received ${signal}. Shutting down...`);
289
+ stopTraceFlushLoop();
290
+ stopAllTypingSessions(client, persistOutboundDiscordMessage);
291
+ try {
292
+ server.close();
293
+ } catch {
294
+ /* ignore */
295
+ }
296
+ try {
297
+ client.destroy();
298
+ } catch {
299
+ /* ignore */
300
+ }
301
+ try {
302
+ db.close();
303
+ } catch {
304
+ /* ignore */
305
+ }
306
+ void memoryStore.close().catch(() => {});
307
+ process.exit(0);
308
+ }
309
+
310
+ process.on("SIGINT", () => shutdown("SIGINT"));
311
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Memory integration for the relay server.
3
+ * Persists inbound/outbound turns and assembles memory context for context injection.
4
+ *
5
+ * Session key strategy:
6
+ * When a channelId is provided, turns are written to a per-channel key
7
+ * (discord:{sessionId}:{channelId}) so that per-channel subagents can
8
+ * retrieve them. This matches the key that wait-for-discord-messages
9
+ * uses on the read side (agentId = channelId).
10
+ *
11
+ * When no channelId is available, falls back to the legacy shared key
12
+ * (discord:{sessionId}:{CLAUDE_AGENT_ID}).
13
+ */
14
+
15
+ import { join } from "node:path";
16
+ import { MemoryCoordinator } from "../memory/core/MemoryCoordinator.ts";
17
+ import { buildMemorySessionKey } from "../memory/core/session-key.ts";
18
+ import { SqliteMemoryStore } from "../memory/providers/sqlite/SqliteMemoryStore.ts";
19
+ import { CLAUDE_AGENT_ID, DATA_DIR, DISCORD_SESSION_ID } from "./config.ts";
20
+
21
+ /** Legacy fallback key for turns without a channel association. */
22
+ const fallbackSessionKey = buildMemorySessionKey({
23
+ sessionId: DISCORD_SESSION_ID,
24
+ agentId: CLAUDE_AGENT_ID,
25
+ });
26
+
27
+ export const memoryStore = new SqliteMemoryStore({
28
+ dbPath: join(DATA_DIR, "memory.db"),
29
+ logger: console,
30
+ });
31
+
32
+ export const memory = new MemoryCoordinator({
33
+ store: memoryStore,
34
+ logger: console,
35
+ });
36
+
37
+ await memory.init();
38
+
39
+ /**
40
+ * Resolve the memory session key for a turn.
41
+ * If channelId is available, produces a per-channel key matching what
42
+ * the subagent's wait-for-discord-messages will query.
43
+ */
44
+ function resolveSessionKey(channelId?: string): string {
45
+ if (channelId) {
46
+ // Subagents set AGENT_ID=channelId and build their key as:
47
+ // buildMemorySessionKey({ sessionId, agentId: channelId })
48
+ // => discord:{sessionId}:{channelId}
49
+ return buildMemorySessionKey({
50
+ sessionId: DISCORD_SESSION_ID,
51
+ agentId: channelId,
52
+ });
53
+ }
54
+ return fallbackSessionKey;
55
+ }
56
+
57
+ export async function appendMemoryTurn({
58
+ role,
59
+ content,
60
+ metadata = {} as any,
61
+ }: {
62
+ role: string;
63
+ content: string;
64
+ metadata?: any;
65
+ }) {
66
+ try {
67
+ const channelId = metadata?.channelId || null;
68
+ const sessionKey = resolveSessionKey(channelId);
69
+ const runtimeState = await memoryStore.readRuntimeState(sessionKey);
70
+
71
+ const result = await memory.appendTurn({
72
+ sessionKey,
73
+ agentId: channelId || CLAUDE_AGENT_ID,
74
+ role,
75
+ content,
76
+ metadata: {
77
+ ...metadata,
78
+ runtimeContextId: runtimeState?.runtimeContextId || null,
79
+ runtimeEpoch: runtimeState?.runtimeEpoch || null,
80
+ },
81
+ });
82
+ console.log(
83
+ `[Memory] persisted ${role} turn to ${sessionKey} (batch=${result?.batchId}, turns=${result?.counts?.turns})`,
84
+ );
85
+ } catch (err: unknown) {
86
+ console.error("[Memory] failed to persist turn:", (err as Error).message);
87
+ }
88
+ }