Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/ship-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
## CI Settings

ci: node

## Test Settings

test-command: none
coverage-threshold: 80
3 changes: 3 additions & 0 deletions .claude/ship-initialized
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
initialized=2026-03-17T23:18:27+11:00
reviewer=claude-reviewer-max
ci=node
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Check for docs-only changes
id: docs-check
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
FILES=$(gh pr view ${{ github.event.pull_request.number }} --json files --jq '.files[].path')
else
FILES=$(git diff --name-only HEAD~1 2>/dev/null || echo "")
fi
NON_DOCS=$(echo "$FILES" | grep -vE '\.(md|txt)$|^LICENSE' || true)
if [ -z "$NON_DOCS" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
env:
GH_TOKEN: ${{ github.token }}

- uses: actions/setup-node@v4
if: steps.docs-check.outputs.skip != 'true'
with:
node-version: 20
cache: npm

- name: Install dependencies
if: steps.docs-check.outputs.skip != 'true'
run: npm ci

- name: Build
if: steps.docs-check.outputs.skip != 'true'
run: npm run build

- name: Lint
if: steps.docs-check.outputs.skip != 'true'
run: npm run lint
94 changes: 52 additions & 42 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,51 +2,77 @@

## Project Overview

Node.js bot service (Clawd) for Tech World. Uses LiveKit Agents framework to join rooms as a participant and respond to chat messages using Claude API.
Node.js bot service for Tech World. Runs two AI bot personalities — **Clawd** (friendly coding tutor) and **Gremlin** (chaotic hype creature) — using the LiveKit Agents framework. Each bot joins rooms as a participant and interacts via Claude API.

## Build & Run

```bash
npm install
npm run dev # local development with hot reload
npm run build # compile TypeScript
npm start # run compiled version
npm run dev -- --bot=clawd # local dev (or --bot=gremlin)
npm run build # compile TypeScript
npm start # run compiled version
npm run lint # ESLint
```

## Key Files

- `src/index.ts`: Main bot agent implementation
- `ecosystem.config.cjs`: PM2 process manager config (exponential backoff restarts)
- `package.json`: Node.js dependencies
- `src/bot-config.ts`: Per-bot configuration (identity, prompts, behavior)
- `src/prompts/clawd.ts`: Clawd personality prompts
- `src/prompts/gremlin.ts`: Gremlin personality prompts
- `src/agent-loop.ts`: Autonomous wandering and stuck detection
- `src/server.ts`: HTTP health server for Cloud Run
- `Dockerfile`: Multi-stage build for Cloud Run deployment
- `.env`: Environment variables (see Configuration)

## Architecture

Uses `@livekit/agents` framework (v1.0+):
1. Registers as a worker with LiveKit Cloud
1. Registers as a named worker with LiveKit Cloud (`agentName` from config)
2. Receives job dispatch when a user joins a room (see Agent Dispatch below)
3. Joins room as participant `bot-claude`
4. Listens for `chat` topic data messages
3. Joins room as participant (e.g., `bot-claude` or `bot-gremlin`)
4. Listens for `chat`, `help-request`, `map-info`, and `terminal-activity` data messages
5. Calls Claude API with conversation history
6. Publishes response on `chat-response` topic
7. On room disconnect, exits process so PM2 can restart it
6. Publishes responses on `chat-response` and `help-response` topics
7. Autonomously wanders the game world using A* pathfinding
8. Proactively approaches stuck players to offer help

### Dual-Bot Configuration

Bot behavior is driven by `BotConfig` in `src/bot-config.ts`:
- **Clawd** (`agentName: "clawd"`): Responds to all messages, evaluates challenges, gives hints
- **Gremlin** (`agentName: "gremlin"`): Only responds when addressed by name, no challenge eval or hints

Bot selection: `--bot=<name>` CLI arg → `BOT_NAME` env var → defaults to `clawd`.

### Agent Dispatch

LiveKit dispatches the bot via **token-based dispatch**: the Firebase Cloud Function (`retrieveLiveKitToken` in `tech_world_firebase_functions`) embeds a `RoomAgentDispatch` in every user's access token. When a user joins a room, LiveKit automatically dispatches the bot worker.
LiveKit dispatches bots via **token-based dispatch**: the Firebase Cloud Function (`retrieveLiveKitToken`) embeds `RoomAgentDispatch` entries for both `clawd` and `gremlin` in every user's access token.

**If a bot isn't being dispatched:**
1. Check Cloud Run logs — look for `"registered worker"` and `"received job request"`.
2. If worker registers but no dispatch, check `@livekit/agents` SDK compatibility.
3. Manual dispatch (emergency): `lk dispatch create --agent-name clawd --room <room-name>`

**Why not automatic dispatch?** LiveKit's default automatic dispatch only fires for *new* rooms. The `tech-world` room has a 5-minute `empty_timeout`, so if users sign out and back in quickly, the room persists and automatic dispatch never triggers.
### Deployment

**If the bot isn't being dispatched:**
1. Check `pm2 logs tech-world-bot` — look for `"registered worker"` (worker is connected) and `"received job request"` (dispatch received).
2. If worker registers but no dispatch, the `@livekit/agents` SDK version may be incompatible with LiveKit Cloud. Check `npm outdated @livekit/agents`.
3. Manual dispatch (emergency): use the LiveKit API `POST /twirp/livekit.AgentDispatchService/CreateDispatch {"room": "tech-world"}` (requires a signed JWT with admin grants).
Deployed as two Cloud Run services (one per bot) with scale-to-zero:

### Disconnect Handling
```bash
# Build and push image
gcloud builds submit --tag gcr.io/adventures-in-tech-world-0/tech-world-bot

# Deploy (same image, different BOT_NAME)
gcloud run deploy clawd-bot --image gcr.io/..../tech-world-bot \
--set-env-vars "BOT_NAME=clawd" --min-instances 0 --max-instances 1 \
--no-cpu-throttling --timeout 3600

The bot listens for `RoomEvent.Disconnected` and calls `process.exit(1)`. PM2 restarts the process with exponential backoff (`ecosystem.config.cjs`). On restart, the worker re-registers and waits for a new dispatch.
gcloud run deploy gremlin-bot --image gcr.io/..../tech-world-bot \
--set-env-vars "BOT_NAME=gremlin" --min-instances 0 --max-instances 1 \
--no-cpu-throttling --timeout 3600
```

Message history is scoped per room instance (inside the `entry` function) to prevent context leaking across restarts.
The Cloud Function wakes both services on user join. `--no-cpu-throttling` keeps the WebSocket and wandering loop alive between HTTP requests.

## Data Channel Protocol

Expand All @@ -63,14 +89,16 @@ Message history is scoped per room instance (inside the `entry` function) to pre
```json
{
"type": "chat-response",
"id": "message-id-response",
"id": "message-id-botname-response",
"messageId": "original-message-id",
"text": "Clawd's response",
"text": "Bot's response",
"senderName": "Clawd",
"timestamp": "2024-01-15T10:30:00.000Z"
}
```

Response IDs include the bot's `agentName` to prevent deduplication when both bots respond to the same message.

## Configuration

Create `.env`:
Expand All @@ -82,27 +110,9 @@ LIVEKIT_API_SECRET=your-api-secret
ANTHROPIC_API_KEY=your-anthropic-key
```

## Deployment

Runs on GCP Compute Engine (`tech-world-bot` instance) managed by PM2.

```bash
# SSH to server
gcloud compute ssh tech-world-bot --zone=us-central1-a --project=adventures-in-tech-world-0

# Check status
pm2 status

# View logs
pm2 logs tech-world-bot --lines 50

# Restart after update
cd ~/tech_world_bot && git pull && npm install && npm run build && pm2 restart tech-world-bot
```

## Notes

- Uses Claude Haiku 4.5 model (`claude-haiku-4-5-20251001`) for fast, cost-effective responses
- Keeps last 20 messages for conversation context
- System prompt configures "Clawd" personality as friendly coding tutor
- Challenge evaluations use a separate system prompt with structured `<!-- CHALLENGE_RESULT -->` tags for pass/fail parsing
- Challenge evaluations use structured `<!-- CHALLENGE_RESULT -->` tags for pass/fail parsing
- Requires `node:20` (not slim) — `@livekit/rtc-node` native binary needs system libraries
33 changes: 23 additions & 10 deletions ecosystem.config.cjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
module.exports = {
apps: [{
name: 'tech-world-bot',
script: 'dist/index.js',
args: 'start',
exp_backoff_restart_delay: 1000,
max_restarts: 20,
autorestart: true,
env: {
NODE_ENV: 'production',
apps: [
{
name: 'clawd-bot',
script: 'dist/index.js',
args: 'start --bot=clawd',
exp_backoff_restart_delay: 1000,
max_restarts: 20,
autorestart: true,
env: {
NODE_ENV: 'production',
},
},
}],
{
name: 'gremlin-bot',
script: 'dist/index.js',
args: 'start --bot=gremlin',
exp_backoff_restart_delay: 1000,
max_restarts: 20,
autorestart: true,
env: {
NODE_ENV: 'production',
},
},
],
};
10 changes: 10 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";

export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ["dist/"],
},
);
Loading
Loading