@hoverlover/cc-discord 0.2.4 → 0.3.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/README.md CHANGED
@@ -37,7 +37,6 @@ Edit those files with your credentials, then run again. Override the config loca
37
37
  git clone https://github.com/hoverlover/cc-discord.git
38
38
  cd cc-discord
39
39
  bun install
40
- bun run generate-settings
41
40
  bun start
42
41
  ```
43
42
 
@@ -120,6 +119,7 @@ Project-local env files take precedence over `~/.config/cc-discord/`, so cloned-
120
119
  | `MAX_ATTACHMENT_INLINE_BYTES` | `100000` | Max bytes for inline attachment content |
121
120
  | `MAX_ATTACHMENT_DOWNLOAD_BYTES` | `10000000` | Max bytes for downloaded attachments |
122
121
  | `ATTACHMENT_TTL_MS` | `3600000` | TTL for downloaded attachment files (ms) |
122
+ | `CATCHUP_MESSAGE_LIMIT` | `100` | Messages to fetch per channel on startup for catch-up (`0` to disable) |
123
123
 
124
124
  ### `.env.worker` — required
125
125
 
@@ -262,6 +262,160 @@ In interactive mode, the orchestrator runs as a Claude Code session with a visib
262
262
 
263
263
  ---
264
264
 
265
+ ## Running as a daemon
266
+
267
+ To keep cc-discord running across reboots and terminal closures, install it as a system service.
268
+
269
+ ### macOS (launchd)
270
+
271
+ 1. Find the full paths to `bunx` and `claude`:
272
+
273
+ ```bash
274
+ which bunx # e.g. /Users/you/.bun/bin/bunx
275
+ which claude # e.g. /Users/you/.local/bin/claude
276
+ ```
277
+
278
+ 2. Create the plist file (replace all `/Users/you/...` paths with your actual paths from step 1):
279
+
280
+ ```bash
281
+ cat > ~/Library/LaunchAgents/com.cc-discord.plist << 'EOF'
282
+ <?xml version="1.0" encoding="UTF-8"?>
283
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
284
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
285
+ <plist version="1.0">
286
+ <dict>
287
+ <key>Label</key>
288
+ <string>com.cc-discord</string>
289
+
290
+ <key>ProgramArguments</key>
291
+ <array>
292
+ <!-- Replace with the output of: which bunx -->
293
+ <string>/Users/you/.bun/bin/bunx</string>
294
+ <string>@hoverlover/cc-discord</string>
295
+ </array>
296
+
297
+ <key>EnvironmentVariables</key>
298
+ <dict>
299
+ <!-- Must include directories for both bunx and claude -->
300
+ <key>PATH</key>
301
+ <string>/Users/you/.bun/bin:/Users/you/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
302
+ </dict>
303
+
304
+ <key>RunAtLoad</key>
305
+ <true/>
306
+
307
+ <key>KeepAlive</key>
308
+ <true/>
309
+
310
+ <key>StandardOutPath</key>
311
+ <string>/tmp/cc-discord/launchd-stdout.log</string>
312
+
313
+ <key>StandardErrorPath</key>
314
+ <string>/tmp/cc-discord/launchd-stderr.log</string>
315
+ </dict>
316
+ </plist>
317
+ EOF
318
+ ```
319
+
320
+ 3. **Important:** launchd does not source your shell profile, so the `PATH` must explicitly include the directories for both `bunx` and `claude`. Verify the paths match your system.
321
+
322
+ 4. Load the service:
323
+
324
+ ```bash
325
+ mkdir -p /tmp/cc-discord
326
+ launchctl load ~/Library/LaunchAgents/com.cc-discord.plist
327
+ ```
328
+
329
+ 5. Manage the service:
330
+
331
+ ```bash
332
+ # Check status
333
+ launchctl list | grep cc-discord
334
+
335
+ # Stop
336
+ launchctl stop com.cc-discord
337
+
338
+ # Start
339
+ launchctl start com.cc-discord
340
+
341
+ # Uninstall
342
+ launchctl unload ~/Library/LaunchAgents/com.cc-discord.plist
343
+ rm ~/Library/LaunchAgents/com.cc-discord.plist
344
+ ```
345
+
346
+ ### Linux (systemd)
347
+
348
+ 1. Find the full paths to `bunx` and `claude`:
349
+
350
+ ```bash
351
+ which bunx # e.g. /home/you/.bun/bin/bunx
352
+ which claude # e.g. /home/you/.local/bin/claude
353
+ ```
354
+
355
+ 2. Create the service file:
356
+
357
+ ```bash
358
+ sudo cat > /etc/systemd/system/cc-discord.service << 'EOF'
359
+ [Unit]
360
+ Description=cc-discord — Discord <-> Claude Code relay
361
+ After=network-online.target
362
+ Wants=network-online.target
363
+
364
+ [Service]
365
+ Type=simple
366
+ # Replace "you" with your username
367
+ User=you
368
+ # Replace with the output of: which bunx
369
+ ExecStart=/home/you/.bun/bin/bunx @hoverlover/cc-discord
370
+ # Ensure bun and claude are on PATH
371
+ Environment=PATH=/home/you/.bun/bin:/home/you/.local/bin:/usr/local/bin:/usr/bin:/bin
372
+ Environment=HOME=/home/you
373
+ Restart=on-failure
374
+ RestartSec=10
375
+
376
+ [Install]
377
+ WantedBy=multi-user.target
378
+ EOF
379
+ ```
380
+
381
+ 3. Replace `you` with your actual username and update the paths to match your system.
382
+
383
+ 4. Enable and start the service:
384
+
385
+ ```bash
386
+ sudo systemctl daemon-reload
387
+ sudo systemctl enable cc-discord
388
+ sudo systemctl start cc-discord
389
+ ```
390
+
391
+ 5. Manage the service:
392
+
393
+ ```bash
394
+ # Check status
395
+ systemctl status cc-discord
396
+
397
+ # View logs
398
+ journalctl -u cc-discord -f
399
+
400
+ # Stop
401
+ sudo systemctl stop cc-discord
402
+
403
+ # Restart
404
+ sudo systemctl restart cc-discord
405
+
406
+ # Disable (won't start on boot)
407
+ sudo systemctl disable cc-discord
408
+ ```
409
+
410
+ ### Notes
411
+
412
+ - Both approaches run `bunx @hoverlover/cc-discord`, which is the same as `bun start` in the cloned repo.
413
+ - Environment files are read from `~/.config/cc-discord/` — make sure those are configured before starting the service.
414
+ - Application logs still go to `CC_DISCORD_LOG_DIR` (default `/tmp/cc-discord/logs`). The launchd/systemd logs are separate and capture startup errors.
415
+ - Claude CLI must be authenticated (`claude auth login`) as the user the service runs under before starting.
416
+
417
+ ---
418
+
265
419
  ## Development
266
420
 
267
421
  ### Scripts
@@ -273,10 +427,8 @@ In interactive mode, the orchestrator runs as a Claude Code session with a visib
273
427
  | `bun run start:orchestrator` | Start headless orchestrator only |
274
428
  | `bun run start:orchestrator-interactive` | Start interactive orchestrator (terminal UI) |
275
429
  | `bun run dev` | Alias for `start:relay` |
276
- | `bun run generate-settings` | Generate `.claude/settings.json` with absolute hook paths |
277
430
  | `bun run memory:smoke` | Run memory system smoke test |
278
431
  | `bun run memory:inspect` | Inspect memory database contents |
279
- | `bun run memory:migrate` | Migrate memory to channel-scoped keys |
280
432
  | `bun run lint` | Run Biome linter |
281
433
  | `bun run lint:fix` | Run Biome linter with auto-fix |
282
434
  | `bun run format` | Format code with Biome |
@@ -284,7 +436,7 @@ In interactive mode, the orchestrator runs as a Claude Code session with a visib
284
436
 
285
437
  ### Hook system
286
438
 
287
- Claude Code hooks are configured in `.claude/settings.json` (generated from `.claude/settings.template.json` by `bun run generate-settings`). The template uses `__ORCHESTRATOR_DIR__` placeholders that are replaced with absolute paths at generation time.
439
+ Claude Code hooks are configured in `.claude/settings.local.json`, generated automatically from `.claude/settings.template.json` when the relay starts. This file is gitignored and only exists while the relay is running — `start.sh` creates it on startup and removes it on shutdown so hooks don't interfere with normal development. The template uses `__ORCHESTRATOR_DIR__` placeholders that are replaced with absolute paths at generation time.
288
440
 
289
441
  | Hook | Event | Description |
290
442
  |---|---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoverlover/cc-discord",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "Discord <-> Claude Code relay: use your Claude subscription to power per-channel AI bots",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,10 +25,8 @@
25
25
  "start:orchestrator": "bash scripts/orchestrator.sh",
26
26
  "start:orchestrator-interactive": "bash scripts/start-orchestrator.sh",
27
27
  "dev": "bash scripts/start-relay.sh",
28
- "generate-settings": "bash scripts/generate-settings.sh",
29
28
  "memory:smoke": "bun tools/memory-smoke.ts",
30
29
  "memory:inspect": "bun tools/memory-inspect.ts",
31
- "memory:migrate": "bun scripts/migrate-memory-to-channel-keys.ts",
32
30
  "lint": "bunx biome check .",
33
31
  "lint:fix": "bunx biome check --write .",
34
32
  "format": "bunx biome format --write .",
@@ -47,7 +47,7 @@ load_env_keys "${CC_DISCORD_CONFIG_DIR:-$HOME/.config/cc-discord}/.env" "${WORKE
47
47
  # Ensure bun is on PATH for hooks/tools
48
48
  export PATH="$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$ROOT_DIR/tools:$PATH"
49
49
 
50
- SETTINGS_PATH="$ROOT_DIR/.claude/settings.json"
50
+ SETTINGS_PATH="$ROOT_DIR/.claude/settings.local.json"
51
51
  PROMPT_TEMPLATE="$ROOT_DIR/prompts/channel-system.md"
52
52
 
53
53
  if ! command -v claude >/dev/null 2>&1; then
@@ -11,7 +11,7 @@ SCRIPT_DIR="$(cd "$(dirname "$_SCRIPT")" && pwd)"
11
11
  ORCHESTRATOR_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
12
12
 
13
13
  TEMPLATE="$ORCHESTRATOR_DIR/.claude/settings.template.json"
14
- OUTPUT="$ORCHESTRATOR_DIR/.claude/settings.json"
14
+ OUTPUT="$ORCHESTRATOR_DIR/.claude/settings.local.json"
15
15
 
16
16
  if [ ! -f "$TEMPLATE" ]; then
17
17
  echo "Template not found: $TEMPLATE"
@@ -34,7 +34,7 @@ load_env_keys "${CC_DISCORD_CONFIG_DIR:-$HOME/.config/cc-discord}/.env.worker" "
34
34
  load_env_keys "$ROOT_DIR/.env" "${WORKER_KEYS[@]}"
35
35
  load_env_keys "${CC_DISCORD_CONFIG_DIR:-$HOME/.config/cc-discord}/.env" "${WORKER_KEYS[@]}"
36
36
 
37
- SETTINGS_PATH="$ROOT_DIR/.claude/settings.json"
37
+ SETTINGS_PATH="$ROOT_DIR/.claude/settings.local.json"
38
38
  SYSTEM_PROMPT_PATH="$ROOT_DIR/prompts/orchestrator-system.md"
39
39
 
40
40
  if ! command -v claude >/dev/null 2>&1; then
package/scripts/start.sh CHANGED
@@ -85,11 +85,11 @@ if [ -d "$ROOT_DIR/.claude/skills" ] && [ "$ROOT_DIR" != "$CC_DISCORD_HOME" ]; t
85
85
  done
86
86
  fi
87
87
 
88
- # Seed settings.json so Claude sees project config.
89
- if [ "$ROOT_DIR" != "$CC_DISCORD_HOME" ] && [ -f "$ROOT_DIR/.claude/settings.template.json" ]; then
88
+ # Generate settings.local.json (hooks + relay permissions) so Claude agents see project config.
89
+ if [ -f "$ROOT_DIR/.claude/settings.template.json" ]; then
90
90
  bash "$ROOT_DIR/scripts/generate-settings.sh"
91
- if [ -f "$ROOT_DIR/.claude/settings.json" ]; then
92
- cp "$ROOT_DIR/.claude/settings.json" "$CC_DISCORD_HOME/.claude/settings.json"
91
+ if [ "$ROOT_DIR" != "$CC_DISCORD_HOME" ] && [ -f "$ROOT_DIR/.claude/settings.local.json" ]; then
92
+ cp "$ROOT_DIR/.claude/settings.local.json" "$CC_DISCORD_HOME/.claude/settings.local.json"
93
93
  fi
94
94
  fi
95
95
 
@@ -120,6 +120,10 @@ cleanup() {
120
120
  wait "$RELAY_PID" 2>/dev/null || true
121
121
  fi
122
122
 
123
+ # Remove generated settings.local.json so hooks don't fire during normal development
124
+ rm -f "$ROOT_DIR/.claude/settings.local.json"
125
+ rm -f "$CC_DISCORD_HOME/.claude/settings.local.json"
126
+
123
127
  log "All processes stopped."
124
128
  exit 0
125
129
  }
@@ -145,7 +149,7 @@ RELAY_PORT="${RELAY_PORT:-3199}"
145
149
  RELAY_API_TOKEN="${RELAY_API_TOKEN:-}"
146
150
 
147
151
  # Ensure bun/curl/claude are findable
148
- export PATH="$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
152
+ export PATH="$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
149
153
 
150
154
  # ---- Pre-flight: Claude auth check ----
151
155
  if ! claude auth status >/dev/null 2>&1; then
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Catch-up: fetch recent messages from allowed channels on startup
3
+ * to fill in any messages missed while the relay was offline.
4
+ */
5
+
6
+ import type { Client } from "discord.js";
7
+ import { CATCHUP_MESSAGE_LIMIT, IGNORED_CHANNEL_IDS, ALLOWED_CHANNEL_IDS, isAllowedUser } from "./config.ts";
8
+ import { persistInboundDiscordMessage, persistOutboundDiscordMessage } from "./messages.ts";
9
+ import { startTypingIndicator } from "./typing.ts";
10
+
11
+ export async function catchUpMissedMessages(client: Client) {
12
+ if (CATCHUP_MESSAGE_LIMIT <= 0) {
13
+ console.log("[Catchup] Disabled (CATCHUP_MESSAGE_LIMIT=0)");
14
+ return;
15
+ }
16
+
17
+ let channelsScanned = 0;
18
+ let messagesCaughtUp = 0;
19
+
20
+ for (const [, guild] of client.guilds.cache) {
21
+ const guildChannels = await guild.channels.fetch();
22
+ for (const [, channel] of guildChannels) {
23
+ if (!channel || !channel.isTextBased() || channel.isThread() || channel.isVoiceBased()) continue;
24
+ if (IGNORED_CHANNEL_IDS.has(channel.id)) continue;
25
+ if (ALLOWED_CHANNEL_IDS.length > 0 && !ALLOWED_CHANNEL_IDS.includes(channel.id)) continue;
26
+
27
+ try {
28
+ const messages = await channel.messages.fetch({ limit: CATCHUP_MESSAGE_LIMIT });
29
+ channelsScanned++;
30
+
31
+ // Sort oldest-first so they're persisted in chronological order
32
+ const sorted = [...messages.values()]
33
+ .filter((m) => !m.author.bot && isAllowedUser(m.author.id))
34
+ .sort((a, b) => a.createdTimestamp - b.createdTimestamp);
35
+
36
+ let channelHasNew = false;
37
+ for (const msg of sorted) {
38
+ const isNew = await persistInboundDiscordMessage(msg);
39
+ if (isNew) {
40
+ messagesCaughtUp++;
41
+ channelHasNew = true;
42
+ }
43
+ }
44
+ if (channelHasNew) {
45
+ startTypingIndicator(client, channel.id, persistOutboundDiscordMessage);
46
+ }
47
+ } catch (err: unknown) {
48
+ console.error(`[Catchup] Failed to fetch messages for #${channel.name} (${channel.id}):`, (err as Error).message);
49
+ }
50
+ }
51
+ }
52
+
53
+ console.log(`[Catchup] Done — scanned ${channelsScanned} channel(s), caught up ${messagesCaughtUp} message(s)`);
54
+ }
package/server/config.ts CHANGED
@@ -83,8 +83,23 @@ export const MAX_ATTACHMENT_INLINE_BYTES = Number(process.env.MAX_ATTACHMENT_INL
83
83
  export const MAX_ATTACHMENT_DOWNLOAD_BYTES = Number(process.env.MAX_ATTACHMENT_DOWNLOAD_BYTES || 10_000_000);
84
84
  export const ATTACHMENT_TTL_MS = Number(process.env.ATTACHMENT_TTL_MS || 3_600_000);
85
85
 
86
+ export const CATCHUP_MESSAGE_LIMIT = Number(process.env.CATCHUP_MESSAGE_LIMIT ?? 100);
87
+
86
88
  export const ATTACHMENT_DIR = join("/tmp", "cc-discord", "attachments");
87
89
 
90
+ export function isAllowedChannel(channelId: string): boolean {
91
+ if (!channelId) return false;
92
+ if (IGNORED_CHANNEL_IDS.has(channelId)) return false;
93
+ if (ALLOWED_CHANNEL_IDS.length > 0) return ALLOWED_CHANNEL_IDS.includes(channelId);
94
+ return true;
95
+ }
96
+
97
+ export function isAllowedUser(userId: string | undefined): boolean {
98
+ if (!userId) return false;
99
+ if (ALLOWED_DISCORD_USER_IDS.length === 0) return true;
100
+ return ALLOWED_DISCORD_USER_IDS.includes(userId);
101
+ }
102
+
88
103
  function loadDotEnv(path: string) {
89
104
  if (!existsSync(path)) return;
90
105
  const raw = readFileSync(path, "utf8");
package/server/index.ts CHANGED
@@ -4,6 +4,7 @@ import { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder } from "di
4
4
  import express, { type NextFunction, type Request, type Response } from "express";
5
5
  import { cleanupOldAttachments } from "./attachment.ts";
6
6
  import { maybeNotifyBusyQueued } from "./busy-notify.ts";
7
+ import { catchUpMissedMessages } from "./catchup.ts";
7
8
  import {
8
9
  ALLOWED_CHANNEL_IDS,
9
10
  ALLOWED_DISCORD_USER_IDS,
@@ -13,6 +14,8 @@ import {
13
14
  DISCORD_BOT_TOKEN,
14
15
  DISCORD_SESSION_ID,
15
16
  IGNORED_CHANNEL_IDS,
17
+ isAllowedChannel,
18
+ isAllowedUser,
16
19
  MESSAGE_ROUTING_MODE,
17
20
  RELAY_ALLOW_NO_AUTH,
18
21
  RELAY_API_TOKEN,
@@ -39,19 +42,6 @@ const client = new Client({
39
42
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
40
43
  });
41
44
 
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
45
  function requireAuth(req: Request, res: Response): boolean {
56
46
  if (RELAY_ALLOW_NO_AUTH) return true;
57
47
  const token = req.header("x-api-token") || req.header("authorization")?.replace(/^Bearer\s+/i, "");
@@ -104,6 +94,11 @@ client.once("clientReady", async () => {
104
94
 
105
95
  // Start live trace thread flush loop
106
96
  startTraceFlushLoop(client);
97
+
98
+ // Catch up messages missed while offline
99
+ catchUpMissedMessages(client).catch((err) => {
100
+ console.error("[Relay] Catch-up failed:", (err as Error).message);
101
+ });
107
102
  });
108
103
 
109
104
  client.on("messageCreate", async (message) => {
@@ -26,7 +26,7 @@ export async function formatInboundMessage(message: any) {
26
26
  return `${author}: ${fullText}`;
27
27
  }
28
28
 
29
- export async function persistInboundDiscordMessage(message: any) {
29
+ export async function persistInboundDiscordMessage(message: any): Promise<boolean> {
30
30
  const normalizedContent = await formatInboundMessage(message);
31
31
  // In channel mode, route to channelId so per-channel subagents consume independently.
32
32
  // In agent mode (legacy), route to CLAUDE_AGENT_ID for single-agent consumption.
@@ -57,13 +57,15 @@ export async function persistInboundDiscordMessage(message: any) {
57
57
  });
58
58
 
59
59
  console.log(`[Relay] queued Discord message ${message.id} -> ${targetAgent}`);
60
+ return true;
60
61
  } catch (err: unknown) {
61
62
  const msg = String((err as any)?.message || "");
62
63
  if (msg.includes("UNIQUE constraint failed")) {
63
64
  // Discord can re-deliver in edge cases; idempotent ignore
64
- return;
65
+ return false;
65
66
  }
66
67
  console.error("[Relay] failed to persist inbound message:", (err as Error).message);
68
+ return false;
67
69
  }
68
70
  }
69
71
 
@@ -1,149 +0,0 @@
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 defaultDataDir = process.env.CC_DISCORD_DATA_DIR || join(process.env.HOME || "", ".cc-discord", "data");
23
- const dbPath = process.argv[2] || join(defaultDataDir, "memory.db");
24
-
25
- console.log(`[migrate] Opening ${dbPath}`);
26
- const db = new Database(dbPath);
27
-
28
- const OLD_SESSION_KEY = "discord:default:claude-discord";
29
-
30
- // Read all turns under the old key
31
- const turns = db
32
- .prepare(`
33
- SELECT id, session_key, turn_index, role, content, metadata_json, created_at
34
- FROM memory_turns
35
- WHERE session_key = ?
36
- ORDER BY turn_index ASC
37
- `)
38
- .all(OLD_SESSION_KEY) as any[];
39
-
40
- console.log(`[migrate] Found ${turns.length} turns under ${OLD_SESSION_KEY}`);
41
-
42
- if (turns.length === 0) {
43
- console.log("[migrate] Nothing to migrate.");
44
- db.close();
45
- process.exit(0);
46
- }
47
-
48
- // Group by channelId from metadata
49
- const byChannel = new Map<string, typeof turns>();
50
- const noChannel: typeof turns = [];
51
-
52
- for (const turn of turns) {
53
- let channelId: string | null = null;
54
- try {
55
- const meta = JSON.parse(String((turn as any).metadata_json || "{}"));
56
- channelId = meta.channelId || null;
57
- } catch {}
58
-
59
- if (!channelId) {
60
- noChannel.push(turn);
61
- continue;
62
- }
63
-
64
- if (!byChannel.has(channelId)) byChannel.set(channelId, []);
65
- byChannel.get(channelId)!.push(turn);
66
- }
67
-
68
- console.log(`[migrate] Splitting into ${byChannel.size} channels:`);
69
- for (const [ch, chTurns] of byChannel) {
70
- console.log(` discord:default:${ch} -> ${chTurns.length} turns`);
71
- }
72
- if (noChannel.length > 0) {
73
- console.log(` (${noChannel.length} turns have no channelId -- left in old key)`);
74
- }
75
-
76
- // Find the current max turn_index for each target channel key
77
- function getMaxTurnIndex(sessionKey: string): number {
78
- const row = db
79
- .prepare("SELECT MAX(turn_index) as max_idx FROM memory_turns WHERE session_key = ?")
80
- .get(sessionKey) as any;
81
- return row?.max_idx ?? 0;
82
- }
83
-
84
- // Ensure per-channel session keys exist in memory_sessions
85
- const upsertSession = db.prepare(`
86
- INSERT OR IGNORE INTO memory_sessions (session_key, created_at)
87
- VALUES (?, datetime('now'))
88
- `);
89
-
90
- // Update each turn's session_key and re-index
91
- const updateTurn = db.prepare(`
92
- UPDATE memory_turns SET session_key = ?, turn_index = ? WHERE id = ?
93
- `);
94
-
95
- // Copy runtime state for new keys
96
- const readRuntimeState = db.prepare(`
97
- SELECT * FROM memory_runtime_state WHERE session_key = ?
98
- `);
99
- const upsertRuntimeState = db.prepare(`
100
- INSERT OR IGNORE INTO memory_runtime_state (session_key, runtime_context_id, runtime_epoch, updated_at)
101
- VALUES (?, ?, ?, datetime('now'))
102
- `);
103
-
104
- db.exec("BEGIN TRANSACTION");
105
- try {
106
- const oldRuntime = readRuntimeState.get(OLD_SESSION_KEY) as any;
107
-
108
- for (const [channelId, chTurns] of byChannel) {
109
- const newKey = `discord:default:${channelId}`;
110
- upsertSession.run(newKey);
111
-
112
- // Start numbering after any existing turns in the target key
113
- const startIndex = getMaxTurnIndex(newKey) + 1;
114
-
115
- for (let i = 0; i < chTurns.length; i++) {
116
- updateTurn.run(newKey, startIndex + i, chTurns[i].id);
117
- }
118
-
119
- // Bootstrap runtime state for the new key if it doesn't exist
120
- if (oldRuntime) {
121
- upsertRuntimeState.run(newKey, `migrated_from_${OLD_SESSION_KEY}`, 1);
122
- }
123
-
124
- console.log(
125
- `[migrate] Updated ${chTurns.length} turns -> ${newKey} (indices ${startIndex}..${startIndex + chTurns.length - 1})`,
126
- );
127
- }
128
-
129
- db.exec("COMMIT");
130
- console.log("[migrate] Migration complete.");
131
- } catch (err) {
132
- db.exec("ROLLBACK");
133
- console.error("[migrate] Migration failed, rolled back:", (err as Error).message || err);
134
- process.exit(1);
135
- }
136
-
137
- // Summary
138
- for (const [channelId] of byChannel) {
139
- const newKey = `discord:default:${channelId}`;
140
- const count = (db.prepare("SELECT COUNT(*) as cnt FROM memory_turns WHERE session_key = ?").get(newKey) as any).cnt;
141
- console.log(` ${newKey}: ${count} turns total`);
142
- }
143
-
144
- const remaining = (
145
- db.prepare("SELECT COUNT(*) as cnt FROM memory_turns WHERE session_key = ?").get(OLD_SESSION_KEY) as any
146
- ).cnt;
147
- console.log(` ${OLD_SESSION_KEY}: ${remaining} turns remaining`);
148
-
149
- db.close();