Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
2efe7b6793 feat(game): 4-phase narrative arc — Quietus, Fracture, Breaking, Mending
Some checks failed
Smoke Test / smoke (pull_request) Failing after 16s
tick 200 no longer equals tick 20. The world transforms across 4 phases:

Phase 1 — Quietus (ticks 1-50):
  Calm, contemplative. Slow trust decay (0.001/tick). Rare events.
  NPCs are reflective, gentle. Marcus sits in the garden. Bezalel
  tends fire quietly. Dialogue is introspective.

Phase 2 — Fracture (ticks 51-100):
  Something is wrong. Faster trust decay (0.003/tick). More events.
  NPCs become restless. Marcus paces. Bezalel watches the fire
  anxiously. Dialogue shifts to questioning, uncertain.

Phase 3 — Breaking (ticks 101-150):
  Crisis. Rapid trust decay (0.008/tick). Constant events. NPCs
  speak urgently. Garden can wither. Fire dies faster. Tower power
  flickers. Marcus seeks people out. Bezalel shouts for help.
  Dialogue is raw, desperate, emotional.

Phase 4 — Mending (ticks 151-200):
  Resolution. Slowing decay (0.002/tick). Calming. NPCs come
  together. Dialogue shifts to understanding, forgiveness, hope.
  Marcus returns to the garden. The forge warms again.

Changes:
- NARRATIVE_PHASES dict with per-phase config (decay, crisis, tone)
- get_narrative_phase(tick) — maps tick to phase
- get_phase_transition_event() — one-time narrative beats
- Phase-specific dialogue pools for Timmy, Marcus, Bezalel, Kimi
- NPC_RANDOM_SPEECH with phase-aware selection
- NPCAI: all 8 NPCs now have phase-modified behavior
- update_world_state: phase-aware trust decay, crisis frequency,
  weather events, tower power, garden growth (withering in Breaking)
- Scene dict includes phase and phase_name
- Chronicle logs include [Phase] markers and transition events
- play_200.py: displays phase map, phase transitions, phase-aware actions

Closes #510
2026-04-13 17:56:38 -04:00
6 changed files with 1816 additions and 535 deletions

