feat: rewrite KimiClaw heartbeat — launchd, sovereignty fixes, dispatch cap
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.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user