@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
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Claude Code hook: deliver unread Discord messages into Claude context.
4
+ *
5
+ * Hook input: snake_case fields (hook_event_name, tool_name, ...)
6
+ * Hook output: camelCase fields (hookEventName, additionalContext)
7
+ */
8
+
9
+ // Suppress Node.js ExperimentalWarning (SQLite) to keep hook output clean
10
+ const _origEmit = process.emit;
11
+ process.emit = function (event: string, ...args: any[]) {
12
+ if (event === "warning" && args[0]?.name === "ExperimentalWarning") return false;
13
+ return _origEmit.call(this, event, ...args) as any;
14
+ };
15
+
16
+ import { Database as DatabaseSync } from "bun:sqlite";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { MemoryCoordinator } from "../memory/core/MemoryCoordinator.ts";
20
+ import { buildMemorySessionKey } from "../memory/core/session-key.ts";
21
+ import { SqliteMemoryStore } from "../memory/providers/sqlite/SqliteMemoryStore.ts";
22
+
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+ const ROOT_DIR = process.env.ORCHESTRATOR_DIR || join(__dirname, "..");
25
+
26
+ const agentId = process.env.AGENT_ID || process.env.CLAUDE_AGENT_ID || "claude";
27
+ const sessionId =
28
+ process.env.DISCORD_SESSION_ID || process.env.BROKER_SESSION_ID || process.env.SESSION_ID || "default";
29
+
30
+ const dbPath = join(ROOT_DIR, "data", "messages.db");
31
+
32
+ const noopLogger = {
33
+ log() {},
34
+ warn() {},
35
+ error() {},
36
+ };
37
+
38
+ async function syncRuntimeContext({ hookEvent, hookInput }: { hookEvent: string; hookInput: any }) {
39
+ const memoryDbPath = join(ROOT_DIR, "data", "memory.db");
40
+ const memorySessionKey = buildMemorySessionKey({ sessionId, agentId });
41
+ const runtimeHint = process.env.CLAUDE_RUNTIME_ID || hookInput?.session_id || hookInput?.sessionId || null;
42
+
43
+ let store: SqliteMemoryStore | undefined;
44
+ try {
45
+ store = new SqliteMemoryStore({ dbPath: memoryDbPath, logger: noopLogger });
46
+ const coordinator = new MemoryCoordinator({ store, logger: noopLogger });
47
+ await coordinator.init();
48
+
49
+ if (hookEvent === "SessionStart") {
50
+ return await coordinator.beginNewRuntimeContext({
51
+ sessionKey: memorySessionKey,
52
+ runtimeContextId: runtimeHint ? `${runtimeHint}_start_${Date.now().toString(36)}` : null,
53
+ });
54
+ }
55
+
56
+ return await coordinator.ensureRuntimeContext({
57
+ sessionKey: memorySessionKey,
58
+ runtimeContextId: runtimeHint,
59
+ });
60
+ } catch {
61
+ return null;
62
+ } finally {
63
+ if (store) {
64
+ try {
65
+ await store.close();
66
+ } catch {
67
+ /* ignore */
68
+ }
69
+ }
70
+ }
71
+ }
72
+
73
+ async function buildMemoryContext({ queryText, runtimeState }: { queryText: string; runtimeState: any }) {
74
+ const memoryDbPath = join(ROOT_DIR, "data", "memory.db");
75
+ const memorySessionKey = buildMemorySessionKey({ sessionId, agentId });
76
+
77
+ let store: SqliteMemoryStore | undefined;
78
+ try {
79
+ store = new SqliteMemoryStore({ dbPath: memoryDbPath, logger: noopLogger });
80
+ const coordinator = new MemoryCoordinator({ store, logger: noopLogger });
81
+ await coordinator.init();
82
+
83
+ const packet = await coordinator.assembleContext({
84
+ sessionKey: memorySessionKey,
85
+ queryText,
86
+ runtimeContextId: runtimeState?.runtimeContextId || null,
87
+ runtimeEpoch: runtimeState?.runtimeEpoch || null,
88
+ includeSnapshot: true,
89
+ avoidCurrentRuntime: true,
90
+ activeWindowSize: 12,
91
+ maxCards: 6,
92
+ maxRecallTurns: 8,
93
+ maxTurnScan: 300,
94
+ });
95
+
96
+ return coordinator.formatContextPacket(packet);
97
+ } catch {
98
+ return "";
99
+ } finally {
100
+ if (store) {
101
+ try {
102
+ await store.close();
103
+ } catch {
104
+ /* ignore */
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ let hookInput: any;
111
+ try {
112
+ const chunks: Buffer[] = [];
113
+ for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
114
+ const raw = Buffer.concat(chunks).toString();
115
+ hookInput = raw ? JSON.parse(raw) : { hook_event_name: "PostToolUse" };
116
+ } catch {
117
+ process.exit(0);
118
+ }
119
+
120
+ const hookEvent = hookInput.hook_event_name || "PostToolUse";
121
+ const runtimeState = await syncRuntimeContext({ hookEvent, hookInput });
122
+
123
+ // Match direct agent IDs, generic "claude", and optionally base role
124
+ const baseRole = agentId.replace(/-\d+$/, "");
125
+ const targets = [...new Set([agentId, baseRole, "claude"])];
126
+
127
+ let db: InstanceType<typeof DatabaseSync>;
128
+ try {
129
+ db = new DatabaseSync(dbPath);
130
+ } catch {
131
+ process.exit(0);
132
+ }
133
+
134
+ try {
135
+ db.exec("BEGIN IMMEDIATE");
136
+
137
+ const placeholders = targets.map(() => "?").join(",");
138
+ const rows = db
139
+ .prepare(`
140
+ SELECT id, from_agent, message_type, content
141
+ FROM messages
142
+ WHERE session_id = ?
143
+ AND to_agent IN (${placeholders})
144
+ AND read = 0
145
+ ORDER BY id ASC
146
+ `)
147
+ .all(sessionId, ...targets) as any[];
148
+
149
+ if (rows.length > 0) {
150
+ const ids = rows.map((r: any) => r.id);
151
+ const idPlaceholders = ids.map(() => "?").join(",");
152
+ db.prepare(`UPDATE messages SET read = 1 WHERE id IN (${idPlaceholders})`).run(...ids);
153
+ db.exec("COMMIT");
154
+
155
+ const formatted = rows.map((r: any) => {
156
+ const oneLine = String(r.content).replace(/\r/g, "").replace(/\n/g, " ");
157
+ return `[MESSAGE from ${r.from_agent}] [${r.message_type}]: ${oneLine}`;
158
+ });
159
+ const inboxText = `NEW DISCORD MESSAGE(S): ${formatted.join(" | ")}`;
160
+
161
+ const latestQueryText = String(rows[rows.length - 1]?.content || "");
162
+ const memoryText = await buildMemoryContext({
163
+ queryText: latestQueryText,
164
+ runtimeState,
165
+ });
166
+ const contextText = memoryText ? `${inboxText}\n\n${memoryText}` : inboxText;
167
+
168
+ if (hookEvent === "Stop") {
169
+ process.stdout.write(
170
+ JSON.stringify({
171
+ decision: "block",
172
+ reason: `New Discord messages received. Process these before stopping.\n\n${contextText}`,
173
+ }),
174
+ );
175
+ process.exit(0);
176
+ }
177
+
178
+ process.stdout.write(
179
+ JSON.stringify({
180
+ hookSpecificOutput: {
181
+ hookEventName: hookEvent,
182
+ additionalContext: contextText,
183
+ },
184
+ }),
185
+ );
186
+ process.exit(0);
187
+ }
188
+
189
+ db.exec("COMMIT");
190
+ process.exit(0);
191
+ } catch {
192
+ try {
193
+ db.exec("ROLLBACK");
194
+ } catch {
195
+ /* ignore */
196
+ }
197
+ process.exit(0);
198
+ } finally {
199
+ try {
200
+ db.close();
201
+ } catch {
202
+ /* ignore */
203
+ }
204
+ }
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Claude Code hook: clean up downloaded Discord attachment files after they are read.
4
+ *
5
+ * Runs on PostToolUse for the Read tool. If the file path is inside
6
+ * /tmp/cc-discord/attachments/, delete it to keep the filesystem clean.
7
+ */
8
+
9
+ import { existsSync, unlinkSync } from "node:fs";
10
+
11
+ const ATTACHMENT_DIR = "/tmp/cc-discord/attachments/";
12
+
13
+ let hookInput: any;
14
+ try {
15
+ const chunks: Buffer[] = [];
16
+ for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
17
+ const raw = Buffer.concat(chunks).toString();
18
+ hookInput = raw ? JSON.parse(raw) : {};
19
+ } catch {
20
+ process.exit(0);
21
+ }
22
+
23
+ const toolName = hookInput.tool_name || hookInput.toolName || "";
24
+ const toolInput = hookInput.tool_input || hookInput.toolInput || {};
25
+
26
+ // Only act on Read tool calls
27
+ if (toolName !== "Read") {
28
+ process.exit(0);
29
+ }
30
+
31
+ const filePath = toolInput.file_path || toolInput.path || "";
32
+
33
+ // Only clean up files in our attachment directory
34
+ if (!filePath.startsWith(ATTACHMENT_DIR)) {
35
+ process.exit(0);
36
+ }
37
+
38
+ try {
39
+ if (existsSync(filePath)) {
40
+ unlinkSync(filePath);
41
+ // No console output to keep hook silent
42
+ }
43
+ } catch {
44
+ // fail-open: if we can't delete, the TTL cleanup in the relay will handle it
45
+ }
46
+
47
+ process.exit(0);
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * PreToolUse Bash guard.
4
+ *
5
+ * Policies:
6
+ * - Detects risky background execution patterns:
7
+ * 1) Bash tool_input.run_in_background=true
8
+ * 2) standalone '&' operator in command text
9
+ *
10
+ * Behavior is controlled by env vars:
11
+ * - BASH_POLICY_MODE=block|allow (default: block)
12
+ * - ALLOW_BASH_RUN_IN_BACKGROUND=true|false (default: true)
13
+ * - ALLOW_BASH_BACKGROUND_OPS=true|false (default: false)
14
+ * - BASH_POLICY_NOTIFY_ON_BLOCK=true|false (default: true)
15
+ * - BASH_POLICY_NOTIFY_CHANNEL_ID=<discord-channel-id> (optional)
16
+ *
17
+ * When blocked, script exits 2 (Claude Code hook "block" behavior).
18
+ */
19
+
20
+ function readJsonStdin(): Promise<any> {
21
+ return new Promise((resolve, reject) => {
22
+ const chunks: Buffer[] = [];
23
+ process.stdin.on("data", (chunk: Buffer) => chunks.push(chunk));
24
+ process.stdin.on("end", () => {
25
+ try {
26
+ const raw = Buffer.concat(chunks).toString();
27
+ resolve(raw ? JSON.parse(raw) : {});
28
+ } catch (err) {
29
+ reject(err);
30
+ }
31
+ });
32
+ process.stdin.on("error", reject);
33
+ });
34
+ }
35
+
36
+ function asBool(value: unknown, defaultValue: boolean = false): boolean {
37
+ if (value === undefined || value === null || value === "") return defaultValue;
38
+ const normalized = String(value).toLowerCase().trim();
39
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
40
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
41
+ return defaultValue;
42
+ }
43
+
44
+ function normalizeMode(value: unknown): "allow" | "block" {
45
+ const v = String(value || "block")
46
+ .toLowerCase()
47
+ .trim();
48
+ return v === "allow" ? "allow" : "block";
49
+ }
50
+
51
+ function hasStandaloneBackgroundOperator(command: string): boolean {
52
+ // Remove escaped ampersands and logical-and to reduce false positives.
53
+ const reduced = String(command || "")
54
+ .replace(/\\&/g, "")
55
+ .replace(/&&/g, "");
56
+
57
+ // Match standalone job-control '&' (e.g. "cmd &" or "; &").
58
+ return /(^|[\s;|])&($|[\s\n])/m.test(reduced);
59
+ }
60
+
61
+ function truncateCommand(command: string, max: number = 220): string {
62
+ const clean = String(command || "")
63
+ .replace(/[`\r\n\t]+/g, " ")
64
+ .trim();
65
+ if (clean.length <= max) return clean;
66
+ return `${clean.slice(0, max - 1)}…`;
67
+ }
68
+
69
+ async function notifyDiscord({ blocked, reasons, command }: { blocked: boolean; reasons: string[]; command: string }) {
70
+ const notifyEnabled = asBool(process.env.BASH_POLICY_NOTIFY_ON_BLOCK, true);
71
+ if (!notifyEnabled) return;
72
+
73
+ const relayHost = process.env.RELAY_HOST || "127.0.0.1";
74
+ const relayPort = process.env.RELAY_PORT || "3199";
75
+ const relayUrl = process.env.RELAY_URL || `http://${relayHost}:${relayPort}`;
76
+ const apiToken = process.env.RELAY_API_TOKEN || "";
77
+ const channelId = process.env.BASH_POLICY_NOTIFY_CHANNEL_ID || null;
78
+ const fromAgent = process.env.AGENT_ID || process.env.CLAUDE_AGENT_ID || "bash-guard";
79
+
80
+ const status = blocked ? "blocked" : "warning";
81
+ const reasonText = reasons.join("; ");
82
+ const commandText = truncateCommand(command);
83
+
84
+ const content = [
85
+ `⚠️ Bash safety policy ${status} a command.`,
86
+ `Agent: ${fromAgent}`,
87
+ `Reason: ${reasonText}`,
88
+ `Command: ${commandText}`,
89
+ ].join("\n");
90
+
91
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
92
+ if (apiToken) headers["x-api-token"] = apiToken;
93
+
94
+ const controller = new AbortController();
95
+ const timeout = setTimeout(() => controller.abort(), 1500);
96
+
97
+ try {
98
+ await fetch(`${relayUrl}/api/send`, {
99
+ method: "POST",
100
+ headers,
101
+ body: JSON.stringify({
102
+ content,
103
+ channelId,
104
+ fromAgent: "bash-guard",
105
+ }),
106
+ signal: controller.signal,
107
+ });
108
+ } catch {
109
+ // Best-effort notification only.
110
+ } finally {
111
+ clearTimeout(timeout);
112
+ }
113
+ }
114
+
115
+ try {
116
+ const input: any = await readJsonStdin();
117
+ const toolName = input.tool_name || input.toolName;
118
+ if (toolName !== "Bash") {
119
+ process.exit(0);
120
+ }
121
+
122
+ const toolInput = input.tool_input || input.toolInput || {};
123
+ const command = String(toolInput.command || "");
124
+
125
+ const mode = normalizeMode(process.env.BASH_POLICY_MODE);
126
+ const allowRunInBackground = asBool(process.env.ALLOW_BASH_RUN_IN_BACKGROUND, true);
127
+ const allowAmpersand = asBool(process.env.ALLOW_BASH_BACKGROUND_OPS, false);
128
+
129
+ const reasons: string[] = [];
130
+
131
+ if ((toolInput.run_in_background === true || toolInput.runInBackground === true) && !allowRunInBackground) {
132
+ reasons.push("run_in_background=true is disabled");
133
+ }
134
+
135
+ if (hasStandaloneBackgroundOperator(command) && !allowAmpersand) {
136
+ reasons.push("standalone & background operator is disabled");
137
+ }
138
+
139
+ if (reasons.length === 0) {
140
+ process.exit(0);
141
+ }
142
+
143
+ const blocked = mode !== "allow";
144
+ await notifyDiscord({ blocked, reasons, command });
145
+
146
+ if (blocked) {
147
+ console.error(`Bash safety policy blocked command: ${reasons.join("; ")}`);
148
+ process.exit(2);
149
+ }
150
+
151
+ process.exit(0);
152
+ } catch {
153
+ // Fail-open to avoid blocking all Bash calls on hook parse issues.
154
+ process.exit(0);
155
+ }
156
+
157
+ export {};
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * PreToolUse hook: block send-discord when unread messages exist.
4
+ *
5
+ * When a Discord user sends a follow-up while the agent is composing a reply,
6
+ * this hook intercepts the send-discord Bash call, delivers the new messages
7
+ * as the block reason, and forces the agent to revise its reply.
8
+ *
9
+ * Hook input: snake_case fields (hook_event_name, tool_name, tool_input)
10
+ * Hook output: { "decision": "block", "reason": "..." } or silent exit 0
11
+ */
12
+
13
+ import { Database as DatabaseSync } from "bun:sqlite";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const ROOT_DIR = process.env.ORCHESTRATOR_DIR || join(__dirname, "..");
19
+
20
+ const agentId = process.env.AGENT_ID || process.env.CLAUDE_AGENT_ID || "claude";
21
+ const sessionId =
22
+ process.env.DISCORD_SESSION_ID || process.env.BROKER_SESSION_ID || process.env.SESSION_ID || "default";
23
+
24
+ const dbPath = join(ROOT_DIR, "data", "messages.db");
25
+
26
+ let hookInput: any;
27
+ try {
28
+ const chunks: Buffer[] = [];
29
+ for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
30
+ const raw = Buffer.concat(chunks).toString();
31
+ hookInput = raw ? JSON.parse(raw) : {};
32
+ } catch {
33
+ process.exit(0); // fail-open
34
+ }
35
+
36
+ // Only intercept Bash calls that run send-discord
37
+ const toolName = hookInput.tool_name || hookInput.toolName;
38
+ if (toolName !== "Bash") process.exit(0);
39
+
40
+ const toolInput = hookInput.tool_input || hookInput.toolInput || {};
41
+ const command = String(toolInput.command || "");
42
+ if (!command.includes("send-discord")) process.exit(0);
43
+
44
+ // Check for unread messages
45
+ const baseRole = agentId.replace(/-\d+$/, "");
46
+ const targets = [...new Set([agentId, baseRole, "claude"])];
47
+
48
+ let db: InstanceType<typeof DatabaseSync>;
49
+ try {
50
+ db = new DatabaseSync(dbPath);
51
+ } catch {
52
+ process.exit(0); // fail-open: can't reach DB, allow send
53
+ }
54
+
55
+ try {
56
+ db.exec("BEGIN IMMEDIATE");
57
+
58
+ const placeholders = targets.map(() => "?").join(",");
59
+ const rows = db
60
+ .prepare(
61
+ `SELECT id, from_agent, message_type, content
62
+ FROM messages
63
+ WHERE session_id = ?
64
+ AND to_agent IN (${placeholders})
65
+ AND read = 0
66
+ ORDER BY id ASC`,
67
+ )
68
+ .all(sessionId, ...targets) as any[];
69
+
70
+ if (rows.length === 0) {
71
+ db.exec("COMMIT");
72
+ process.exit(0); // no new messages, allow send
73
+ }
74
+
75
+ // Mark consumed so check-discord-messages won't double-deliver
76
+ const ids = rows.map((r: any) => r.id);
77
+ const idPlaceholders = ids.map(() => "?").join(",");
78
+ db.prepare(`UPDATE messages SET read = 1 WHERE id IN (${idPlaceholders})`).run(...ids);
79
+ db.exec("COMMIT");
80
+
81
+ const formatted = rows.map((r: any) => {
82
+ const oneLine = String(r.content).replace(/\r/g, "").replace(/\n/g, " ");
83
+ return `[MESSAGE from ${r.from_agent}] [${r.message_type}]: ${oneLine}`;
84
+ });
85
+
86
+ const reason = [
87
+ "SEND BLOCKED: New messages arrived while you were composing a reply.",
88
+ "Read them carefully, revise your reply to address ALL messages, then re-send.",
89
+ "",
90
+ ...formatted,
91
+ ].join("\n");
92
+
93
+ process.stdout.write(JSON.stringify({ decision: "block", reason }));
94
+ process.exit(0);
95
+ } catch {
96
+ try {
97
+ db.exec("ROLLBACK");
98
+ } catch {
99
+ /* ignore */
100
+ }
101
+ process.exit(0); // fail-open
102
+ } finally {
103
+ try {
104
+ db.close();
105
+ } catch {
106
+ /* ignore */
107
+ }
108
+ }