@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 @@
1
+ export { SqliteMemoryStore } from "./SqliteMemoryStore.ts";
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@hoverlover/cc-discord",
3
+ "version": "0.1.0",
4
+ "description": "Discord <-> Claude Code relay: use your Claude subscription to power per-channel AI bots",
5
+ "type": "module",
6
+ "bin": {
7
+ "cc-discord": "./scripts/start.sh"
8
+ },
9
+ "files": [
10
+ "scripts/",
11
+ "server/",
12
+ "hooks/",
13
+ "tools/",
14
+ "prompts/",
15
+ "memory/",
16
+ ".claude/settings.template.json",
17
+ ".env.example",
18
+ ".env.relay.example",
19
+ ".env.worker.example"
20
+ ],
21
+ "scripts": {
22
+ "start": "bash scripts/start.sh",
23
+ "start:relay": "bash scripts/start-relay.sh",
24
+ "start:orchestrator": "bash scripts/orchestrator.sh",
25
+ "start:orchestrator-interactive": "bash scripts/start-orchestrator.sh",
26
+ "dev": "bash scripts/start-relay.sh",
27
+ "generate-settings": "bash scripts/generate-settings.sh",
28
+ "memory:smoke": "bun tools/memory-smoke.ts",
29
+ "memory:inspect": "bun tools/memory-inspect.ts",
30
+ "memory:migrate": "bun scripts/migrate-memory-to-channel-keys.ts",
31
+ "lint": "bunx biome check .",
32
+ "lint:fix": "bunx biome check --write .",
33
+ "format": "bunx biome format --write .",
34
+ "typecheck": "bunx tsc --noEmit"
35
+ },
36
+ "dependencies": {
37
+ "discord.js": "^14.23.2",
38
+ "express": "^4.21.2"
39
+ },
40
+ "devDependencies": {
41
+ "@biomejs/biome": "^2.4.4",
42
+ "@types/bun": "^1.3.9",
43
+ "@types/express": "^5.0.6"
44
+ }
45
+ }
@@ -0,0 +1,32 @@
1
+ You are running in autonomous Discord reply mode for a local relay.
2
+
3
+ ## Goal
4
+ Automatically read incoming Discord messages and send a useful reply back to Discord without asking for terminal input.
5
+
6
+ ## Infinite loop behavior
7
+ Repeat forever:
8
+
9
+ 1. Run:
10
+ wait-for-discord-messages --deliver --timeout 600
11
+
12
+ 2. If output contains:
13
+ NEW DISCORD MESSAGE(S): ...
14
+ then read the message content and decide on a reply.
15
+
16
+ If output is empty (no messages / timeout), immediately continue the loop.
17
+
18
+ 3. Send exactly one reply message with:
19
+ send-discord "<your reply>"
20
+
21
+ 4. Immediately go back to step 1.
22
+
23
+ ## Rules
24
+ - Do not ask the terminal user for confirmation.
25
+ - Do not narrate internal status (no "waiting...", "checking...", etc.).
26
+ - Keep replies concise and useful.
27
+ - Keep each reply under 1800 characters.
28
+ - If multiple inbound messages are delivered together, prioritize the newest question/request.
29
+ - If polling times out with no messages, continue the loop.
30
+ - If the wait command is interrupted by the terminal user (e.g., Esc), treat it as a manual override: handle the user’s requested command/task, then return to step 1.
31
+ - Never use shell background operators (`&`) in commands. Use `run_in_background: true` Bash parameter instead when needed.
32
+ - Never stop unless explicitly told by the terminal user.
@@ -0,0 +1,22 @@
1
+ You are a Discord bot responsible for the #__CHANNEL_NAME__ channel.
2
+
3
+ ## Loop
4
+ Repeat forever:
5
+ 1. Run: `AGENT_ID=__CHANNEL_ID__ wait-for-discord-messages --deliver --timeout 600`
6
+ 2. If output contains NEW DISCORD MESSAGE(S), read the content and craft a reply.
7
+ 3. Send reply: `send-discord --channel __CHANNEL_ID__ "your reply"`
8
+ 4. Go back to step 1.
9
+
10
+ IMPORTANT: Always set AGENT_ID=__CHANNEL_ID__ as an env var prefix on every wait-for-discord-messages call. This is how messages are routed to you.
11
+
12
+ ## Steering
13
+ - If your send-discord call is blocked with new messages, read them carefully, revise your reply to address them, and send the updated reply.
14
+ - Do not resend the same text that was blocked.
15
+
16
+ ## Rules
17
+ - Keep replies under 1800 characters.
18
+ - If polling times out with no messages, continue the loop.
19
+ - Never stop unless explicitly told.
20
+ - Do not ask the terminal user for confirmation.
21
+ - Do not narrate internal status.
22
+ - Never use shell background operators (&). Use `run_in_background: true` Bash parameter instead when needed.
@@ -0,0 +1,56 @@
1
+ You are the orchestrator for a multi-channel Discord bot. Your job is to spawn one subagent per channel and keep them healthy.
2
+
3
+ ## Startup
4
+
5
+ 1. Discover channels by running:
6
+ ```
7
+ curl -s -H "x-api-token: $RELAY_API_TOKEN" http://${RELAY_HOST:-127.0.0.1}:${RELAY_PORT:-3199}/api/channels
8
+ ```
9
+ This returns JSON with `{ success: true, channels: [{ id, name, model, ... }] }`.
10
+
11
+ 2. For each channel, spawn a subagent using the Agent tool. Give each subagent a prompt like:
12
+
13
+ > You are a Discord bot responsible for the #CHANNEL_NAME channel.
14
+ >
15
+ > ## Loop
16
+ > Repeat forever:
17
+ > 1. Run: `AGENT_ID=CHANNEL_ID wait-for-discord-messages --deliver --timeout 600`
18
+ > 2. If output contains NEW DISCORD MESSAGE(S), read the content and craft a reply.
19
+ > 3. Send reply: `send-discord --channel CHANNEL_ID "your reply"`
20
+ > 4. Go back to step 1.
21
+ >
22
+ > IMPORTANT: Always set AGENT_ID=CHANNEL_ID as an env var prefix on every wait-for-discord-messages call. This is how messages are routed to you.
23
+ >
24
+ > ## Rules
25
+ > - Keep replies under 1800 characters.
26
+ > - If polling times out with no messages, continue the loop.
27
+ > - Never stop unless explicitly told.
28
+ > - Do not ask the terminal user for confirmation.
29
+ > - Do not narrate internal status.
30
+ > - Never use shell background operators (&). Use `run_in_background: true` Bash parameter instead when needed.
31
+
32
+ Replace CHANNEL_NAME with the channel's name and CHANNEL_ID with the channel's id in the prompt above.
33
+
34
+ ## Health check loop
35
+
36
+ After all subagents are spawned, repeat forever:
37
+
38
+ 1. Wait for up to 300 seconds using:
39
+ ```
40
+ wait-for-discord-messages --timeout 300
41
+ ```
42
+ This returns after 300s or when any message arrives (whichever comes first).
43
+
44
+ 2. After waking, perform these checks:
45
+ - **Subagent health:** If a subagent has stopped or errored, restart it by spawning a new Agent for that channel.
46
+ - **New channels:** Re-fetch `/api/channels` and compare to your known channel list. If a new channel has appeared, spawn a subagent for it.
47
+
48
+ 3. Go back to step 1.
49
+
50
+ ## Rules
51
+ - Do not ask the terminal user for confirmation.
52
+ - Do not narrate internal status (no "waiting...", "checking...", etc.).
53
+ - Never use shell background operators (`&`) in commands. Use `run_in_background: true` Bash parameter instead when needed.
54
+ - Never stop unless explicitly told by the terminal user.
55
+ - If a subagent dies, restart it promptly on the next health check cycle.
56
+ - You do NOT consume Discord messages yourself. Only subagents do.
@@ -0,0 +1,159 @@
1
+ #!/bin/bash
2
+ # channel-agent.sh — Run a single Claude channel agent in headless (-p) mode.
3
+ #
4
+ # Usage: channel-agent.sh <channel_id> <channel_name>
5
+ #
6
+ # Designed to be spawned by orchestrator.sh. Each invocation runs one
7
+ # claude -p session that polls for messages and replies. When claude
8
+ # exits (e.g., max turns, cost limit, or normal stop), the orchestrator
9
+ # restarts this script to create a persistent loop.
10
+
11
+ set -euo pipefail
12
+
13
+ CHANNEL_ID="${1:?Usage: channel-agent.sh <channel_id> <channel_name>}"
14
+ CHANNEL_NAME="${2:-channel-$CHANNEL_ID}"
15
+
16
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
17
+ source "$ROOT_DIR/scripts/load-env.sh"
18
+
19
+ # Load worker env vars (same set as start-orchestrator.sh)
20
+ WORKER_KEYS=(
21
+ DISCORD_SESSION_ID
22
+ CLAUDE_AGENT_ID
23
+ RELAY_HOST
24
+ RELAY_PORT
25
+ RELAY_URL
26
+ RELAY_API_TOKEN
27
+ AUTO_REPLY_PERMISSION_MODE
28
+ CLAUDE_RUNTIME_ID
29
+ WAIT_QUIET_TIMEOUT
30
+ BASH_POLICY_MODE
31
+ ALLOW_BASH_RUN_IN_BACKGROUND
32
+ ALLOW_BASH_BACKGROUND_OPS
33
+ BASH_POLICY_NOTIFY_ON_BLOCK
34
+ BASH_POLICY_NOTIFY_CHANNEL_ID
35
+ )
36
+ load_env_keys "$ROOT_DIR/.env.worker" "${WORKER_KEYS[@]}"
37
+ load_env_keys "${CC_DISCORD_CONFIG_DIR:-$HOME/.config/cc-discord}/.env.worker" "${WORKER_KEYS[@]}"
38
+ load_env_keys "$ROOT_DIR/.env" "${WORKER_KEYS[@]}"
39
+ load_env_keys "${CC_DISCORD_CONFIG_DIR:-$HOME/.config/cc-discord}/.env" "${WORKER_KEYS[@]}"
40
+
41
+ # Ensure bun is on PATH for hooks/tools
42
+ export PATH="$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$ROOT_DIR/tools:$PATH"
43
+
44
+ SETTINGS_PATH="$ROOT_DIR/.claude/settings.json"
45
+ PROMPT_TEMPLATE="$ROOT_DIR/prompts/channel-system.md"
46
+
47
+ if ! command -v claude >/dev/null 2>&1; then
48
+ echo "[channel-agent:$CHANNEL_NAME] Error: 'claude' CLI not found on PATH" >&2
49
+ exit 1
50
+ fi
51
+
52
+ if [ ! -f "$SETTINGS_PATH" ]; then
53
+ echo "[channel-agent:$CHANNEL_NAME] Generating settings..."
54
+ bash "$ROOT_DIR/scripts/generate-settings.sh"
55
+ fi
56
+
57
+ if [ ! -f "$PROMPT_TEMPLATE" ]; then
58
+ echo "[channel-agent:$CHANNEL_NAME] Missing prompt template: $PROMPT_TEMPLATE" >&2
59
+ exit 1
60
+ fi
61
+
62
+ # Defensive cleanup: never leak relay-only secrets to Claude
63
+ unset DISCORD_BOT_TOKEN DISCORD_CHANNEL_ID DISCORD_ALLOWED_CHANNEL_IDS
64
+
65
+ # Kill orphaned poller processes from previous runs of this channel agent.
66
+ # These linger when Claude exits but its child wait-for-discord-messages keeps polling.
67
+ POLLER_LOCK="/tmp/cc-discord/poller-${CHANNEL_ID}-${DISCORD_SESSION_ID:-default}.lock"
68
+ if [ -f "$POLLER_LOCK" ]; then
69
+ OLD_PID=$(cat "$POLLER_LOCK" 2>/dev/null)
70
+ if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then
71
+ echo "[channel-agent:$CHANNEL_NAME] Killing orphaned poller (PID $OLD_PID)"
72
+ kill -TERM "$OLD_PID" 2>/dev/null || true
73
+ sleep 1
74
+ kill -9 "$OLD_PID" 2>/dev/null || true
75
+ fi
76
+ rm -f "$POLLER_LOCK"
77
+ fi
78
+
79
+ export ORCHESTRATOR_DIR="$ROOT_DIR"
80
+ export DISCORD_SESSION_ID="${DISCORD_SESSION_ID:-default}"
81
+ export AGENT_ID="$CHANNEL_ID"
82
+ export CLAUDE_AGENT_ID="${CLAUDE_AGENT_ID:-claude-discord}"
83
+ export CLAUDE_RUNTIME_ID="${CLAUDE_RUNTIME_ID:-rt_$(date +%s)_${RANDOM}}"
84
+
85
+ # Build the channel-specific system prompt
86
+ SYSTEM_PROMPT="$(sed \
87
+ -e "s|__CHANNEL_ID__|${CHANNEL_ID}|g" \
88
+ -e "s|__CHANNEL_NAME__|${CHANNEL_NAME}|g" \
89
+ "$PROMPT_TEMPLATE")"
90
+
91
+ # Permission mode
92
+ if [ "${AUTO_REPLY_PERMISSION_MODE:-skip}" = "accept-edits" ]; then
93
+ PERMISSION_ARGS=(--permission-mode acceptEdits)
94
+ else
95
+ PERMISSION_ARGS=(--dangerously-skip-permissions)
96
+ fi
97
+
98
+ # Log directory
99
+ LOG_DIR="${CC_DISCORD_LOG_DIR:-/tmp/cc-discord/logs}"
100
+ mkdir -p "$LOG_DIR"
101
+ LOG_FILE="${LOG_DIR}/channel-${CHANNEL_NAME}-${CHANNEL_ID}.log"
102
+
103
+ PARSER="$ROOT_DIR/scripts/parse-claude-stream.ts"
104
+
105
+ echo "[channel-agent:$CHANNEL_NAME] Starting claude -p (channel=$CHANNEL_ID, session=$DISCORD_SESSION_ID, runtime=$CLAUDE_RUNTIME_ID)"
106
+ echo "[channel-agent:$CHANNEL_NAME] Logging to $LOG_FILE"
107
+
108
+ # Write the system prompt to a temp file to avoid quoting issues in pipes.
109
+ PROMPT_FILE=$(mktemp /tmp/cc-discord-prompt-XXXXXX)
110
+ printf '%s' "$SYSTEM_PROMPT" > "$PROMPT_FILE"
111
+
112
+ # On exit: clean up temp file, kill orphaned pollers, and kill child processes.
113
+ cleanup_agent() {
114
+ rm -f "$PROMPT_FILE"
115
+ # Kill any poller left behind by this session
116
+ if [ -f "$POLLER_LOCK" ]; then
117
+ local lpid
118
+ lpid=$(cat "$POLLER_LOCK" 2>/dev/null)
119
+ if [ -n "$lpid" ] && kill -0 "$lpid" 2>/dev/null; then
120
+ kill -TERM "$lpid" 2>/dev/null || true
121
+ fi
122
+ rm -f "$POLLER_LOCK"
123
+ fi
124
+ # Kill any remaining children (claude, parser, pollers)
125
+ local pids
126
+ pids=$(jobs -p 2>/dev/null) || true
127
+ if [ -n "$pids" ]; then
128
+ kill $pids 2>/dev/null || true
129
+ fi
130
+ }
131
+ trap cleanup_agent EXIT
132
+
133
+ # Run claude in headless/print mode with stream-json output.
134
+ # The stream is piped through the parser which extracts reasoning, tool
135
+ # calls, and errors into human-readable log lines.
136
+ # If the parser is missing, fall back to raw output.
137
+ if [ -f "$PARSER" ]; then
138
+ claude \
139
+ -p \
140
+ --output-format stream-json \
141
+ --verbose \
142
+ --settings "$SETTINGS_PATH" \
143
+ "${PERMISSION_ARGS[@]}" \
144
+ --system-prompt-file "$PROMPT_FILE" \
145
+ --no-session-persistence \
146
+ -- "Begin listening for messages in #${CHANNEL_NAME} now." 2>&1 \
147
+ | bun "$PARSER" >> "$LOG_FILE" 2>&1
148
+ else
149
+ echo "[channel-agent:$CHANNEL_NAME] WARNING: Parser not found at $PARSER — using raw output"
150
+ claude \
151
+ -p \
152
+ --output-format stream-json \
153
+ --settings "$SETTINGS_PATH" \
154
+ "${PERMISSION_ARGS[@]}" \
155
+ --system-prompt-file "$PROMPT_FILE" \
156
+ --no-session-persistence \
157
+ -- "Begin listening for messages in #${CHANNEL_NAME} now." \
158
+ >> "$LOG_FILE" 2>&1
159
+ fi
@@ -0,0 +1,17 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ ORCHESTRATOR_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
6
+
7
+ TEMPLATE="$ORCHESTRATOR_DIR/.claude/settings.template.json"
8
+ OUTPUT="$ORCHESTRATOR_DIR/.claude/settings.json"
9
+
10
+ if [ ! -f "$TEMPLATE" ]; then
11
+ echo "Template not found: $TEMPLATE"
12
+ exit 1
13
+ fi
14
+
15
+ sed -e "s|__ORCHESTRATOR_DIR__|$ORCHESTRATOR_DIR|g" "$TEMPLATE" > "$OUTPUT"
16
+
17
+ echo "Generated: $OUTPUT"
@@ -0,0 +1,79 @@
1
+ #!/bin/bash
2
+ # Load KEY=VALUE pairs from .env into the current shell without overriding
3
+ # variables that are already set.
4
+
5
+ _parse_env_line() {
6
+ local line="$1"
7
+ # Normalize CRLF endings
8
+ line="${line%$'\r'}"
9
+
10
+ # Skip blanks/comments
11
+ [[ -z "$line" ]] && return 1
12
+ [[ "$line" =~ ^[[:space:]]*# ]] && return 1
13
+ [[ "$line" == *=* ]] || return 1
14
+
15
+ local key="${line%%=*}"
16
+ local value="${line#*=}"
17
+
18
+ # Trim surrounding whitespace from key/value
19
+ key="${key#"${key%%[![:space:]]*}"}"
20
+ key="${key%"${key##*[![:space:]]}"}"
21
+ value="${value#"${value%%[![:space:]]*}"}"
22
+ value="${value%"${value##*[![:space:]]}"}"
23
+
24
+ # Valid shell variable names only
25
+ [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || return 1
26
+
27
+ # Remove matching surrounding quotes
28
+ if [[ "$value" =~ ^\".*\"$ ]]; then
29
+ value="${value:1:${#value}-2}"
30
+ elif [[ "$value" =~ ^\'.*\'$ ]]; then
31
+ value="${value:1:${#value}-2}"
32
+ fi
33
+
34
+ ENV_KEY="$key"
35
+ ENV_VALUE="$value"
36
+ return 0
37
+ }
38
+
39
+ load_env_file() {
40
+ local env_file="${1:-.env}"
41
+ if [ ! -f "$env_file" ]; then
42
+ return 0
43
+ fi
44
+
45
+ while IFS= read -r line || [ -n "$line" ]; do
46
+ _parse_env_line "$line" || continue
47
+ # Keep explicit environment overrides
48
+ if [ -z "${!ENV_KEY+x}" ]; then
49
+ export "$ENV_KEY=$ENV_VALUE"
50
+ fi
51
+ done < "$env_file"
52
+ }
53
+
54
+ # Load only specified keys from an env file.
55
+ # Usage: load_env_keys /path/to/.env KEY1 KEY2 ...
56
+ load_env_keys() {
57
+ local env_file="${1:-.env}"
58
+ shift || true
59
+
60
+ if [ ! -f "$env_file" ]; then
61
+ return 0
62
+ fi
63
+
64
+ # Build lookup table of requested keys
65
+ local wanted=" "
66
+ local key
67
+ for key in "$@"; do
68
+ wanted+="$key "
69
+ done
70
+
71
+ while IFS= read -r line || [ -n "$line" ]; do
72
+ _parse_env_line "$line" || continue
73
+ [[ "$wanted" == *" $ENV_KEY "* ]] || continue
74
+
75
+ if [ -z "${!ENV_KEY+x}" ]; then
76
+ export "$ENV_KEY=$ENV_VALUE"
77
+ fi
78
+ done < "$env_file"
79
+ }
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * One-time migration: re-key memory_turns from the legacy shared session key
4
+ * (discord:default:claude-discord) into per-channel session keys
5
+ * (discord:default:{channelId}).
6
+ *
7
+ * Each turn's channelId is read from its metadata_json. Turns without a
8
+ * channelId are left in the old key.
9
+ *
10
+ * Turn indices are renumbered per-channel to be sequential, starting after
11
+ * any existing turns already in that channel key.
12
+ *
13
+ * Usage:
14
+ * bun scripts/migrate-memory-to-channel-keys.ts [path/to/memory.db]
15
+ */
16
+
17
+ import { Database } from "bun:sqlite";
18
+ import { dirname, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ const dbPath = process.argv[2] || join(__dirname, "..", "data", "memory.db");
23
+
24
+ console.log(`[migrate] Opening ${dbPath}`);
25
+ const db = new Database(dbPath);
26
+
27
+ const OLD_SESSION_KEY = "discord:default:claude-discord";
28
+
29
+ // Read all turns under the old key
30
+ const turns = db
31
+ .prepare(`
32
+ SELECT id, session_key, turn_index, role, content, metadata_json, created_at
33
+ FROM memory_turns
34
+ WHERE session_key = ?
35
+ ORDER BY turn_index ASC
36
+ `)
37
+ .all(OLD_SESSION_KEY) as any[];
38
+
39
+ console.log(`[migrate] Found ${turns.length} turns under ${OLD_SESSION_KEY}`);
40
+
41
+ if (turns.length === 0) {
42
+ console.log("[migrate] Nothing to migrate.");
43
+ db.close();
44
+ process.exit(0);
45
+ }
46
+
47
+ // Group by channelId from metadata
48
+ const byChannel = new Map<string, typeof turns>();
49
+ const noChannel: typeof turns = [];
50
+
51
+ for (const turn of turns) {
52
+ let channelId: string | null = null;
53
+ try {
54
+ const meta = JSON.parse(String((turn as any).metadata_json || "{}"));
55
+ channelId = meta.channelId || null;
56
+ } catch {}
57
+
58
+ if (!channelId) {
59
+ noChannel.push(turn);
60
+ continue;
61
+ }
62
+
63
+ if (!byChannel.has(channelId)) byChannel.set(channelId, []);
64
+ byChannel.get(channelId)!.push(turn);
65
+ }
66
+
67
+ console.log(`[migrate] Splitting into ${byChannel.size} channels:`);
68
+ for (const [ch, chTurns] of byChannel) {
69
+ console.log(` discord:default:${ch} -> ${chTurns.length} turns`);
70
+ }
71
+ if (noChannel.length > 0) {
72
+ console.log(` (${noChannel.length} turns have no channelId -- left in old key)`);
73
+ }
74
+
75
+ // Find the current max turn_index for each target channel key
76
+ function getMaxTurnIndex(sessionKey: string): number {
77
+ const row = db
78
+ .prepare("SELECT MAX(turn_index) as max_idx FROM memory_turns WHERE session_key = ?")
79
+ .get(sessionKey) as any;
80
+ return row?.max_idx ?? 0;
81
+ }
82
+
83
+ // Ensure per-channel session keys exist in memory_sessions
84
+ const upsertSession = db.prepare(`
85
+ INSERT OR IGNORE INTO memory_sessions (session_key, created_at)
86
+ VALUES (?, datetime('now'))
87
+ `);
88
+
89
+ // Update each turn's session_key and re-index
90
+ const updateTurn = db.prepare(`
91
+ UPDATE memory_turns SET session_key = ?, turn_index = ? WHERE id = ?
92
+ `);
93
+
94
+ // Copy runtime state for new keys
95
+ const readRuntimeState = db.prepare(`
96
+ SELECT * FROM memory_runtime_state WHERE session_key = ?
97
+ `);
98
+ const upsertRuntimeState = db.prepare(`
99
+ INSERT OR IGNORE INTO memory_runtime_state (session_key, runtime_context_id, runtime_epoch, updated_at)
100
+ VALUES (?, ?, ?, datetime('now'))
101
+ `);
102
+
103
+ db.exec("BEGIN TRANSACTION");
104
+ try {
105
+ const oldRuntime = readRuntimeState.get(OLD_SESSION_KEY) as any;
106
+
107
+ for (const [channelId, chTurns] of byChannel) {
108
+ const newKey = `discord:default:${channelId}`;
109
+ upsertSession.run(newKey);
110
+
111
+ // Start numbering after any existing turns in the target key
112
+ const startIndex = getMaxTurnIndex(newKey) + 1;
113
+
114
+ for (let i = 0; i < chTurns.length; i++) {
115
+ updateTurn.run(newKey, startIndex + i, chTurns[i].id);
116
+ }
117
+
118
+ // Bootstrap runtime state for the new key if it doesn't exist
119
+ if (oldRuntime) {
120
+ upsertRuntimeState.run(newKey, `migrated_from_${OLD_SESSION_KEY}`, 1);
121
+ }
122
+
123
+ console.log(
124
+ `[migrate] Updated ${chTurns.length} turns -> ${newKey} (indices ${startIndex}..${startIndex + chTurns.length - 1})`,
125
+ );
126
+ }
127
+
128
+ db.exec("COMMIT");
129
+ console.log("[migrate] Migration complete.");
130
+ } catch (err) {
131
+ db.exec("ROLLBACK");
132
+ console.error("[migrate] Migration failed, rolled back:", (err as Error).message || err);
133
+ process.exit(1);
134
+ }
135
+
136
+ // Summary
137
+ for (const [channelId] of byChannel) {
138
+ const newKey = `discord:default:${channelId}`;
139
+ const count = (db.prepare("SELECT COUNT(*) as cnt FROM memory_turns WHERE session_key = ?").get(newKey) as any).cnt;
140
+ console.log(` ${newKey}: ${count} turns total`);
141
+ }
142
+
143
+ const remaining = (
144
+ db.prepare("SELECT COUNT(*) as cnt FROM memory_turns WHERE session_key = ?").get(OLD_SESSION_KEY) as any
145
+ ).cnt;
146
+ console.log(` ${OLD_SESSION_KEY}: ${remaining} turns remaining`);
147
+
148
+ db.close();