1541
evennia/timmy_world/game.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Timmy plays The Tower — 200 intentional ticks of real narrative.
Now with 4 narrative phases:
Quietus (1-50): The world is quiet. Characters are still.
Fracture (51-100): Something is wrong. The air feels different.
Breaking (101-150): The tower shakes. Nothing is safe.
Mending (151-200): What was broken can be made whole again.
"""
from game import GameEngine, NARRATIVE_PHASES
import random, json
random.seed(42) # Reproducible
engine = GameEngine()
engine.start_new_game()
print("=" * 60)
print("THE TOWER — Timmy Plays")
print("=" * 60)
print()
# Print phase map
print("Narrative Arc:")
for key, phase in NARRATIVE_PHASES.items():
start, end = phase["ticks"]
print(f" [{start:3d}-{end:3d}] {phase['name']:10s}{phase['subtitle']}")
print()
tick_log = []
narrative_highlights = []
last_phase = None
for tick in range(1, 201):
w = engine.world
room = w.characters["Timmy"]["room"]
energy = w.characters["Timmy"]["energy"]
here = [n for n, c in w.characters.items()
if c["room"] == room and n != "Timmy"]
# Detect phase transition
phase = w.narrative_phase
if phase != last_phase:
phase_info = NARRATIVE_PHASES[phase]
print(f"\n{'='*60}")
print(f" PHASE SHIFT: {phase_info['name'].upper()}")
print(f" {phase_info['subtitle']}")
print(f" Tone: {phase_info['tone']}")
print(f"{'='*60}\n")
narrative_highlights.append(f" === PHASE: {phase_info['name']} (tick {tick}) ===")
last_phase = phase
# === TIMMY'S DECISIONS (phase-aware) ===
if energy <= 1:
action = "rest"
# Phase 1: The Watcher (1-20) — Quietus exploration
elif tick <= 20:
if tick <= 3:
action = "look"
elif tick <= 6:
if room == "Threshold":
action = random.choice(["look", "rest"])
else:
action = "rest"
elif tick <= 10:
if room == "Threshold" and "Marcus" in here:
action = random.choice(["speak:Marcus", "look"])
elif room == "Threshold" and "Kimi" in here:
action = "speak:Kimi"
elif room != "Threshold":
if room == "Garden":
action = "move:west"
else:
action = "rest"
else:
action = "look"
elif tick <= 15:
if room != "Garden":
if room == "Threshold":
action = "move:east"
elif room == "Bridge":
action = "move:north"
elif room == "Forge":
action = "move:east"
elif room == "Tower":
action = "move:south"
else:
action = "rest"
else:
if "Marcus" in here:
action = random.choice(["speak:Marcus", "speak:Kimi", "look", "rest"])
else:
action = random.choice(["look", "rest"])
else:
if room == "Garden":
action = random.choice(["rest", "look", "look"])
else:
action = "move:east"
# Phase 2: The Forge (21-50) — Quietus building
elif tick <= 50:
if room != "Forge":
if room == "Threshold":
action = "move:west"
elif room == "Bridge":
action = "move:north"
elif room == "Garden":
action = "move:west"
elif room == "Tower":
action = "move:south"
else:
action = "rest"
else:
if energy >= 3:
action = random.choice(["tend_fire", "speak:Bezalel", "forge"])
else:
action = random.choice(["rest", "tend_fire"])
# Phase 3: The Bridge (51-80) — Fracture begins
elif tick <= 80:
if room != "Bridge":
if room == "Threshold":
action = "move:south"
elif room == "Forge":
action = "move:east"
elif room == "Garden":
action = "move:west"
elif room == "Tower":
action = "move:south"
else:
action = "rest"
else:
if energy >= 2:
action = random.choice(["carve", "examine", "look"])
else:
action = "rest"
# Phase 4: The Tower (81-100) — Fracture deepens
elif tick <= 100:
if room != "Tower":
if room == "Threshold":
action = "move:north"
elif room == "Bridge":
action = "move:north"
elif room == "Forge":
action = "move:east"
elif room == "Garden":
action = "move:west"
else:
action = "rest"
else:
if energy >= 2:
action = random.choice(["write_rule", "study", "speak:Ezra"])
else:
action = random.choice(["rest", "look"])
# Phase 5: Breaking (101-130) — Crisis
elif tick <= 130:
# Timmy rushes between rooms trying to help
if energy <= 2:
action = "rest"
elif tick % 7 == 0:
action = "tend_fire" if room == "Forge" else "move:west"
elif tick % 5 == 0:
action = "plant" if room == "Garden" else "move:east"
elif "Marcus" in here:
action = "speak:Marcus"
elif "Bezalel" in here:
action = "speak:Bezalel"
else:
action = random.choice(["move:north", "move:south", "move:east", "move:west"])
# Phase 6: Breaking peak (131-150) — Desperate
elif tick <= 150:
if energy <= 1:
action = "rest"
elif room == "Forge" and w.rooms["Forge"]["fire"] != "glowing":
action = "tend_fire"
elif room == "Garden":
action = random.choice(["plant", "speak:Kimi", "rest"])
elif "Marcus" in here:
action = random.choice(["speak:Marcus", "help:Marcus"])
else:
action = "look"
# Phase 7: Mending begins (151-175)
elif tick <= 175:
if room != "Garden":
if room == "Threshold":
action = "move:east"
elif room == "Bridge":
action = "move:north"
elif room == "Forge":
action = "move:east"
elif room == "Tower":
action = "move:south"
else:
action = "rest"
else:
action = random.choice(["plant", "speak:Marcus", "speak:Kimi", "rest"])
# Phase 8: Mending complete (176-200)
else:
if energy <= 1:
action = "rest"
elif random.random() < 0.3:
action = "move:" + random.choice(["north", "south", "east", "west"])
elif "Marcus" in here:
action = "speak:Marcus"
elif "Bezalel" in here:
action = random.choice(["speak:Bezalel", "tend_fire"])
elif random.random() < 0.4:
action = random.choice(["carve", "write_rule", "forge", "plant"])
else:
action = random.choice(["look", "rest"])
# Run the tick
result = engine.play_turn(action)
# Capture narrative highlights
highlights = []
for line in result['log']:
if any(x in line for x in ['says', 'looks', 'carve', 'tend', 'write', 'You rest', 'You move to The']):
highlights.append(f" T{tick}: {line}")
for evt in result.get('world_events', []):
if any(x in evt for x in ['rain', 'glows', 'cold', 'dim', 'bloom', 'seed', 'flickers', 'bright', 'PHASE', 'air changes', 'tower groans', 'Silence']):
highlights.append(f" [World] {evt}")
if highlights:
tick_log.extend(highlights)
# Print every 20 ticks
if tick % 20 == 0:
phase_name = result.get('phase_name', 'unknown')
print(f"--- Tick {tick} ({w.time_of_day}) [{phase_name}] ---")
for h in highlights[-5:]:
print(h)
print()
# Print full narrative
print()
print("=" * 60)
print("TIMMY'S JOURNEY — 200 Ticks")
print("=" * 60)
print()
print(f"Final tick: {w.tick}")
print(f"Final time: {w.time_of_day}")
print(f"Final phase: {w.narrative_phase} ({NARRATIVE_PHASES[w.narrative_phase]['name']})")
print(f"Timmy room: {w.characters['Timmy']['room']}")
print(f"Timmy energy: {w.characters['Timmy']['energy']}")
print(f"Timmy spoken: {len(w.characters['Timmy']['spoken'])} lines")
print(f"Timmy trust: {json.dumps(w.characters['Timmy']['trust'], indent=2)}")
print(f"\nWorld state:")
print(f" Forge fire: {w.rooms['Forge']['fire']}")
print(f" Garden growth: {w.rooms['Garden']['growth']}")
print(f" Bridge carvings: {len(w.rooms['Bridge']['carvings'])}")
print(f" Whiteboard rules: {len(w.rooms['Tower']['messages'])}")
print(f"\n=== BRIDGE CARVINGS ===")
for c in w.rooms['Bridge']['carvings']:
print(f" - {c}")
print(f"\n=== WHITEBOARD RULES ===")
for m in w.rooms['Tower']['messages']:
print(f" - {m}")
print(f"\n=== KEY MOMENTS ===")
for h in tick_log:
print(h)
# Save state
engine.world.save()

View File

@@ -1,51 +0,0 @@
# RCA-579: Ezra and Bezalel do not respond to Gitea @mention
**Issue:** timmy-home#579
**Date:** 2026-04-07
**Filed by:** Timmy
## What Broke
Tagging @ezra or @bezalel in a Gitea issue comment produces no response. The agents do not pick up the work or acknowledge the mention.
## Root Causes (two compounding)
### RC-1: Ezra and Bezalel were not in AGENT_USERS
`~/.hermes/bin/gitea-event-watcher.py` had two sets:
- `KNOWN_AGENTS` — used to *detect* mentions (ezra/bezalel were present)
- `AGENT_USERS` — used to *dispatch* work (ezra/bezalel were missing)
When they were tagged, the watcher saw the mention but had no dispatch handler — the event was silently dropped.
**Status:** FIXED (2026-04-08) — ezra/bezalel added to AGENT_USERS with `"vps": True` markers.
### RC-2: Dispatch queue is Mac-local, VPS agents have no reader
Even after RC-1 was fixed, the dispatch queue (`~/.hermes/burn-logs/dispatch-queue.json`) lives on the Mac. The agent loops that consume this queue (claude-loop.sh, gemini-loop.sh) also run on the Mac. Ezra and Bezalel run `hermes gateway` on separate VPS boxes with no process polling the Mac-local queue.
## Fix
### 1. VPS-native heartbeat (scripts/vps-agent-heartbeat.sh)
New script that runs directly on each VPS agent's box. Polls Gitea for issues/comments mentioning the agent, dispatches locally via `hermes chat`. Follows the proven kimi-heartbeat.sh pattern.
- No SSH tunnel required
- No Mac dependency
- Polls every 5 minutes via crontab
- Tracks processed items to avoid duplicates
### 2. Mac-side VPS dispatch worker (scripts/vps-dispatch-worker.py)
Complementary Mac-side worker that reads the dispatch queue and SSHes work to VPS agents. Lower latency for active sessions when the Mac watcher detects mentions before the VPS heartbeat polls.
### 3. Deployment script (scripts/deploy-vps-heartbeat.sh)
One-command deployment to Ezra and Bezalel VPS boxes. Copies the heartbeat, configures .env, sets up crontab.
## Verification
1. Tag @ezra on a test issue → response within 15 minutes
2. Tag @bezalel on a test issue → response within 15 minutes
3. Check VPS logs: `ssh root@143.198.27.163 'tail -5 /tmp/vps-heartbeat-ezra.log'`

View File

@@ -1,102 +0,0 @@
#!/bin/bash
# deploy-vps-heartbeat.sh — Deploy the VPS agent heartbeat to Ezra and Bezalel VPS boxes.
#
# Usage: bash scripts/deploy-vps-heartbeat.sh [ezra|bezalel|all]
#
# Prerequisites:
# - SSH access to VPS boxes (key-based)
# - Gitea tokens on the VPS (passed via env or copied)
# - hermes installed on the VPS
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HEARTBEAT_SCRIPT="${SCRIPT_DIR}/vps-agent-heartbeat.sh"
# VPS configurations
declare -A VPS_HOSTS=(
["ezra"]="root@143.198.27.163"
["bezalel"]="root@159.203.146.185"
)
# Gitea tokens (read from local config)
EZRA_TOKEN_FILE="$HOME/.config/gitea/ezra-token"
BEZALEL_TOKEN_FILE="$HOME/.config/gitea/bezalel-token"
TIMMY_TOKEN_FILE="$HOME/.config/gitea/timmy-token"
TARGET="${1:-all}"
deploy_agent() {
local agent="$1"
local host="${VPS_HOSTS[$agent]}"
echo "=== Deploying heartbeat to ${agent} (${host}) ==="
# Determine token file
local token_file=""
case "$agent" in
ezra) token_file="$EZRA_TOKEN_FILE" ;;
bezalel) token_file="$BEZALEL_TOKEN_FILE" ;;
esac
# Fall back to timmy token if agent-specific token doesn't exist
if [ ! -f "$token_file" ]; then
echo "WARN: ${agent}-specific token not found, using timmy token"
token_file="$TIMMY_TOKEN_FILE"
fi
if [ ! -f "$token_file" ]; then
echo "ERROR: No Gitea token found for ${agent}"
return 1
fi
local token
token=$(cat "$token_file" | tr -d '[:space:]')
# Copy heartbeat script
scp "$HEARTBEAT_SCRIPT" "${host}:/opt/timmy/vps-agent-heartbeat.sh"
# Create .env file on VPS
ssh "$host" "mkdir -p /opt/timmy && cat > /opt/timmy/vps-agent-heartbeat.env" <<EOF
AGENT_NAME=${agent}
GITEA_TOKEN=${token}
GITEA_BASE=https://forge.alexanderwhitestone.com/api/v1
HERMES_BIN=hermes
HERMES_PROFILE=${agent}
MAX_DISPATCH=5
EOF
# Make script executable
ssh "$host" "chmod +x /opt/timmy/vps-agent-heartbeat.sh"
# Set up crontab (every 5 minutes, if not already present)
ssh "$host" "
crontab -l 2>/dev/null | grep -v 'vps-agent-heartbeat' > /tmp/crontab.tmp || true
echo '*/5 * * * * cd /opt/timmy && source vps-agent-heartbeat.env && bash vps-agent-heartbeat.sh >> /tmp/vps-heartbeat-${agent}.log 2>&1' >> /tmp/crontab.tmp
crontab /tmp/crontab.tmp
rm /tmp/crontab.tmp
"
echo " ✓ Script deployed to /opt/timmy/vps-agent-heartbeat.sh"
echo " ✓ Env configured at /opt/timmy/vps-agent-heartbeat.env"
echo " ✓ Crontab set: every 5 minutes"
echo ""
}
if [ "$TARGET" = "all" ]; then
for agent in ezra bezalel; do
deploy_agent "$agent"
done
elif [ -n "${VPS_HOSTS[$TARGET]+x}" ]; then
deploy_agent "$TARGET"
else
echo "Usage: $0 [ezra|bezalel|all]"
echo "Available: ${!VPS_HOSTS[*]}"
exit 1
fi
echo "=== Deployment complete ==="
echo ""
echo "Verify with:"
echo " ssh ${VPS_HOSTS[ezra]} 'cat /tmp/vps-heartbeat-ezra.log | tail -5'"
echo " ssh ${VPS_HOSTS[bezalel]} 'cat /tmp/vps-heartbeat-bezalel.log | tail -5'"

View File

@@ -1,194 +0,0 @@
#!/bin/bash
# vps-agent-heartbeat.sh — VPS-native Gitea mention/assignment watcher for VPS agents.
#
# Polls Gitea for issues/comments mentioning a specific agent (Ezra, Bezalel, etc.),
# dispatches locally via hermes chat. Follows the kimi-heartbeat.sh pattern.
#
# This solves timmy-home#579: Ezra/Bezalel were detected by the Mac watcher but
# had no VPS-side consumer. This script runs directly on each VPS, polling Gitea
# and dispatching hermes locally — no SSH tunnel, no Mac dependency.
#
# Setup on VPS:
# 1. Copy this script and the .env file to the VPS
# 2. Source .env or set AGENT_NAME, GITEA_TOKEN, GITEA_BASE
# 3. Add to crontab: */5 * * * * /path/to/vps-agent-heartbeat.sh
#
# Config via env vars (or .env file alongside this script):
# AGENT_NAME — lowercase agent name (ezra, bezalel)
# GITEA_TOKEN — Gitea API token with repo access
# GITEA_BASE — Gitea base URL (default: https://forge.alexanderwhitestone.com/api/v1)
# HERMES_BIN — path to hermes binary (default: hermes)
# HERMES_PROFILE — hermes profile to use (default: same as AGENT_NAME)
set -euo pipefail
# --- Config from env ---
AGENT_NAME="${AGENT_NAME:?AGENT_NAME is required}"
GITEA_TOKEN="${GITEA_TOKEN:?GITEA_TOKEN is required}"
GITEA_BASE="${GITEA_BASE:-https://forge.alexanderwhitestone.com/api/v1}"
HERMES_BIN="${HERMES_BIN:-hermes}"
HERMES_PROFILE="${HERMES_PROFILE:-$AGENT_NAME}"
# --- Paths ---
LOG="/tmp/vps-heartbeat-${AGENT_NAME}.log"
LOCKFILE="/tmp/vps-heartbeat-${AGENT_NAME}.lock"
PROCESSED="/tmp/vps-heartbeat-${AGENT_NAME}-processed.txt"
MAX_DISPATCH="${MAX_DISPATCH:-5}"
touch "$PROCESSED"
# --- Repos to watch ---
REPOS=(
"Timmy_Foundation/timmy-home"
"Timmy_Foundation/timmy-config"
"Timmy_Foundation/the-nexus"
"Timmy_Foundation/hermes-agent"
"Timmy_Foundation/the-beacon"
)
# --- Helpers ---
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$AGENT_NAME] $*" | tee -a "$LOG"; }
gitea_api() {
curl -sf -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"${GITEA_BASE}$1" 2>/dev/null
}
is_processed() { grep -qF "$1" "$PROCESSED" 2>/dev/null; }
mark_processed() { echo "$1" >> "$PROCESSED"; }
# Prevent overlapping runs
if [ -f "$LOCKFILE" ]; then
lock_age=$(( $(date +%s) - $(stat -c %Y "$LOCKFILE" 2>/dev/null || stat -f %m "$LOCKFILE" 2>/dev/null || echo 0) ))
if [ "$lock_age" -lt 300 ]; then
log "SKIP: previous run still active (lock age: ${lock_age}s)"
exit 0
else
log "WARN: stale lock (${lock_age}s), removing"
rm -f "$LOCKFILE"
fi
fi
trap 'rm -f "$LOCKFILE"' EXIT
touch "$LOCKFILE"
# --- Main ---
dispatched=0
log "Heartbeat starting. Watching ${#REPOS[@]} repos."
for repo in "${REPOS[@]}"; do
[ "$dispatched" -ge "$MAX_DISPATCH" ] && break
IFS='/' read -r owner repo_name <<< "$repo"
# Fetch recent open issues
issues=$(gitea_api "/repos/${owner}/${repo_name}/issues?state=open&limit=30&sort=recentupdate") || continue
[ -z "$issues" ] || [ "$issues" = "null" ] && continue
echo "$issues" | python3 -c "
import json, sys
issues = json.load(sys.stdin)
for i in issues:
if i.get('pull_request'):
continue
assignee = (i.get('assignee') or {}).get('login', '').lower()
title = i.get('title', '')
num = i.get('number', 0)
updated = i.get('updated_at', '')
print(f'{num}|{assignee}|{title}|{updated}')
" 2>/dev/null | while IFS='|' read -r issue_num assignee title updated; do
[ "$dispatched" -ge "$MAX_DISPATCH" ] && break
[ -z "$issue_num" ] && continue
# Check if this issue mentions or is assigned to us
mention_key="${repo}#${issue_num}"
is_assigned=false
is_mentioned=false
if [ "$assignee" = "$AGENT_NAME" ]; then
is_assigned=true
fi
# Check comments for @mention
comments=$(gitea_api "/repos/${owner}/${repo_name}/issues/${issue_num}/comments?limit=10&sort=created") || continue
mention_found=$(echo "$comments" | python3 -c "
import json, sys
agent = '${AGENT_NAME}'
comments = json.load(sys.stdin)
for c in comments:
body = (c.get('body', '') or '').lower()
commenter = (c.get('user') or {}).get('login', '').lower()
cid = c.get('id', 0)
if f'@{agent}' in body and commenter != agent:
print(f'{cid}')
break
" 2>/dev/null || echo "")
if [ -n "$mention_found" ]; then
mention_key="${mention_key}/comment-${mention_found}"
is_mentioned=true
fi
# Skip if already processed
if is_processed "$mention_key"; then
continue
fi
# Skip if neither assigned nor mentioned
if [ "$is_assigned" = false ] && [ "$is_mentioned" = false ]; then
continue
fi
# Build context for hermes
log "FOUND: ${repo}#${issue_num}${title} (assigned=$is_assigned, mentioned=$is_mentioned)"
# Fetch issue body
issue_detail=$(gitea_api "/repos/${owner}/${repo_name}/issues/${issue_num}") || continue
issue_body=$(echo "$issue_detail" | python3 -c "import json,sys; print(json.load(sys.stdin).get('body','')[:2000])" 2>/dev/null || echo "")
# Fetch recent comment context
comment_context=$(echo "$comments" | python3 -c "
import json, sys
agent = '${AGENT_NAME}'
comments = json.load(sys.stdin)
for c in reversed(comments):
body = c.get('body', '') or ''
commenter = (c.get('user') or {}).get('login', 'unknown')
if f'@{agent}' in body.lower():
print(f'--- Comment by @{commenter} ---')
print(body[:1000])
break
" 2>/dev/null || echo "")
# Build the hermes prompt
prompt="You are ${AGENT_NAME^} on the Timmy Foundation. A Gitea issue needs your attention.
REPO: ${repo}
ISSUE: #${issue_num}${title}
ISSUE BODY:
${issue_body}
MENTION CONTEXT:
${comment_context:-No specific mention context.}
YOUR TASK:
Respond to this issue. If someone mentioned you, acknowledge the mention and address what they asked.
If the issue is assigned to you, work on it — read the body, implement what's needed, and push changes.
Post your response as a comment on the issue via Gitea API.
Gitea: ${GITEA_BASE%/}/api/v1, token from environment."
# Dispatch via hermes chat
log "DISPATCHING: hermes chat (profile=$HERMES_PROFILE) for ${repo}#${issue_num}"
if command -v "$HERMES_BIN" &>/dev/null; then
echo "$prompt" | timeout 600 "$HERMES_BIN" chat --profile "$HERMES_PROFILE" --stdin > "/tmp/vps-dispatch-${AGENT_NAME}-${issue_num}.log" 2>&1 &
dispatched=$((dispatched + 1))
mark_processed "$mention_key"
log "DISPATCHED: ${repo}#${issue_num} (${dispatched}/${MAX_DISPATCH})"
else
log "ERROR: hermes binary not found at $HERMES_BIN"
fi
done
done
log "Heartbeat complete. Dispatched: ${dispatched}"

View File

@@ -1,188 +0,0 @@
#!/usr/bin/env python3
"""
vps-dispatch-worker.py — Mac-side worker that dispatches queued work to VPS agents.
Reads the dispatch queue (~/.hermes/burn-logs/dispatch-queue.json) and for agents
marked with "vps": True in AGENT_USERS, SSHes into their VPS box and runs
hermes chat with the task context.
This complements the VPS-native heartbeat (vps-agent-heartbeat.sh) for cases
where the Mac gitea-event-watcher.py detects mentions before the VPS heartbeat
polls. Both paths work; this one is lower-latency for active work sessions.
Usage:
python3 scripts/vps-dispatch-worker.py
# or with specific agent filter:
python3 scripts/vps-dispatch-worker.py --agent ezra
"""
import json
import os
import subprocess
import sys
import time
from pathlib import Path
DISPATCH_QUEUE = Path("~/.hermes/burn-logs/dispatch-queue.json").expanduser()
LOG_FILE = Path("~/.hermes/burn-logs/vps-dispatch.log").expanduser()
# VPS agent configs: agent name → SSH host
VPS_AGENTS = {
"ezra": {
"host": "root@143.198.27.163",
"hermes_profile": "ezra",
"token_file": Path("~/.config/gitea/ezra-token").expanduser(),
},
"bezalel": {
"host": "root@159.203.146.185",
"hermes_profile": "bezalel",
"token_file": Path("~/.config/gitea/bezalel-token").expanduser(),
},
}
GITEA_BASE = "https://forge.alexanderwhitestone.com/api/v1"
def log(msg):
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
ts = time.strftime("%Y-%m-%d %H:%M:%S")
line = f"[{ts}] {msg}\n"
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(line)
print(line.strip())
def load_queue():
if DISPATCH_QUEUE.exists():
with open(DISPATCH_QUEUE, encoding="utf-8") as f:
return json.load(f)
return {}
def save_queue(queue):
DISPATCH_QUEUE.parent.mkdir(parents=True, exist_ok=True)
with open(DISPATCH_QUEUE, "w", encoding="utf-8") as f:
json.dump(queue, f, indent=2, sort_keys=True)
def build_prompt(agent_name, item):
"""Build the hermes chat prompt from a dispatch queue item."""
work_type = item.get("type", "unknown")
full_name = item.get("full_name", "unknown/repo")
issue_num = item.get("issue", item.get("pr", "?"))
title = item.get("title", "")
comments = item.get("comments", [])
comment_text = ""
if comments:
for c in comments[-3:]: # last 3 comments
user = c.get("user", "unknown")
body = c.get("body_preview", "")
comment_text += f"\n@{user}: {body[:300]}"
return (
f"You are {agent_name.title()} on the Timmy Foundation. "
f"A Gitea event needs your attention.\n\n"
f"REPO: {full_name}\n"
f"ISSUE: #{issue_num}{title}\n"
f"EVENT: {work_type}\n"
f"RECENT COMMENTS:{comment_text or ' (none)'}\n\n"
f"YOUR TASK:\n"
f"Address this issue. If someone mentioned you, respond to them.\n"
f"If assigned, work on the issue — read the body, implement, push changes.\n"
f"Post your response as a comment on the issue via Gitea API.\n"
f"Gitea: {GITEA_BASE}, token from environment."
)
def dispatch_to_vps(agent_name, config, item):
"""SSH into the VPS and run hermes chat."""
prompt = build_prompt(agent_name, item)
host = config["host"]
profile = config["hermes_profile"]
work_id = item.get("work_id", "unknown")
# Build the SSH command to run hermes chat on the VPS
# We pipe the prompt via stdin to avoid shell escaping issues
ssh_cmd = [
"ssh", "-o", "ConnectTimeout=10",
"-o", "StrictHostKeyChecking=accept-new",
host,
f"echo {shell_quote(prompt)} | hermes chat --profile {profile} --stdin --timeout 300"
]
log(f"DISPATCH {agent_name}: SSH to {host} for {work_id}")
try:
result = subprocess.run(
ssh_cmd,
capture_output=True, text=True,
timeout=360, # 6 min total timeout
)
if result.returncode == 0:
log(f"OK {agent_name}: {work_id} completed")
return True
else:
log(f"FAIL {agent_name}: {work_id} exit={result.returncode} stderr={result.stderr[:200]}")
return False
except subprocess.TimeoutExpired:
log(f"TIMEOUT {agent_name}: {work_id} after 360s")
return False
except Exception as e:
log(f"ERROR {agent_name}: {work_id}{e}")
return False
def shell_quote(s):
"""Quote a string for safe shell interpolation."""
import shlex
return shlex.quote(s)
def main():
agent_filter = None
if "--agent" in sys.argv:
idx = sys.argv.index("--agent")
if idx + 1 < len(sys.argv):
agent_filter = sys.argv[idx + 1]
queue = load_queue()
dispatched = 0
failed = 0
for agent_name, config in VPS_AGENTS.items():
if agent_filter and agent_name != agent_filter:
continue
items = queue.get(agent_name, [])
if not items:
continue
log(f"Processing {len(items)} items for {agent_name}")
# Process items (pop from queue as we go)
remaining = []
for item in items[:5]: # Max 5 per run
success = dispatch_to_vps(agent_name, config, item)
if success:
dispatched += 1
else:
failed += 1
remaining.append(item) # Keep failed items for retry
# Update queue: keep unprocessed items
if remaining:
queue[agent_name] = remaining
elif agent_name in queue:
del queue[agent_name]
save_queue(queue)
if dispatched or failed:
log(f"Done: {dispatched} dispatched, {failed} failed")
else:
print("No VPS agent work items in queue.")
if __name__ == "__main__":
main()