Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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',
},
},
],
};
44 changes: 22 additions & 22 deletions src/agent-loop.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* Clawd's autonomous wandering behaviour.
* Autonomous wandering behaviour for bot characters.
*
* Picks random walkable destinations, pathfinds to them, publishes the full
* path so the client can animate smooth movement, then pauses before repeating.
*
* The loop is cancellable via AbortController for Phase 4 (approach player).
* The loop is cancellable via AbortController (e.g. to approach a stuck player).
*/

import type { JobContext } from "@livekit/agents";
Expand All @@ -15,6 +15,7 @@ import {
pathToDirections,
pathToPixels,
} from "./pathfinding.js";
import type { BotConfig } from "./bot-config.js";

/** Tracks a player who currently has a terminal editor open. */
export interface PlayerTerminalState {
Expand Down Expand Up @@ -43,28 +44,22 @@ export interface WorldState {
gridSize: number;
cellSize: number;
} | null;
/** Clawd's current position in mini-grid coordinates. */
/** Bot's current position in mini-grid coordinates. */
position: { x: number; y: number };
}

/** Movement timing — must match client's MoveToEffect duration. */
export const STEP_DURATION_MS = 200;

/** Pause range between walks (milliseconds). */
const MIN_PAUSE_MS = 2_000;
const MAX_PAUSE_MS = 5_000;

/** Maximum path length to avoid very long walks. */
const MAX_PATH_LENGTH = 20;

