@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.
- package/.claude/settings.template.json +94 -0
- package/.env.example +41 -0
- package/.env.relay.example +46 -0
- package/.env.worker.example +40 -0
- package/README.md +313 -0
- package/hooks/check-discord-messages.ts +204 -0
- package/hooks/cleanup-attachment.ts +47 -0
- package/hooks/safe-bash.ts +157 -0
- package/hooks/steer-send.ts +108 -0
- package/hooks/track-activity.ts +220 -0
- package/memory/README.md +60 -0
- package/memory/core/MemoryCoordinator.ts +703 -0
- package/memory/core/MemoryStore.ts +72 -0
- package/memory/core/session-key.ts +14 -0
- package/memory/core/types.ts +59 -0
- package/memory/index.ts +19 -0
- package/memory/providers/sqlite/SqliteMemoryStore.ts +838 -0
- package/memory/providers/sqlite/index.ts +1 -0
- package/package.json +45 -0
- package/prompts/autoreply-system.md +32 -0
- package/prompts/channel-system.md +22 -0
- package/prompts/orchestrator-system.md +56 -0
- package/scripts/channel-agent.sh +159 -0
- package/scripts/generate-settings.sh +17 -0
- package/scripts/load-env.sh +79 -0
- package/scripts/migrate-memory-to-channel-keys.ts +148 -0
- package/scripts/orchestrator.sh +325 -0
- package/scripts/parse-claude-stream.ts +349 -0
- package/scripts/start-orchestrator.sh +82 -0
- package/scripts/start-relay.sh +17 -0
- package/scripts/start.sh +175 -0
- package/server/attachment.ts +182 -0
- package/server/busy-notify.ts +69 -0
- package/server/config.ts +121 -0
- package/server/db.ts +249 -0
- package/server/index.ts +311 -0
- package/server/memory.ts +88 -0
- package/server/messages.ts +111 -0
- package/server/trace-thread.ts +340 -0
- package/server/typing.ts +101 -0
- package/tools/memory-inspect.ts +94 -0
- package/tools/memory-smoke.ts +173 -0
- package/tools/send-discord +2 -0
- package/tools/send-discord.ts +82 -0
- package/tools/wait-for-discord-messages +2 -0
- 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
|
+
}
|
package/server/index.ts
ADDED
|
@@ -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"));
|
package/server/memory.ts
ADDED
|
@@ -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
|
+
}
|