Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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