Files
timmy-home/uniwizard/kimi-heartbeat.sh
2026-04-04 15:50:38 -04:00

308 lines
15 KiB
Bash
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# 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 2 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
#
# PLANNING: If the issue body is >500 chars or contains "##" headers,
# KimiClaw first runs a 2-minute planning pass to decompose the task.
# If it needs subtasks, it creates child issues and labels them assigned-kimi
# for the next heartbeat cycle. This prevents 10-minute timeouts on complex work.
set -euo pipefail
# --- 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=10 # Increased max dispatch to 10
PLAN_TIMEOUT=120 # 2 minutes for planning pass
EXEC_TIMEOUT=480 # 8 minutes for execution pass
BODY_COMPLEXITY_THRESHOLD=500 # chars — above this triggers planning
STALE_PROGRESS_SECONDS=3600 # reclaim kimi-in-progress after 1 hour of silence
REPOS=(
"Timmy_Foundation/timmy-home"
"Timmy_Foundation/timmy-config"
"Timmy_Foundation/the-nexus"
"Timmy_Foundation/hermes-agent"
)
# --- 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
# 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 done tasks, but reclaim stale kimi-in-progress work automatically
issues=$(echo "$response" | python3 -c "
import json, sys, datetime
STALE = int(${STALE_PROGRESS_SECONDS})
def parse_ts(value):
if not value:
return None
try:
return datetime.datetime.fromisoformat(value.replace('Z', '+00:00'))
except Exception:
return None
try:
data = json.loads(sys.stdin.buffer.read())
except:
sys.exit(0)
now = datetime.datetime.now(datetime.timezone.utc)
for i in data:
labels = [l['name'] for l in i.get('labels', [])]
if 'kimi-done' in labels:
continue
reclaim = False
updated_at = i.get('updated_at', '') or ''
if 'kimi-in-progress' in labels:
ts = parse_ts(updated_at)
age = (now - ts).total_seconds() if ts else (STALE + 1)
if age < STALE:
continue
reclaim = True
body = (i.get('body', '') or '')
body_len = len(body)
body_clean = body[:1500].replace('\n', ' ').replace('|', ' ')
title = i['title'].replace('|', ' ')
updated_clean = updated_at.replace('|', ' ')
reclaim_flag = 'reclaim' if reclaim else 'fresh'
print(f\"{i['number']}|{title}|{body_len}|{reclaim_flag}|{updated_clean}|{body_clean}\")
" 2>/dev/null)
[ -z "$issues" ] && continue
while IFS='|' read -r issue_num title body_len reclaim_flag updated_at body; do
[ -z "$issue_num" ] && continue
log "FOUND: $repo #$issue_num$title (body: ${body_len} chars, mode: ${reclaim_flag}, updated: ${updated_at})"
# --- Get label IDs for this repo ---
label_json=$(curl -sf -H "Authorization: token $TIMMY_TOKEN" \
"$BASE/repos/$repo/labels" 2>/dev/null || echo "[]")
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)
kimi_id=$(echo "$label_json" | python3 -c "import json,sys; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='assigned-kimi']" 2>/dev/null)
if [ "$reclaim_flag" = "reclaim" ]; then
log "RECLAIM: $repo #$issue_num — stale kimi-in-progress since $updated_at"
[ -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
curl -sf -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"body\":\"🟡 **KimiClaw reclaiming stale task.**\\nPrevious kimi-in-progress state exceeded ${STALE_PROGRESS_SECONDS}s without resolution.\\nLast update: $updated_at\\nTimestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')\"}" \
"$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 || true
fi
# --- 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
# --- Decide: plan first or execute directly ---
needs_planning=false
if [ "$body_len" -gt "$BODY_COMPLEXITY_THRESHOLD" ]; then
needs_planning=true
fi
if [ "$needs_planning" = true ]; then
# =============================================
# PHASE 1: PLANNING PASS (2 min timeout)
# =============================================
log "PLAN: $repo #$issue_num — complex task, running planning pass"
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)\\nMode: **Planning first** (task is complex)\\nTimestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')\"}" \
"$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 || true
plan_prompt="You are KimiClaw, a planning agent. You have 2 MINUTES.\n\nTASK: Analyze this Gitea issue and decide if you can complete it in under 8 minutes, or if it needs to be broken into subtasks.\n\nISSUE #$issue_num in $repo: $title\n\nBODY:\n$body\n\nRULES:\n- If you CAN complete this in one pass (research, write analysis, answer a question): respond with EXECUTE followed by a one-line plan.\n- If the task is TOO BIG (needs git operations, multiple repos, >2000 words of output, or multi-step implementation): respond with DECOMPOSE followed by a numbered list of 2-5 smaller subtasks. Each subtask must be completable in under 8 minutes by itself.\n- Each subtask line format: SUBTASK: <title> | <one-line description>\n- Be realistic about what fits in 8 minutes with no terminal access.\n- You CANNOT clone repos, run git, or execute code. You CAN research, analyze, write specs, review code via API, and produce documents.\n\nRespond with ONLY your decision. No preamble."
plan_result=$(openclaw agent --agent main --message "$plan_prompt" --timeout $PLAN_TIMEOUT --json 2>/dev/null || echo '{\"status\":\"error\"}')
plan_status=$(echo "$plan_result" | python3 -c "import json,sys; print(json.load(sys.stdin).get('status','error'))" 2>/dev/null || echo "error")
plan_text=$(echo "$plan_result" | python3 -c "\nimport json,sys\nd = json.load(sys.stdin)\npayloads = d.get('result',{}).get('payloads',[])\nprint(payloads[0]['text'] if payloads else '')\n" 2>/dev/null || echo "")
if echo "$plan_text" | grep -qi "^DECOMPOSE"; then
# --- Create subtask issues ---
log "DECOMPOSE: $repo #$issue_num — creating subtasks"
# Post the plan as a comment
escaped_plan=$(echo "$plan_text" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" 2>/dev/null)
curl -sf -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"body\":\"📝 **Planning complete — decomposing into subtasks:**\\n\\n$plan_text\"}" \
"$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 || true
# Extract SUBTASK lines and create child issues
echo "$plan_text" | grep -i "^SUBTASK:" | head -5 | while IFS='|' read -r sub_title sub_desc; do
sub_title=$(echo "$sub_title" | sed 's/^SUBTASK: *//')
sub_desc=$(echo "${sub_desc:-$sub_title}" | sed 's/^ *//')
if [ -n "$sub_title" ]; then
sub_body="## Parent Issue\\nChild of #$issue_num: $title\\n\\n## Task\\n$sub_desc\\n\\n## Constraints\\n- Must complete in under 8 minutes\\n- No git/terminal operations\\n- Post results as analysis/documentation\\n\\n## Assignee\\n@KimiClaw"
curl -sf -X POST -H "Authorization: token $TIMMY_TOKEN" -H "Content-Type: application/json" \
-d "{\"title\":\"[SUB] $sub_title\",\"body\":\"$sub_body\"}" \
"$BASE/repos/$repo/issues" > /dev/null 2>&1
# Get the issue number of what we just created and label it
new_num=$(curl -sf -H "Authorization: token $TIMMY_TOKEN" \
"$BASE/repos/$repo/issues?state=open&limit=1&type=issues" | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['number'] if d else '')" 2>/dev/null)
if [ -n "$new_num" ] && [ -n "$kimi_id" ]; then
curl -sf -X POST -H "Authorization: token $TIMMY_TOKEN" -H "Content-Type: application/json" \
-d "{\"labels\":[$kimi_id]}" \
"$BASE/repos/$repo/issues/$new_num/labels" > /dev/null 2>&1 || true
log "SUBTASK: $repo #$new_num$sub_title"
fi
fi
done
# Mark parent as kimi-done (subtasks will be picked up next cycle)
[ -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 || true
dispatched=$((dispatched + 1))
log "PLANNED: $repo #$issue_num — subtasks created, parent marked done"
else
# --- Plan says EXECUTE — proceed to execution ---
log "EXECUTE: $repo #$issue_num — planning pass says single-pass OK"
# Fall through to execution below
needs_planning=false
fi
fi
if [ "$needs_planning" = false ]; then
# =============================================
# PHASE 2: EXECUTION PASS (8 min timeout)
# =============================================
# Post pickup comment if we didn't already (simple tasks skip planning)
if [ "$body_len" -le "$BODY_COMPLEXITY_THRESHOLD" ]; then
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)\\nMode: **Direct execution** (task fits in one pass)\\nTimestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')\"}" \
"$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 || true
fi
log "DISPATCH: $repo #$issue_num to openclaw (timeout: ${EXEC_TIMEOUT}s)"
exec_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.
You have 8 MINUTES maximum. Be concise and focused.
ISSUE TITLE: $title
ISSUE BODY:
$body
YOUR TASK:
1. Read the issue carefully and do the work described
2. Stay focused — deliver the core ask, skip nice-to-haves
3. Provide your COMPLETE results as your response (use markdown)
4. If you realize mid-task this will take longer than 8 minutes, STOP and summarize what you've done so far plus what remains"
# --- Dispatch to OpenClaw (background) ---
(
result=$(openclaw agent --agent main --message "$exec_prompt" --timeout $EXEC_TIMEOUT --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")
# Extract response text
response_text=$(echo "$result" | python3 -c "
import json,sys
d = json.load(sys.stdin)
payloads = d.get('result',{}).get('payloads',[])
print(payloads[0]['text'][:3000] if payloads else 'No response')
" 2>/dev/null || echo "No response")
if [ "$status" = "ok" ] && [ "$response_text" != "No response" ]; then
log "COMPLETED: $repo #$issue_num"
# Post result as comment (escape for JSON)
escaped=$(echo "$response_text" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read())[1:-1])" 2>/dev/null)
curl -sf -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"body\":\" **KimiClaw result:**\\n\\n$escaped\"}" \
"$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 || true
# 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 || true
else
log "FAILED: $repo #$issue_num — status=$status"
curl -sf -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"body\":\"\ud83d\udd34 **KimiClaw failed/timed out.**\\nStatus: $status\\nTimestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')\\n\\nTask may be too complex for single-pass execution. Consider breaking into smaller subtasks.\"}" \
"$BASE/repos/$repo/issues/$issue_num/comments" > /dev/null 2>&1 || true
# Remove kimi-in-progress on failure
[ -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 $!)"
fi
# 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
if [ "$dispatched" -eq 0 ]; then
log "Heartbeat: no pending tasks"
else
log "Heartbeat: dispatched $dispatched task(s)"
fi