/** Publish a full movement path on the `position` data channel. */
export async function publishPath(
ctx: JobContext,
points: { x: number; y: number }[],
directions: DirectionName[]
directions: DirectionName[],
botConfig: BotConfig
): Promise<void> {
const payload = {
playerId: "bot-claude",
playerId: botConfig.identity,
points,
directions,
};
Expand All @@ -80,7 +75,8 @@ export async function publishPath(
function pickRandomDestination(
current: GridCell,
barrierSet: Set<string>,
gridSize: number
gridSize: number,
maxPathLength: number
): GridCell | null {
// Try up to 20 times to find a reachable destination
for (let attempt = 0; attempt < 20; attempt++) {
Expand All @@ -92,7 +88,7 @@ function pickRandomDestination(

// Prefer destinations that aren't too far (keeps walks short and natural)
const dist = Math.max(Math.abs(x - current.x), Math.abs(y - current.y));
if (dist > MAX_PATH_LENGTH) continue;
if (dist > maxPathLength) continue;

return { x, y };
}
Expand Down Expand Up @@ -120,11 +116,12 @@ export function abortableSleep(

/**
* Start the wandering loop. Returns the AbortController so callers can
* cancel wandering (e.g. to approach a stuck player in Phase 4).
* cancel wandering (e.g. to approach a stuck player).
*/
export function startWandering(
ctx: JobContext,
world: WorldState
world: WorldState,
botConfig: BotConfig
): AbortController {
const controller = new AbortController();
const { signal } = controller;
Expand All @@ -148,11 +145,14 @@ export function startWandering(
);

while (!signal.aborted) {
const { minPauseMs, maxPauseMs, maxPathLength } = botConfig.wanderConfig;

// Pick a random destination
const dest = pickRandomDestination(
world.position,
barrierSet,
map.gridSize
map.gridSize,
maxPathLength
);

if (!dest) {
Expand All @@ -171,8 +171,8 @@ export function startWandering(
}

// Truncate long paths
const truncated = path.length > MAX_PATH_LENGTH
? path.slice(0, MAX_PATH_LENGTH + 1)
const truncated = path.length > maxPathLength
? path.slice(0, maxPathLength + 1)
: path;

const directions = pathToDirections(truncated);
Expand All @@ -186,7 +186,7 @@ export function startWandering(

// Publish the full path for the client to animate
try {
await publishPath(ctx, points, directions);
await publishPath(ctx, points, directions, botConfig);
} catch (err) {
console.error("[Wander] Failed to publish path:", err);
await abortableSleep(2_000, signal);
Expand All @@ -204,7 +204,7 @@ export function startWandering(

// Pause between walks (randomized for natural feel)
const pause =
MIN_PAUSE_MS + Math.random() * (MAX_PAUSE_MS - MIN_PAUSE_MS);
minPauseMs + Math.random() * (maxPauseMs - minPauseMs);
const pauseCompleted = await abortableSleep(pause, signal);
if (!pauseCompleted) break;
}
Expand Down Expand Up @@ -251,7 +251,7 @@ export function findAdjacentCell(
return null;
}

/** Default time a player must be at a terminal before Clawd proactively offers help. */
/** Default time a player must be at a terminal before the bot proactively offers help. */
const DEFAULT_STUCK_THRESHOLD_MS = 120_000; // 2 minutes

/** Default interval between stuck-detection checks. */
Expand Down
80 changes: 80 additions & 0 deletions src/bot-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/** Bot configuration — drives identity, prompts, and behavior per bot instance. */

export interface BotConfig {
/** LiveKit agent name (lowercase) — must match dispatch config. */
agentName: string;
/** LiveKit participant identity — used for position publishing and self-filtering. */
identity: string;
/** Human-readable display name — used in chat responses. */
displayName: string;
/** System prompt for chat interactions. */
systemPrompt: string;
/** Challenge evaluation prompt, or null if this bot doesn't evaluate. */
challengeEvalPrompt: string | null;
/** Help hint prompt, or null if this bot doesn't give hints. */
helpHintPrompt: string | null;
/** Proactive nudge prompt for stuck player detection. */
proactiveNudgePrompt: string;
/** Wandering behavior tuning. */
wanderConfig: {
minPauseMs: number;
maxPauseMs: number;
maxPathLength: number;
};
}

import * as clawd from "./prompts/clawd.js";
import * as gremlin from "./prompts/gremlin.js";

const configs: Record<string, BotConfig> = {
clawd: {
agentName: "clawd",
identity: "bot-claude",
displayName: "Clawd",
systemPrompt: clawd.SYSTEM_PROMPT,
challengeEvalPrompt: clawd.CHALLENGE_EVALUATION_PROMPT,
helpHintPrompt: clawd.HELP_HINT_PROMPT,
proactiveNudgePrompt: clawd.PROACTIVE_NUDGE_PROMPT,
wanderConfig: {
minPauseMs: 2_000,
maxPauseMs: 5_000,
maxPathLength: 20,
},
},
gremlin: {
agentName: "gremlin",
identity: "bot-gremlin",
displayName: "Gremlin",
systemPrompt: gremlin.SYSTEM_PROMPT,
challengeEvalPrompt: gremlin.CHALLENGE_EVALUATION_PROMPT,
helpHintPrompt: gremlin.HELP_HINT_PROMPT,
proactiveNudgePrompt: gremlin.PROACTIVE_NUDGE_PROMPT,
wanderConfig: {
minPauseMs: 1_000,
maxPauseMs: 3_000,
maxPathLength: 25,
},
},
};

/**
* Resolve the bot config from CLI arguments or environment.
*
* Checks (in order):
* 1. `--bot=<name>` CLI argument
* 2. `BOT_NAME` environment variable (used by Cloud Run)
* 3. Falls back to "clawd"
*/
export function resolveBotConfig(): BotConfig {
const botArg = process.argv.find((arg) => arg.startsWith("--bot="));
const botName = botArg ? botArg.split("=")[1] : (process.env.BOT_NAME || "clawd");

const config = configs[botName];
if (!config) {
const valid = Object.keys(configs).join(", ");
throw new Error(`Unknown bot "${botName}". Valid options: ${valid}`);
}

console.log(`[Config] Loaded bot config: ${config.displayName} (${config.identity})`);
return config;
}
Loading
Loading