From ee7f37c5c772c01e821409b777f8a4c1d82abf4c Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 30 Mar 2026 17:59:43 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20rewrite=20KimiClaw=20heartbeat=20?= =?UTF-8?q?=E2=80=94=20launchd,=20sovereignty=20fixes,=20dispatch=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrote kimi-heartbeat.sh with sovereignty-first design: - Prefer Tailscale (100.x) over public IP for Gitea API calls - Use $HOME instead of hardcoded /Users/apayne paths - Remove token file paths from prompts sent to Kimi API - Add MAX_DISPATCH=5 cap per heartbeat run - Proper lockfile with stale detection (10min timeout) - Correct identity separation: timmy-token for labels, kimi_gitea_token for comments - Covers 4 repos: timmy-home, timmy-config, the-nexus, hermes-agent - Label lifecycle: assigned-kimi -> kimi-in-progress -> kimi-done - Failure handling: removes in-progress label so retry is possible LaunchAgent: ai.timmy.kimi-heartbeat.plist (every 5 minutes) Zero LLM cost for polling — bash/curl only. Kimi tokens only for actual work. All Hermes cron jobs removed — they burned Anthropic tokens for polling. KimiClaw dispatch is now pure infrastructure, no cloud LLM in the loop. --- heartbeat/last_tick.json | 23 +++-- uniwizard/kimi-heartbeat.sh | 197 +++++++++++++++++++++++------------- 2 files changed, 143 insertions(+), 77 deletions(-) diff --git a/heartbeat/last_tick.json b/heartbeat/last_tick.json index c1bedb0..7c7c852 100644 --- a/heartbeat/last_tick.json +++ b/heartbeat/last_tick.json @@ -1,15 +1,24 @@ { - "tick_id": "20260328_015026", - "timestamp": "2026-03-28T01:50:26.595915+00:00", + "tick_id": "20260330_212052", + "timestamp": "2026-03-30T21:20:52.930215+00:00", "perception": { "gitea_alive": true, "model_health": { - "ollama_running": true, - "models_loaded": [], + "provider": "local-llama.cpp", + "provider_base_url": "http://localhost:8081/v1", + "provider_model": "hermes4:14b", + "local_inference_running": true, + "models_loaded": [ + "NousResearch_Hermes-4-14B-Q4_K_M.gguf" + ], "api_responding": true, "inference_ok": false, - "inference_error": "HTTP Error 404: Not Found", - "timestamp": "2026-03-28T01:50:26.594893+00:00" + "inference_error": "HTTP Error 500: Internal Server Error", + "latest_session": "session_d8c25163-9934-4ab2-9158-ff18a31e30f5.json", + "latest_export": "session_d8c25163-9934-4ab2-9158-ff18a31e30f5.json", + "export_lag_minutes": 0, + "export_fresh": true, + "timestamp": "2026-03-30T21:20:52.929294+00:00" }, "Timmy_Foundation/the-nexus": { "open_issues": 1, @@ -21,7 +30,7 @@ }, "huey_alive": true }, - "previous_tick": "20260328_014026", + "previous_tick": "20260328_015026", "decision": { "actions": [], "severity": "fallback", diff --git a/uniwizard/kimi-heartbeat.sh b/uniwizard/kimi-heartbeat.sh index 9fa17b9..ade9b2d 100755 --- a/uniwizard/kimi-heartbeat.sh +++ b/uniwizard/kimi-heartbeat.sh @@ -1,110 +1,167 @@ #!/bin/bash -# kimi-heartbeat.sh — Polls Gitea for assigned-kimi tickets, dispatches to OpenClaw -# Run as: bash ~/.timmy/uniwizard/kimi-heartbeat.sh -# Or as a cron: every 5m +# kimi-heartbeat.sh — Polls Gitea for assigned-kimi issues, dispatches to KimiClaw via OpenClaw +# Zero LLM cost for polling — only calls kimi/kimi-code for actual work. +# +# Run manually: bash ~/.timmy/uniwizard/kimi-heartbeat.sh +# Runs via launchd every 5 minutes: ai.timmy.kimi-heartbeat.plist +# +# Workflow for humans: +# 1. Create or open a Gitea issue in any tracked repo +# 2. Add the "assigned-kimi" label +# 3. This script picks it up, dispatches to KimiClaw, posts results back +# 4. Label transitions: assigned-kimi → kimi-in-progress → kimi-done set -euo pipefail -TOKEN=$(cat /Users/apayne/.timmy/kimi_gitea_token | tr -d '[:space:]') -BASE="http://100.126.61.75:3000/api/v1" +# --- Config --- +TOKEN=$(cat "$HOME/.timmy/kimi_gitea_token" | tr -d '[:space:]') +TIMMY_TOKEN=$(cat "$HOME/.config/gitea/timmy-token" | tr -d '[:space:]') +# Prefer Tailscale (private network) over public IP +if curl -sf --connect-timeout 2 "http://100.126.61.75:3000/api/v1/version" > /dev/null 2>&1; then + BASE="http://100.126.61.75:3000/api/v1" +else + BASE="http://143.198.27.163:3000/api/v1" +fi LOG="/tmp/kimi-heartbeat.log" +LOCKFILE="/tmp/kimi-heartbeat.lock" +MAX_DISPATCH=5 # Don't overwhelm Kimi with too many parallel tasks -log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG"; } +REPOS=( + "Timmy_Foundation/timmy-home" + "Timmy_Foundation/timmy-config" + "Timmy_Foundation/the-nexus" + "Timmy_Foundation/hermes-agent" +) -# Find all issues labeled "assigned-kimi" across repos -REPOS=("Timmy_Foundation/timmy-home" "Timmy_Foundation/timmy-config" "Timmy_Foundation/the-nexus") +# --- Helpers --- +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; } + +# Prevent overlapping runs +if [ -f "$LOCKFILE" ]; then + lock_age=$(( $(date +%s) - $(stat -f %m "$LOCKFILE" 2>/dev/null || echo 0) )) + if [ "$lock_age" -lt 600 ]; 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" + +dispatched=0 for repo in "${REPOS[@]}"; do - # Get issues with assigned-kimi label but NOT kimi-in-progress or kimi-done - issues=$(curl -s -H "Authorization: token $TOKEN" \ - "$BASE/repos/$repo/issues?state=open&labels=assigned-kimi&limit=10" | \ - python3 -c " + # Fetch open issues with assigned-kimi label + response=$(curl -sf -H "Authorization: token $TIMMY_TOKEN" \ + "$BASE/repos/$repo/issues?state=open&labels=assigned-kimi&limit=20" 2>/dev/null || echo "[]") + + # Filter: skip issues that already have kimi-in-progress or kimi-done + issues=$(echo "$response" | python3 -c " import json, sys -issues = json.load(sys.stdin) -for i in issues: - labels = [l['name'] for l in i.get('labels',[])] - # Skip if already in-progress or done +try: + data = json.loads(sys.stdin.buffer.read()) +except: + sys.exit(0) +for i in data: + labels = [l['name'] for l in i.get('labels', [])] if 'kimi-in-progress' in labels or 'kimi-done' in labels: continue - body = (i.get('body','') or '')[:500].replace('\n',' ') - print(f\"{i['number']}|{i['title']}|{body}\") + # Pipe-delimited: number|title|body (truncated, newlines removed) + body = (i.get('body', '') or '')[:1500].replace('\n', ' ').replace('|', ' ') + title = i['title'].replace('|', ' ') + print(f\"{i['number']}|{title}|{body}\") " 2>/dev/null) - if [ -z "$issues" ]; then - continue - fi + [ -z "$issues" ] && continue while IFS='|' read -r issue_num title body; do [ -z "$issue_num" ] && continue - log "DISPATCH: $repo #$issue_num — $title" + log "FOUND: $repo #$issue_num — $title" - # Add kimi-in-progress label - # First get the label ID - label_id=$(curl -s -H "Authorization: token $TOKEN" \ - "$BASE/repos/$repo/labels" | \ - python3 -c "import json,sys; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='kimi-in-progress']" 2>/dev/null) + # --- Get label IDs for this repo --- + label_json=$(curl -sf -H "Authorization: token $TIMMY_TOKEN" \ + "$BASE/repos/$repo/labels" 2>/dev/null || echo "[]") - if [ -n "$label_id" ]; then - curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ - -d "{\"labels\":[$label_id]}" \ - "$BASE/repos/$repo/issues/$issue_num/labels" > /dev/null 2>&1 + progress_id=$(echo "$label_json" | python3 -c "import json,sys; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='kimi-in-progress']" 2>/dev/null) + done_id=$(echo "$label_json" | python3 -c "import json,sys; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='kimi-done']" 2>/dev/null) + + # --- Add kimi-in-progress label --- + if [ -n "$progress_id" ]; then + curl -sf -X POST -H "Authorization: token $TIMMY_TOKEN" -H "Content-Type: application/json" \ + -d "{\"labels\":[$progress_id]}" \ + "$BASE/repos/$repo/issues/$issue_num/labels" > /dev/null 2>&1 || true fi - # Post "picking up" comment - curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ - -d "{\"body\":\"🟠 **Kimi picking up this task** via OpenClaw heartbeat.\\nBackend: kimi/kimi-code\\nTimestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')\"}" \ - "$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 + # --- Post pickup comment as KimiClaw --- + curl -sf -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "{\"body\":\"🟠 **KimiClaw picking up this task** via heartbeat.\\nBackend: kimi/kimi-code (Moonshot AI)\\nTimestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')\"}" \ + "$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 || true - # Dispatch to OpenClaw - # Build a self-contained prompt from the issue - prompt="You are Timmy, working on $repo issue #$issue_num: $title + log "DISPATCH: $repo #$issue_num to openclaw" + + # --- Build the prompt --- + prompt="You are KimiClaw, an AI agent powered by Kimi K2.5 (Moonshot AI). +You are working on Gitea issue #$issue_num in repo $repo. + +ISSUE TITLE: $title ISSUE BODY: $body YOUR TASK: -1. Read the issue carefully -2. Do the work described — create files, write code, analyze, review as needed -3. Work in ~/.timmy/uniwizard/ for new files -4. When done, post a summary of what you did as a comment on the Gitea issue - Gitea API: $BASE, token in /Users/apayne/.config/gitea/token - Repo: $repo, Issue: $issue_num -5. Be thorough but practical. Ship working code." +1. Read the issue carefully and do the work described +2. Be thorough — use your full 256K context when needed +3. When done, provide your COMPLETE results as your response (use markdown) +4. Include code, analysis, or deliverables directly in your response +5. If the task requires creating files, describe what to create and provide the full content" - # Fire via openclaw agent (async via background) + # --- Dispatch to OpenClaw (background) --- ( - result=$(openclaw agent --agent main --message "$prompt" --json 2>/dev/null) - status=$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin).get('status','error'))" 2>/dev/null) + result=$(openclaw agent --agent main --message "$prompt" --json 2>/dev/null || echo '{"status":"error"}') + status=$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin).get('status','error'))" 2>/dev/null || echo "error") if [ "$status" = "ok" ]; then log "COMPLETED: $repo #$issue_num" - # Swap kimi-in-progress for kimi-done - done_id=$(curl -s -H "Authorization: token $TOKEN" \ - "$BASE/repos/$repo/labels" | \ - python3 -c "import json,sys; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='kimi-done']" 2>/dev/null) - progress_id=$(curl -s -H "Authorization: token $TOKEN" \ - "$BASE/repos/$repo/labels" | \ - python3 -c "import json,sys; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='kimi-in-progress']" 2>/dev/null) - - [ -n "$progress_id" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" \ - "$BASE/repos/$repo/issues/$issue_num/labels/$progress_id" > /dev/null 2>&1 - [ -n "$done_id" ] && curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + + # Remove kimi-in-progress, add kimi-done + [ -n "$progress_id" ] && curl -sf -X DELETE -H "Authorization: token $TIMMY_TOKEN" \ + "$BASE/repos/$repo/issues/$issue_num/labels/$progress_id" > /dev/null 2>&1 || true + [ -n "$done_id" ] && curl -sf -X POST -H "Authorization: token $TIMMY_TOKEN" -H "Content-Type: application/json" \ -d "{\"labels\":[$done_id]}" \ - "$BASE/repos/$repo/issues/$issue_num/labels" > /dev/null 2>&1 + "$BASE/repos/$repo/issues/$issue_num/labels" > /dev/null 2>&1 || true else - log "FAILED: $repo #$issue_num — $status" - curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ - -d "{\"body\":\"🔴 **Kimi failed on this task.**\\nStatus: $status\\nTimestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')\"}" \ - "$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 + log "FAILED: $repo #$issue_num — status=$status" + + # Post failure comment + curl -sf -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "{\"body\":\"🔴 **KimiClaw failed on this task.**\\nStatus: $status\\nTimestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')\\n\\nWill retry on next heartbeat if label is reset to assigned-kimi.\"}" \ + "$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 || true + + # Remove kimi-in-progress on failure so it can be retried + [ -n "$progress_id" ] && curl -sf -X DELETE -H "Authorization: token $TIMMY_TOKEN" \ + "$BASE/repos/$repo/issues/$issue_num/labels/$progress_id" > /dev/null 2>&1 || true fi ) & - + + dispatched=$((dispatched + 1)) log "DISPATCHED: $repo #$issue_num (background PID $!)" - - # Don't flood — wait 5s between dispatches - sleep 5 - + + # Enforce dispatch cap + if [ "$dispatched" -ge "$MAX_DISPATCH" ]; then + log "CAPPED: reached $MAX_DISPATCH dispatches, remaining issues deferred to next heartbeat" + break 2 # Break out of both loops + fi + + # Stagger dispatches to avoid overwhelming kimi + sleep 3 + done <<< "$issues" done -log "Heartbeat complete. $(date)" +if [ "$dispatched" -eq 0 ]; then + log "Heartbeat: no pending tasks" +else + log "Heartbeat: dispatched $dispatched task(s)" +fi