@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 @@
|
|
|
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();
|