@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
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message persistence and formatting for inbound/outbound Discord messages.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { fetchAttachmentContent } from "./attachment.ts";
|
|
6
|
+
import { CLAUDE_AGENT_ID, DEFAULT_CHANNEL_ID, DISCORD_SESSION_ID, MESSAGE_ROUTING_MODE } from "./config.ts";
|
|
7
|
+
import { insertStmt } from "./db.ts";
|
|
8
|
+
import { appendMemoryTurn } from "./memory.ts";
|
|
9
|
+
|
|
10
|
+
export async function formatInboundMessage(message: any) {
|
|
11
|
+
const author = message.author?.username || message.author?.globalName || message.author?.id || "unknown";
|
|
12
|
+
const base = message.content?.trim() || "";
|
|
13
|
+
const attachments = [...message.attachments.values()];
|
|
14
|
+
|
|
15
|
+
let fullText = base;
|
|
16
|
+
if (attachments.length > 0) {
|
|
17
|
+
const attachmentLines = await Promise.all(attachments.map((a: any) => fetchAttachmentContent(a, message.id)));
|
|
18
|
+
const attachmentText = attachmentLines.join("\n");
|
|
19
|
+
fullText = fullText ? `${fullText}\n${attachmentText}` : attachmentText;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!fullText) {
|
|
23
|
+
fullText = "[No text content]";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return `${author}: ${fullText}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function persistInboundDiscordMessage(message: any) {
|
|
30
|
+
const normalizedContent = await formatInboundMessage(message);
|
|
31
|
+
// In channel mode, route to channelId so per-channel subagents consume independently.
|
|
32
|
+
// In agent mode (legacy), route to CLAUDE_AGENT_ID for single-agent consumption.
|
|
33
|
+
const targetAgent = MESSAGE_ROUTING_MODE === "agent" ? CLAUDE_AGENT_ID : message.channelId;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
insertStmt.run(
|
|
37
|
+
DISCORD_SESSION_ID,
|
|
38
|
+
`discord:${message.author?.id || "unknown"}`,
|
|
39
|
+
targetAgent,
|
|
40
|
+
"DISCORD_MESSAGE",
|
|
41
|
+
normalizedContent,
|
|
42
|
+
"discord",
|
|
43
|
+
message.id,
|
|
44
|
+
message.channelId,
|
|
45
|
+
0,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
void appendMemoryTurn({
|
|
49
|
+
role: "user",
|
|
50
|
+
content: normalizedContent,
|
|
51
|
+
metadata: {
|
|
52
|
+
source: "discord",
|
|
53
|
+
messageId: message.id,
|
|
54
|
+
channelId: message.channelId,
|
|
55
|
+
authorId: message.author?.id || null,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
console.log(`[Relay] queued Discord message ${message.id} -> ${targetAgent}`);
|
|
60
|
+
} catch (err: unknown) {
|
|
61
|
+
const msg = String((err as any)?.message || "");
|
|
62
|
+
if (msg.includes("UNIQUE constraint failed")) {
|
|
63
|
+
// Discord can re-deliver in edge cases; idempotent ignore
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
console.error("[Relay] failed to persist inbound message:", (err as Error).message);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function persistOutboundDiscordMessage({
|
|
71
|
+
content,
|
|
72
|
+
channelId,
|
|
73
|
+
externalId,
|
|
74
|
+
fromAgent,
|
|
75
|
+
}: {
|
|
76
|
+
content: string;
|
|
77
|
+
channelId?: string;
|
|
78
|
+
externalId?: string;
|
|
79
|
+
fromAgent?: string;
|
|
80
|
+
}) {
|
|
81
|
+
const normalizedContent = String(content);
|
|
82
|
+
const normalizedFromAgent = fromAgent || CLAUDE_AGENT_ID;
|
|
83
|
+
const normalizedChannelId = channelId || DEFAULT_CHANNEL_ID || "";
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
insertStmt.run(
|
|
87
|
+
DISCORD_SESSION_ID,
|
|
88
|
+
normalizedFromAgent,
|
|
89
|
+
"discord",
|
|
90
|
+
"DISCORD_REPLY",
|
|
91
|
+
normalizedContent,
|
|
92
|
+
"relay-outbound",
|
|
93
|
+
externalId || null,
|
|
94
|
+
normalizedChannelId,
|
|
95
|
+
1,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
void appendMemoryTurn({
|
|
99
|
+
role: "assistant",
|
|
100
|
+
content: normalizedContent,
|
|
101
|
+
metadata: {
|
|
102
|
+
source: "discord",
|
|
103
|
+
messageId: externalId || null,
|
|
104
|
+
channelId: normalizedChannelId,
|
|
105
|
+
fromAgent: normalizedFromAgent,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
} catch (err: unknown) {
|
|
109
|
+
console.error("[Relay] failed to persist outbound message:", (err as Error).message);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Trace Thread: creates a pinned thread per channel and posts batched
|
|
3
|
+
* trace events (tool calls, status changes) so users can watch the agent work.
|
|
4
|
+
*
|
|
5
|
+
* Architecture:
|
|
6
|
+
* hooks write → trace_events table → flush loop reads → batches → posts to thread
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
ChannelType,
|
|
11
|
+
type Client,
|
|
12
|
+
type TextChannel,
|
|
13
|
+
type ThreadChannel,
|
|
14
|
+
} from "discord.js";
|
|
15
|
+
import {
|
|
16
|
+
TRACE_FLUSH_INTERVAL_MS,
|
|
17
|
+
TRACE_THREAD_ENABLED,
|
|
18
|
+
TRACE_THREAD_NAME,
|
|
19
|
+
} from "./config.ts";
|
|
20
|
+
import {
|
|
21
|
+
getPendingTraceEvents,
|
|
22
|
+
getTraceThreadId,
|
|
23
|
+
markTraceEventsPosted,
|
|
24
|
+
setTraceThreadId,
|
|
25
|
+
type TraceEvent,
|
|
26
|
+
} from "./db.ts";
|
|
27
|
+
|
|
28
|
+
// In-memory cache of channel → thread to avoid DB lookups every flush
|
|
29
|
+
const threadCache = new Map<string, string>();
|
|
30
|
+
|
|
31
|
+
// Channels that failed with access errors — skip for a cooldown period
|
|
32
|
+
const failedChannels = new Map<string, number>();
|
|
33
|
+
const FAILURE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
|
|
34
|
+
|
|
35
|
+
let flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
36
|
+
|
|
37
|
+
// ── Thread lifecycle ────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** Lock a thread if not already locked (idempotent). */
|
|
40
|
+
async function ensureLocked(thread: ThreadChannel): Promise<void> {
|
|
41
|
+
try {
|
|
42
|
+
if (!thread.locked) await thread.setLocked(true);
|
|
43
|
+
} catch {
|
|
44
|
+
// best-effort — bot may lack MANAGE_THREADS
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Find or create the trace thread for a channel. Locks the thread so
|
|
50
|
+
* only the bot (with MANAGE_THREADS) can post.
|
|
51
|
+
*/
|
|
52
|
+
async function ensureTraceThread(client: Client, channelId: string): Promise<ThreadChannel | null> {
|
|
53
|
+
// Skip channels that recently failed with access errors
|
|
54
|
+
const failedAt = failedChannels.get(channelId);
|
|
55
|
+
if (failedAt && Date.now() - failedAt < FAILURE_COOLDOWN_MS) return null;
|
|
56
|
+
|
|
57
|
+
// Check in-memory cache first
|
|
58
|
+
const cachedThreadId = threadCache.get(channelId);
|
|
59
|
+
if (cachedThreadId) {
|
|
60
|
+
try {
|
|
61
|
+
const thread = await client.channels.fetch(cachedThreadId);
|
|
62
|
+
if (thread && thread.isThread()) {
|
|
63
|
+
// Ensure thread is locked (idempotent — handles pre-existing unlocked threads)
|
|
64
|
+
await ensureLocked(thread as ThreadChannel);
|
|
65
|
+
return thread as ThreadChannel;
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Thread was deleted or inaccessible; fall through to re-create
|
|
69
|
+
threadCache.delete(channelId);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check DB
|
|
74
|
+
const dbThreadId = getTraceThreadId(channelId);
|
|
75
|
+
if (dbThreadId) {
|
|
76
|
+
try {
|
|
77
|
+
const thread = await client.channels.fetch(dbThreadId);
|
|
78
|
+
if (thread && thread.isThread()) {
|
|
79
|
+
// Ensure thread is locked
|
|
80
|
+
await ensureLocked(thread as ThreadChannel);
|
|
81
|
+
threadCache.set(channelId, dbThreadId);
|
|
82
|
+
return thread as ThreadChannel;
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// Thread was deleted; fall through to create
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Create new thread
|
|
90
|
+
try {
|
|
91
|
+
const channel = await client.channels.fetch(channelId);
|
|
92
|
+
if (!channel || !channel.isTextBased() || channel.type !== ChannelType.GuildText) return null;
|
|
93
|
+
|
|
94
|
+
const textChannel = channel as TextChannel;
|
|
95
|
+
const thread = await textChannel.threads.create({
|
|
96
|
+
name: TRACE_THREAD_NAME,
|
|
97
|
+
autoArchiveDuration: 10080, // 7 days (max for non-boosted servers)
|
|
98
|
+
reason: "Live trace thread for agent activity",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Lock the thread so only moderators (i.e. the bot) can send messages.
|
|
102
|
+
// Note: threads don't support permissionOverwrites — use setLocked() instead.
|
|
103
|
+
try {
|
|
104
|
+
await thread.setLocked(true);
|
|
105
|
+
} catch {
|
|
106
|
+
// May fail if bot lacks MANAGE_THREADS; continue anyway
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Pin an intro message
|
|
110
|
+
try {
|
|
111
|
+
const intro = await thread.send(
|
|
112
|
+
`📡 **${TRACE_THREAD_NAME}**\n` +
|
|
113
|
+
"This thread shows live agent activity — tool calls, status changes, and more.\n" +
|
|
114
|
+
"It updates automatically. You can watch here while chatting in the main channel.",
|
|
115
|
+
);
|
|
116
|
+
await intro.pin();
|
|
117
|
+
} catch {
|
|
118
|
+
// best effort
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Persist
|
|
122
|
+
setTraceThreadId(channelId, thread.id);
|
|
123
|
+
threadCache.set(channelId, thread.id);
|
|
124
|
+
return thread;
|
|
125
|
+
} catch (err) {
|
|
126
|
+
console.error(`[Trace] Failed to create trace thread for channel ${channelId}:`, err);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Event formatting ────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
const EVENT_ICONS: Record<string, string> = {
|
|
134
|
+
tool_start: "🔧",
|
|
135
|
+
tool_end: "✅",
|
|
136
|
+
thinking: "🧠",
|
|
137
|
+
status_busy: "⏳",
|
|
138
|
+
status_idle: "💤",
|
|
139
|
+
error: "❌",
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
function formatTraceEvent(event: TraceEvent): string {
|
|
143
|
+
const icon = EVENT_ICONS[event.event_type] || "📌";
|
|
144
|
+
const ts = formatTimestamp(event.created_at);
|
|
145
|
+
|
|
146
|
+
// Reasoning/thinking events get a distinct format
|
|
147
|
+
if (event.event_type === "thinking") {
|
|
148
|
+
const thought = event.summary ? cleanWhitespace(event.summary) : "";
|
|
149
|
+
return `${icon} \`${ts}\` ${thought}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const tool = event.tool_name ? `\`${event.tool_name}\`` : "";
|
|
153
|
+
|
|
154
|
+
// Tool start: ▶ marker with summary
|
|
155
|
+
if (event.event_type === "tool_start") {
|
|
156
|
+
const summary = event.summary ? ` — ${cleanWhitespace(event.summary)}` : "";
|
|
157
|
+
return `${icon} \`${ts}\` ▶ ${tool}${summary}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Tool end: ✓ marker with elapsed time, no summary duplication
|
|
161
|
+
if (event.event_type === "tool_end") {
|
|
162
|
+
const elapsed = parseElapsed(event.summary);
|
|
163
|
+
const elapsedStr = elapsed !== null ? ` in ${formatElapsed(elapsed)}` : "";
|
|
164
|
+
return `${icon} \`${ts}\` ✓ ${tool} done${elapsedStr}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Default format for other event types
|
|
168
|
+
const summary = event.summary ? ` — ${cleanWhitespace(event.summary)}` : "";
|
|
169
|
+
return `${icon} \`${ts}\` ${tool}${summary}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Parse "elapsed:1234|..." prefix from tool_end summary. Returns ms or null. */
|
|
173
|
+
function parseElapsed(summary: string | null | undefined): number | null {
|
|
174
|
+
if (!summary) return null;
|
|
175
|
+
const match = summary.match(/^elapsed:(\d+)\|/);
|
|
176
|
+
if (!match) return null;
|
|
177
|
+
return parseInt(match[1], 10);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Format milliseconds into a human-friendly duration string */
|
|
181
|
+
function formatElapsed(ms: number): string {
|
|
182
|
+
if (ms < 1000) return `${ms}ms`;
|
|
183
|
+
const secs = ms / 1000;
|
|
184
|
+
if (secs < 60) return `${secs.toFixed(1)}s`;
|
|
185
|
+
const mins = Math.floor(secs / 60);
|
|
186
|
+
const remSecs = Math.round(secs % 60);
|
|
187
|
+
return `${mins}m ${remSecs}s`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function formatTimestamp(iso: string): string {
|
|
191
|
+
try {
|
|
192
|
+
const d = new Date(iso);
|
|
193
|
+
return d.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
194
|
+
} catch {
|
|
195
|
+
return iso;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Clean up text for display — convert literal \n to real newlines, preserve full content. */
|
|
200
|
+
function cleanWhitespace(text: string): string {
|
|
201
|
+
return String(text || "")
|
|
202
|
+
.replace(/\\n/g, "\n") // convert literal \n to real newlines
|
|
203
|
+
.replace(/[ \t]+/g, " ") // collapse horizontal whitespace (but keep newlines)
|
|
204
|
+
.trim();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Flush loop ──────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Read pending trace events from DB, group by channel, and post batched
|
|
211
|
+
* messages to each channel's trace thread.
|
|
212
|
+
*/
|
|
213
|
+
async function flushTraceEvents(client: Client) {
|
|
214
|
+
if (!TRACE_THREAD_ENABLED) return;
|
|
215
|
+
|
|
216
|
+
const events = getPendingTraceEvents(100);
|
|
217
|
+
if (!events.length) return;
|
|
218
|
+
|
|
219
|
+
// Group by channel
|
|
220
|
+
const byChannel = new Map<string, TraceEvent[]>();
|
|
221
|
+
for (const evt of events) {
|
|
222
|
+
const ch = evt.channel_id || "unknown";
|
|
223
|
+
const arr = byChannel.get(ch) || [];
|
|
224
|
+
arr.push(evt);
|
|
225
|
+
byChannel.set(ch, arr);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const postedIds: number[] = [];
|
|
229
|
+
|
|
230
|
+
for (const [channelId, channelEvents] of byChannel) {
|
|
231
|
+
if (channelId === "unknown") {
|
|
232
|
+
// Skip events without a channel; mark as posted to avoid infinite retry
|
|
233
|
+
postedIds.push(...channelEvents.map((e) => e.id));
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Filter out noisy events (wait-for-discord-messages, sleep)
|
|
238
|
+
const meaningful = channelEvents.filter((e) => {
|
|
239
|
+
const text = `${e.tool_name || ""} ${e.summary || ""}`.toLowerCase();
|
|
240
|
+
if (text.includes("wait-for-discord-messages")) return false;
|
|
241
|
+
if (/\bsleep\b/.test(text)) return false;
|
|
242
|
+
return true;
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Even if filtered out, mark all as posted
|
|
246
|
+
postedIds.push(...channelEvents.map((e) => e.id));
|
|
247
|
+
|
|
248
|
+
if (!meaningful.length) continue;
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const thread = await ensureTraceThread(client, channelId);
|
|
252
|
+
if (!thread) continue;
|
|
253
|
+
|
|
254
|
+
// Batch into a single message (Discord max 2000 chars)
|
|
255
|
+
const lines = meaningful.map(formatTraceEvent);
|
|
256
|
+
const batches = batchLines(lines, 1900);
|
|
257
|
+
|
|
258
|
+
for (const batch of batches) {
|
|
259
|
+
await thread.send(batch);
|
|
260
|
+
}
|
|
261
|
+
} catch (err) {
|
|
262
|
+
const code = (err as any)?.code;
|
|
263
|
+
if (code === 50001 || code === 50013) {
|
|
264
|
+
// Missing Access or Missing Permissions — clear cache and back off
|
|
265
|
+
console.warn(`[Trace] No access to trace thread for channel ${channelId} (${code}) — backing off 5m`);
|
|
266
|
+
threadCache.delete(channelId);
|
|
267
|
+
failedChannels.set(channelId, Date.now());
|
|
268
|
+
} else {
|
|
269
|
+
console.error(`[Trace] Failed to post trace events for channel ${channelId}:`, err);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
markTraceEventsPosted(postedIds);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Split lines into batches that fit within maxLen characters.
|
|
278
|
+
* Long lines are split across multiple batches instead of truncated. */
|
|
279
|
+
function batchLines(lines: string[], maxLen: number): string[] {
|
|
280
|
+
const batches: string[] = [];
|
|
281
|
+
let current = "";
|
|
282
|
+
|
|
283
|
+
for (const line of lines) {
|
|
284
|
+
const next = current ? `${current}\n${line}` : line;
|
|
285
|
+
if (next.length > maxLen) {
|
|
286
|
+
if (current) batches.push(current);
|
|
287
|
+
// If a single line exceeds maxLen, split it into chunks
|
|
288
|
+
if (line.length > maxLen) {
|
|
289
|
+
let remaining = line;
|
|
290
|
+
while (remaining.length > maxLen) {
|
|
291
|
+
batches.push(remaining.slice(0, maxLen));
|
|
292
|
+
remaining = remaining.slice(maxLen);
|
|
293
|
+
}
|
|
294
|
+
current = remaining;
|
|
295
|
+
} else {
|
|
296
|
+
current = line;
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
current = next;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (current) batches.push(current);
|
|
304
|
+
return batches;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Public API ──────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Start the periodic trace event flush loop.
|
|
311
|
+
* Call once after the Discord client is ready.
|
|
312
|
+
*/
|
|
313
|
+
export function startTraceFlushLoop(client: Client) {
|
|
314
|
+
if (!TRACE_THREAD_ENABLED) {
|
|
315
|
+
console.log("[Trace] Trace thread feature is disabled.");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (flushTimer) {
|
|
320
|
+
clearInterval(flushTimer);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
console.log(`[Trace] Starting flush loop (interval: ${TRACE_FLUSH_INTERVAL_MS}ms)`);
|
|
324
|
+
|
|
325
|
+
flushTimer = setInterval(() => {
|
|
326
|
+
flushTraceEvents(client).catch((err) => {
|
|
327
|
+
console.error("[Trace] Flush error:", err);
|
|
328
|
+
});
|
|
329
|
+
}, TRACE_FLUSH_INTERVAL_MS);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Stop the flush loop (for graceful shutdown).
|
|
334
|
+
*/
|
|
335
|
+
export function stopTraceFlushLoop() {
|
|
336
|
+
if (flushTimer) {
|
|
337
|
+
clearInterval(flushTimer);
|
|
338
|
+
flushTimer = null;
|
|
339
|
+
}
|
|
340
|
+
}
|
package/server/typing.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord typing indicator management.
|
|
3
|
+
* Tracks per-channel typing state; starts, stops, and times out indicators.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { THINKING_FALLBACK_ENABLED, THINKING_FALLBACK_TEXT, TYPING_INTERVAL_MS, TYPING_MAX_MS } from "./config.ts";
|
|
7
|
+
|
|
8
|
+
// Channel typing state: channelId -> { interval, timeout }
|
|
9
|
+
const typingSessions = new Map<
|
|
10
|
+
string,
|
|
11
|
+
{ interval: ReturnType<typeof setInterval>; timeout: ReturnType<typeof setTimeout> }
|
|
12
|
+
>();
|
|
13
|
+
|
|
14
|
+
async function sendTypingOnce(client: any, channelId: string) {
|
|
15
|
+
if (!channelId || !client.user) return;
|
|
16
|
+
try {
|
|
17
|
+
const channel = await client.channels.fetch(channelId);
|
|
18
|
+
if (!channel || !channel.isTextBased() || typeof channel.sendTyping !== "function") return;
|
|
19
|
+
await channel.sendTyping();
|
|
20
|
+
} catch (err: unknown) {
|
|
21
|
+
const code = (err as any)?.code;
|
|
22
|
+
if (code === 50001 || code === 50013) {
|
|
23
|
+
// Missing Access or Missing Permissions — stop retrying this channel
|
|
24
|
+
console.warn(`[Relay] typing indicator stopped for channel ${channelId}: ${(err as Error).message}`);
|
|
25
|
+
const state = typingSessions.get(channelId);
|
|
26
|
+
if (state) {
|
|
27
|
+
clearInterval(state.interval);
|
|
28
|
+
clearTimeout(state.timeout);
|
|
29
|
+
typingSessions.delete(channelId);
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
console.warn(`[Relay] typing indicator failed for channel ${channelId}: ${(err as Error).message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function sendThinkingFallback(client: any, channelId: string, persistOutbound: (...args: any[]) => any) {
|
|
38
|
+
if (!THINKING_FALLBACK_ENABLED) return;
|
|
39
|
+
if (!channelId || !client.user) return;
|
|
40
|
+
try {
|
|
41
|
+
const channel = await client.channels.fetch(channelId);
|
|
42
|
+
if (!channel || !channel.isTextBased()) return;
|
|
43
|
+
const sent = await channel.send(THINKING_FALLBACK_TEXT);
|
|
44
|
+
persistOutbound({
|
|
45
|
+
content: THINKING_FALLBACK_TEXT,
|
|
46
|
+
channelId,
|
|
47
|
+
externalId: sent.id,
|
|
48
|
+
fromAgent: "relay",
|
|
49
|
+
});
|
|
50
|
+
console.log(`[Relay] thinking fallback sent in channel ${channelId}`);
|
|
51
|
+
} catch (err: unknown) {
|
|
52
|
+
console.warn(`[Relay] thinking fallback failed for channel ${channelId}: ${(err as Error).message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function startTypingIndicator(client: any, channelId: string, persistOutbound: (...args: any[]) => any) {
|
|
57
|
+
if (!channelId) return;
|
|
58
|
+
if (typingSessions.has(channelId)) return;
|
|
59
|
+
|
|
60
|
+
const startedAt = Date.now();
|
|
61
|
+
const interval = setInterval(() => {
|
|
62
|
+
if (Date.now() - startedAt > TYPING_MAX_MS) {
|
|
63
|
+
stopTypingIndicator(client, channelId, persistOutbound, "max-duration", { sendFallback: true });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
void sendTypingOnce(client, channelId);
|
|
67
|
+
}, TYPING_INTERVAL_MS);
|
|
68
|
+
|
|
69
|
+
const timeout = setTimeout(() => {
|
|
70
|
+
stopTypingIndicator(client, channelId, persistOutbound, "timeout", { sendFallback: true });
|
|
71
|
+
}, TYPING_MAX_MS + 1000);
|
|
72
|
+
|
|
73
|
+
typingSessions.set(channelId, { interval, timeout });
|
|
74
|
+
void sendTypingOnce(client, channelId);
|
|
75
|
+
console.log(`[Relay] typing indicator started for channel ${channelId}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function stopTypingIndicator(
|
|
79
|
+
client: any,
|
|
80
|
+
channelId: string,
|
|
81
|
+
persistOutbound: (...args: any[]) => any,
|
|
82
|
+
reason: string = "completed",
|
|
83
|
+
{ sendFallback = false } = {},
|
|
84
|
+
) {
|
|
85
|
+
const state = typingSessions.get(channelId);
|
|
86
|
+
if (!state) return;
|
|
87
|
+
clearInterval(state.interval);
|
|
88
|
+
clearTimeout(state.timeout);
|
|
89
|
+
typingSessions.delete(channelId);
|
|
90
|
+
console.log(`[Relay] typing indicator stopped for channel ${channelId} (${reason})`);
|
|
91
|
+
|
|
92
|
+
if (sendFallback) {
|
|
93
|
+
void sendThinkingFallback(client, channelId, persistOutbound);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function stopAllTypingSessions(client: any, persistOutbound: (...args: any[]) => any) {
|
|
98
|
+
for (const [channelId] of typingSessions) {
|
|
99
|
+
stopTypingIndicator(client, channelId, persistOutbound, "shutdown");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inspect memory_turns for debugging retrieval issues.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node tools/memory-inspect.js # show summary + recent turns
|
|
8
|
+
* node tools/memory-inspect.js --search mattermost # search for keyword
|
|
9
|
+
* node tools/memory-inspect.js --all # dump all turns
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Database as DatabaseSync } from "bun:sqlite";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const ROOT_DIR = process.env.ORCHESTRATOR_DIR || join(__dirname, "..");
|
|
18
|
+
const dbPath = join(ROOT_DIR, "data", "memory.db");
|
|
19
|
+
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
let searchTerm: string | null = null;
|
|
22
|
+
let showAll = false;
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < args.length; i++) {
|
|
25
|
+
if (args[i] === "--search" && args[i + 1]) searchTerm = args[++i];
|
|
26
|
+
else if (args[i] === "--all") showAll = true;
|
|
27
|
+
else if (!args[i].startsWith("-")) searchTerm = args[i];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const db = new DatabaseSync(dbPath);
|
|
31
|
+
|
|
32
|
+
const total = (db.prepare("SELECT COUNT(*) c FROM memory_turns").get() as any).c;
|
|
33
|
+
console.log(`total turns: ${total}`);
|
|
34
|
+
|
|
35
|
+
const rs = db.prepare("SELECT * FROM memory_runtime_state").all() as any[];
|
|
36
|
+
console.log(`runtime states: ${rs.length}`);
|
|
37
|
+
for (const r of rs) {
|
|
38
|
+
console.log(
|
|
39
|
+
` session=${r.session_key} ctx=${r.runtime_context_id} epoch=${r.runtime_epoch} updated=${r.updated_at}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sessions = db.prepare("SELECT session_key, COUNT(*) c FROM memory_turns GROUP BY session_key").all() as any[];
|
|
44
|
+
console.log(`sessions:`);
|
|
45
|
+
for (const s of sessions) console.log(` ${s.session_key}: ${s.c} turns`);
|
|
46
|
+
|
|
47
|
+
if (searchTerm) {
|
|
48
|
+
const pattern = `%${searchTerm.toLowerCase()}%`;
|
|
49
|
+
const hits = db
|
|
50
|
+
.prepare(`
|
|
51
|
+
SELECT turn_index, role, content, created_at, metadata_json
|
|
52
|
+
FROM memory_turns
|
|
53
|
+
WHERE LOWER(content) LIKE ?
|
|
54
|
+
ORDER BY turn_index ASC
|
|
55
|
+
`)
|
|
56
|
+
.all(pattern) as any[];
|
|
57
|
+
console.log(`\nsearch "${searchTerm}": ${hits.length} hits`);
|
|
58
|
+
for (const t of hits) {
|
|
59
|
+
const meta = JSON.parse(t.metadata_json || "{}");
|
|
60
|
+
const rc = meta.runtimeContextId || "null";
|
|
61
|
+
const content = t.content.replace(/\n/g, " ").slice(0, 200);
|
|
62
|
+
console.log(` [${t.turn_index}] ${t.created_at} rc=${rc} ${t.role}: ${content}`);
|
|
63
|
+
}
|
|
64
|
+
} else if (showAll) {
|
|
65
|
+
const all = db
|
|
66
|
+
.prepare(`
|
|
67
|
+
SELECT turn_index, role, content, created_at, metadata_json
|
|
68
|
+
FROM memory_turns ORDER BY turn_index ASC
|
|
69
|
+
`)
|
|
70
|
+
.all() as any[];
|
|
71
|
+
for (const t of all) {
|
|
72
|
+
const meta = JSON.parse(t.metadata_json || "{}");
|
|
73
|
+
const rc = meta.runtimeContextId || "null";
|
|
74
|
+
const content = t.content.replace(/\n/g, " ").slice(0, 200);
|
|
75
|
+
console.log(`[${t.turn_index}] ${t.created_at} rc=${rc} ${t.role}: ${content}`);
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
const recent = db
|
|
79
|
+
.prepare(`
|
|
80
|
+
SELECT turn_index, role, content, created_at, metadata_json
|
|
81
|
+
FROM memory_turns ORDER BY turn_index DESC LIMIT 20
|
|
82
|
+
`)
|
|
83
|
+
.all() as any[];
|
|
84
|
+
console.log(`\nlast 20 turns:`);
|
|
85
|
+
recent.reverse();
|
|
86
|
+
for (const t of recent) {
|
|
87
|
+
const meta = JSON.parse(t.metadata_json || "{}");
|
|
88
|
+
const rc = meta.runtimeContextId || "null";
|
|
89
|
+
const content = t.content.replace(/\n/g, " ").slice(0, 200);
|
|
90
|
+
console.log(` [${t.turn_index}] ${t.created_at} rc=${rc} ${t.role}: ${content}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
db.close();
|