Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
767e563a0c [gemini] [PIPELINE] Merge adapter + convert to GGUF — produce timmy:v0.3 for Ollama (#11) 2026-03-26 12:01:07 -04:00
32 changed files with 218 additions and 270497 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@
*.db-wal
*.db-shm
__pycache__/
.aider*

View File

@@ -1,7 +1,7 @@
# DEPRECATED — Bash Loop Scripts Removed
**Date:** 2026-03-25
**Reason:** Replaced by Hermes + timmy-config sidecar orchestration
**Reason:** Replaced by sovereign-orchestration (SQLite + Python single-process executor)
## What was removed
- claude-loop.sh, gemini-loop.sh, agent-loop.sh
@@ -9,15 +9,14 @@
- nexus-merge-bot.sh, claudemax-watchdog.sh, timmy-loopstat.sh
## What replaces them
**Harness:** Hermes
**Overlay repo:** Timmy_Foundation/timmy-config
**Entry points:** `orchestration.py`, `tasks.py`, `deploy.sh`
**Features:** Huey + SQLite scheduling, local-model health checks, session export, DPO artifact staging
**Repo:** Timmy_Foundation/sovereign-orchestration
**Entry point:** `python3 src/sovereign_executor.py --workers 3 --poll 30`
**Features:** SQLite task queue, crash recovery, dedup, playbooks, MCP server
**Issues:** #29 (fix imports), #30 (deploy as service)
## Why
The bash loops crash-looped, produced zero work after relaunch, had no crash
recovery, no durable export path, and required too many ad hoc scripts. The
Hermes sidecar keeps orchestration close to Timmy's actual config and training
surfaces.
recovery, no dedup, and required 8 separate scripts. The Python executor is
one process with SQLite durability.
Do NOT recreate bash loops. If orchestration is broken, fix the Hermes sidecar.
Do NOT recreate bash loops. If the executor is broken, fix the executor.

View File

@@ -2,7 +2,7 @@
Timmy's sovereign configuration. Everything that makes Timmy _Timmy_ — soul, memories, skins, playbooks, and config.
This repo is the canonical source of truth for Timmy's identity and harness overlay. Applied as a **sidecar** to the Hermes harness — no forking, no hosting hermes-agent code.
This repo is the canonical source of truth for Timmy's identity and operational state. Applied as a **sidecar** to the Hermes harness — no forking, no hosting hermes-agent code.
## Structure
@@ -14,40 +14,22 @@ timmy-config/
├── DEPRECATED.md ← What was removed and why
├── config.yaml ← Hermes harness configuration
├── channel_directory.json ← Platform channel mappings
├── bin/ ← Live utility scripts (NOT deprecated loops)
├── bin/ ← Utility scripts (NOT loops — see below)
│ ├── hermes-startup.sh ← Hermes boot sequence
│ ├── agent-dispatch.sh ← Manual agent dispatch
│ ├── ops-panel.sh ← Ops dashboard panel
│ ├── ops-gitea.sh ← Gitea ops helpers
│ ├── pipeline-freshness.sh ← Session/export drift check
│ └── timmy-status.sh ← Status check
├── memories/ ← Persistent memory YAML
├── skins/ ← UI skins (timmy skin)
├── playbooks/ ← Agent playbooks (YAML)
── cron/ ← Cron job definitions
└── training/ ← Transitional training recipes, not canonical lived data
── cron/ ← Cron job definitions
```
## Boundary
`timmy-config` owns identity, conscience, memories, skins, playbooks, channel
maps, and harness-side orchestration glue.
`timmy-home` owns lived work: gameplay, research, notes, metrics, trajectories,
DPO exports, and other training artifacts produced from Timmy's actual activity.
If a file answers "who is Timmy?" or "how does Hermes host him?", it belongs
here. If it answers "what has Timmy done or learned?" it belongs in
`timmy-home`.
The scripts in `bin/` are live operational helpers for the Hermes sidecar.
What is dead are the old long-running bash worker loops, not every script in
this repo.
## Orchestration: Huey
All orchestration (triage, PR review, dispatch) runs via [Huey](https://github.com/coleifer/huey) with SQLite.
`orchestration.py` + `tasks.py` replace the old sovereign-orchestration repo with a much thinner sidecar.
`orchestration.py` (6 lines) + `tasks.py` (~70 lines) replace the entire sovereign-orchestration repo (3,846 lines).
```bash
pip install huey

View File

@@ -55,8 +55,7 @@ adapters:
timmy-v1.0:
base: hermes4-14b-4bit
date: 2026-03-26
status: rejected
data: 1125 train / 126 valid (same curated set, reused from 8B — NOT re-tokenized)
status: training
data: 1125 train / 126 valid (same curated set, reused)
training: { lr: 1e-6, rank: 16, iters: 800 }
eval: "Val NaN iter 100, train NaN iter 160. Dead."
notes: "Data was pre-truncated for Llama3 tokenizer, not Qwen3. Must re-run clean_data.py with 14B tokenizer before v1.1."
notes: "First 14B adapter. Conservative lr for new arch."

View File

@@ -1,620 +0,0 @@
#!/usr/bin/env bash
# claude-loop.sh — Parallel Claude Code agent dispatch loop
# Runs N workers concurrently against the Gitea backlog.
# Gracefully handles rate limits with backoff.
#
# Usage: claude-loop.sh [NUM_WORKERS] (default: 2)
set -euo pipefail
# === CONFIG ===
NUM_WORKERS="${1:-2}"
MAX_WORKERS=10 # absolute ceiling
WORKTREE_BASE="$HOME/worktrees"
GITEA_URL="http://143.198.27.163:3000"
GITEA_TOKEN=$(cat "$HOME/.hermes/claude_token")
CLAUDE_TIMEOUT=900 # 15 min per issue
COOLDOWN=15 # seconds between issues — stagger clones
RATE_LIMIT_SLEEP=30 # initial sleep on rate limit
MAX_RATE_SLEEP=120 # max backoff on rate limit
LOG_DIR="$HOME/.hermes/logs"
SKIP_FILE="$LOG_DIR/claude-skip-list.json"
LOCK_DIR="$LOG_DIR/claude-locks"
ACTIVE_FILE="$LOG_DIR/claude-active.json"
mkdir -p "$LOG_DIR" "$WORKTREE_BASE" "$LOCK_DIR"
# Initialize files
[ -f "$SKIP_FILE" ] || echo '{}' > "$SKIP_FILE"
echo '{}' > "$ACTIVE_FILE"
# === SHARED FUNCTIONS ===
log() {
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
echo "$msg" >> "$LOG_DIR/claude-loop.log"
}
lock_issue() {
local issue_key="$1"
local lockfile="$LOCK_DIR/$issue_key.lock"
if mkdir "$lockfile" 2>/dev/null; then
echo $$ > "$lockfile/pid"
return 0
fi
return 1
}
unlock_issue() {
local issue_key="$1"
rm -rf "$LOCK_DIR/$issue_key.lock" 2>/dev/null
}
mark_skip() {
local issue_num="$1"
local reason="$2"
local skip_hours="${3:-1}"
python3 -c "
import json, time, fcntl
with open('$SKIP_FILE', 'r+') as f:
fcntl.flock(f, fcntl.LOCK_EX)
try: skips = json.load(f)
except: skips = {}
skips[str($issue_num)] = {
'until': time.time() + ($skip_hours * 3600),
'reason': '$reason',
'failures': skips.get(str($issue_num), {}).get('failures', 0) + 1
}
if skips[str($issue_num)]['failures'] >= 3:
skips[str($issue_num)]['until'] = time.time() + (6 * 3600)
f.seek(0)
f.truncate()
json.dump(skips, f, indent=2)
" 2>/dev/null
log "SKIP: #${issue_num}${reason}"
}
update_active() {
local worker="$1" issue="$2" repo="$3" status="$4"
python3 -c "
import json, fcntl
with open('$ACTIVE_FILE', 'r+') as f:
fcntl.flock(f, fcntl.LOCK_EX)
try: active = json.load(f)
except: active = {}
if '$status' == 'done':
active.pop('$worker', None)
else:
active['$worker'] = {'issue': '$issue', 'repo': '$repo', 'status': '$status'}
f.seek(0)
f.truncate()
json.dump(active, f, indent=2)
" 2>/dev/null
}
cleanup_workdir() {
local wt="$1"
rm -rf "$wt" 2>/dev/null || true
}
get_next_issue() {
python3 -c "
import json, sys, time, urllib.request, os
token = '${GITEA_TOKEN}'
base = '${GITEA_URL}'
repos = [
'Timmy_Foundation/the-nexus',
'Timmy_Foundation/autolora',
]
# Load skip list
try:
with open('${SKIP_FILE}') as f: skips = json.load(f)
except: skips = {}
# Load active issues (to avoid double-picking)
try:
with open('${ACTIVE_FILE}') as f:
active = json.load(f)
active_issues = {v['issue'] for v in active.values()}
except:
active_issues = set()
all_issues = []
for repo in repos:
url = f'{base}/api/v1/repos/{repo}/issues?state=open&type=issues&limit=50&sort=created'
req = urllib.request.Request(url, headers={'Authorization': f'token {token}'})
try:
resp = urllib.request.urlopen(req, timeout=10)
issues = json.loads(resp.read())
for i in issues:
i['_repo'] = repo
all_issues.extend(issues)
except:
continue
# Sort by priority: URGENT > P0 > P1 > bugs > LHF > rest
def priority(i):
t = i['title'].lower()
if '[urgent]' in t or 'urgent:' in t: return 0
if '[p0]' in t: return 1
if '[p1]' in t: return 2
if '[bug]' in t: return 3
if 'lhf:' in t or 'lhf ' in t.lower(): return 4
if '[p2]' in t: return 5
return 6
all_issues.sort(key=priority)
for i in all_issues:
assignees = [a['login'] for a in (i.get('assignees') or [])]
# Take issues assigned to claude OR unassigned (self-assign)
if assignees and 'claude' not in assignees:
continue
title = i['title'].lower()
if '[philosophy]' in title: continue
if '[epic]' in title or 'epic:' in title: continue
if '[showcase]' in title: continue
if '[do not close' in title: continue
if '[meta]' in title: continue
if '[governing]' in title: continue
if '[permanent]' in title: continue
if '[morning report]' in title: continue
if '[retro]' in title: continue
if '[intel]' in title: continue
if 'master escalation' in title: continue
if any(a['login'] == 'Rockachopa' for a in (i.get('assignees') or [])): continue
num_str = str(i['number'])
if num_str in active_issues: continue
entry = skips.get(num_str, {})
if entry and entry.get('until', 0) > time.time(): continue
lock = '${LOCK_DIR}/' + i['_repo'].replace('/', '-') + '-' + num_str + '.lock'
if os.path.isdir(lock): continue
repo = i['_repo']
owner, name = repo.split('/')
# Self-assign if unassigned
if not assignees:
try:
data = json.dumps({'assignees': ['claude']}).encode()
req2 = urllib.request.Request(
f'{base}/api/v1/repos/{repo}/issues/{i[\"number\"]}',
data=data, method='PATCH',
headers={'Authorization': f'token {token}', 'Content-Type': 'application/json'})
urllib.request.urlopen(req2, timeout=5)
except: pass
print(json.dumps({
'number': i['number'],
'title': i['title'],
'repo_owner': owner,
'repo_name': name,
'repo': repo,
}))
sys.exit(0)
print('null')
" 2>/dev/null
}
build_prompt() {
local issue_num="$1"
local issue_title="$2"
local worktree="$3"
local repo_owner="$4"
local repo_name="$5"
cat <<PROMPT
You are Claude, an autonomous code agent on the ${repo_name} project.
YOUR ISSUE: #${issue_num} — "${issue_title}"
GITEA API: ${GITEA_URL}/api/v1
GITEA TOKEN: ${GITEA_TOKEN}
REPO: ${repo_owner}/${repo_name}
WORKING DIRECTORY: ${worktree}
== YOUR POWERS ==
You can do ANYTHING a developer can do.
1. READ the issue and any comments for context:
curl -s -H "Authorization: token ${GITEA_TOKEN}" "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/issues/${issue_num}"
curl -s -H "Authorization: token ${GITEA_TOKEN}" "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/issues/${issue_num}/comments"
2. DO THE WORK. Code, test, fix, refactor — whatever the issue needs.
- Check for tox.ini / Makefile / package.json for test/lint commands
- Run tests if the project has them
- Follow existing code conventions
3. COMMIT with conventional commits: fix: / feat: / refactor: / test: / chore:
Include "Fixes #${issue_num}" or "Refs #${issue_num}" in the message.
4. PUSH to your branch (claude/issue-${issue_num}) and CREATE A PR:
git push origin claude/issue-${issue_num}
curl -s -X POST "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls" \\
-H "Authorization: token ${GITEA_TOKEN}" \\
-H "Content-Type: application/json" \\
-d '{"title": "[claude] <description> (#${issue_num})", "body": "Fixes #${issue_num}\n\n<describe what you did>", "head": "claude/issue-${issue_num}", "base": "main"}'
5. COMMENT on the issue when done:
curl -s -X POST "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/issues/${issue_num}/comments" \\
-H "Authorization: token ${GITEA_TOKEN}" \\
-H "Content-Type: application/json" \\
-d '{"body": "PR created. <summary of changes>"}'
== RULES ==
- Read CLAUDE.md or project README first for conventions
- If the project has tox, use tox. If npm, use npm. Follow the project.
- Never use --no-verify on git commands.
- If tests fail after 2 attempts, STOP and comment on the issue explaining why.
- Be thorough but focused. Fix the issue, don't refactor the world.
== CRITICAL: ALWAYS COMMIT AND PUSH ==
- NEVER exit without committing your work. Even partial progress MUST be committed.
- Before you finish, ALWAYS: git add -A && git commit && git push origin claude/issue-${issue_num}
- ALWAYS create a PR before exiting. No exceptions.
- If a branch already exists with prior work, check it out and CONTINUE from where it left off.
- Check: git ls-remote origin claude/issue-${issue_num} — if it exists, pull it first.
- Your work is WASTED if it's not pushed. Push early, push often.
PROMPT
}
# === WORKER FUNCTION ===
run_worker() {
local worker_id="$1"
local consecutive_failures=0
log "WORKER-${worker_id}: Started"
while true; do
# Backoff on repeated failures
if [ "$consecutive_failures" -ge 5 ]; then
local backoff=$((RATE_LIMIT_SLEEP * (consecutive_failures / 5)))
[ "$backoff" -gt "$MAX_RATE_SLEEP" ] && backoff=$MAX_RATE_SLEEP
log "WORKER-${worker_id}: BACKOFF ${backoff}s (${consecutive_failures} failures)"
sleep "$backoff"
consecutive_failures=0
fi
# RULE: Merge existing PRs BEFORE creating new work.
# Check for open PRs from claude, rebase + merge them first.
local our_prs
our_prs=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/Timmy_Foundation/the-nexus/pulls?state=open&limit=5" 2>/dev/null | \
python3 -c "
import sys, json
prs = json.loads(sys.stdin.buffer.read())
ours = [p for p in prs if p['user']['login'] == 'claude'][:3]
for p in ours:
print(f'{p[\"number\"]}|{p[\"head\"][\"ref\"]}|{p.get(\"mergeable\",False)}')
" 2>/dev/null)
if [ -n "$our_prs" ]; then
local pr_clone_url="http://claude:${GITEA_TOKEN}@143.198.27.163:3000/Timmy_Foundation/the-nexus.git"
echo "$our_prs" | while IFS='|' read pr_num branch mergeable; do
[ -z "$pr_num" ] && continue
if [ "$mergeable" = "True" ]; then
curl -sf -X POST -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"Do":"squash","delete_branch_after_merge":true}' \
"${GITEA_URL}/api/v1/repos/Timmy_Foundation/the-nexus/pulls/${pr_num}/merge" >/dev/null 2>&1
log "WORKER-${worker_id}: merged own PR #${pr_num}"
sleep 3
else
# Rebase and push
local tmpdir="/tmp/claude-rebase-${pr_num}"
cd "$HOME"; rm -rf "$tmpdir" 2>/dev/null
git clone -q --depth=50 -b "$branch" "$pr_clone_url" "$tmpdir" 2>/dev/null
if [ -d "$tmpdir/.git" ]; then
cd "$tmpdir"
git fetch origin main 2>/dev/null
if git rebase origin/main 2>/dev/null; then
git push -f origin "$branch" 2>/dev/null
sleep 3
curl -sf -X POST -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"Do":"squash","delete_branch_after_merge":true}' \
"${GITEA_URL}/api/v1/repos/Timmy_Foundation/the-nexus/pulls/${pr_num}/merge" >/dev/null 2>&1
log "WORKER-${worker_id}: rebased+merged PR #${pr_num}"
else
git rebase --abort 2>/dev/null
curl -sf -X PATCH -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" -d '{"state":"closed"}' \
"${GITEA_URL}/api/v1/repos/Timmy_Foundation/the-nexus/pulls/${pr_num}" >/dev/null 2>&1
log "WORKER-${worker_id}: closed unrebaseable PR #${pr_num}"
fi
cd "$HOME"; rm -rf "$tmpdir"
fi
fi
done
fi
# Get next issue
issue_json=$(get_next_issue)
if [ "$issue_json" = "null" ] || [ -z "$issue_json" ]; then
update_active "$worker_id" "" "" "idle"
sleep 10
continue
fi
issue_num=$(echo "$issue_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['number'])")
issue_title=$(echo "$issue_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['title'])")
repo_owner=$(echo "$issue_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['repo_owner'])")
repo_name=$(echo "$issue_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['repo_name'])")
issue_key="${repo_owner}-${repo_name}-${issue_num}"
branch="claude/issue-${issue_num}"
# Use UUID for worktree dir to prevent collisions under high concurrency
wt_uuid=$(/usr/bin/uuidgen 2>/dev/null || python3 -c "import uuid; print(uuid.uuid4())")
worktree="${WORKTREE_BASE}/claude-${issue_num}-${wt_uuid}"
# Try to lock
if ! lock_issue "$issue_key"; then
sleep 5
continue
fi
log "WORKER-${worker_id}: === ISSUE #${issue_num}: ${issue_title} (${repo_owner}/${repo_name}) ==="
update_active "$worker_id" "$issue_num" "${repo_owner}/${repo_name}" "working"
# Clone and pick up prior work if it exists
rm -rf "$worktree" 2>/dev/null
CLONE_URL="http://claude:${GITEA_TOKEN}@143.198.27.163:3000/${repo_owner}/${repo_name}.git"
# Check if branch already exists on remote (prior work to continue)
if git ls-remote --heads "$CLONE_URL" "$branch" 2>/dev/null | grep -q "$branch"; then
log "WORKER-${worker_id}: Found existing branch $branch — continuing prior work"
if ! git clone --depth=50 -b "$branch" "$CLONE_URL" "$worktree" >/dev/null 2>&1; then
log "WORKER-${worker_id}: ERROR cloning branch $branch for #${issue_num}"
unlock_issue "$issue_key"
consecutive_failures=$((consecutive_failures + 1))
sleep "$COOLDOWN"
continue
fi
# Rebase on main to resolve stale conflicts from closed PRs
cd "$worktree"
git fetch origin main >/dev/null 2>&1
if ! git rebase origin/main >/dev/null 2>&1; then
# Rebase failed — start fresh from main
log "WORKER-${worker_id}: Rebase failed for $branch, starting fresh"
cd "$HOME"
rm -rf "$worktree"
git clone --depth=1 -b main "$CLONE_URL" "$worktree" >/dev/null 2>&1
cd "$worktree"
git checkout -b "$branch" >/dev/null 2>&1
fi
else
if ! git clone --depth=1 -b main "$CLONE_URL" "$worktree" >/dev/null 2>&1; then
log "WORKER-${worker_id}: ERROR cloning for #${issue_num}"
unlock_issue "$issue_key"
consecutive_failures=$((consecutive_failures + 1))
sleep "$COOLDOWN"
continue
fi
cd "$worktree"
git checkout -b "$branch" >/dev/null 2>&1
fi
cd "$worktree"
# Build prompt and run
prompt=$(build_prompt "$issue_num" "$issue_title" "$worktree" "$repo_owner" "$repo_name")
log "WORKER-${worker_id}: Launching Claude Code for #${issue_num}..."
CYCLE_START=$(date +%s)
set +e
cd "$worktree"
env -u CLAUDECODE gtimeout "$CLAUDE_TIMEOUT" claude \
--print \
--model sonnet \
--dangerously-skip-permissions \
-p "$prompt" \
</dev/null >> "$LOG_DIR/claude-${issue_num}.log" 2>&1
exit_code=$?
set -e
CYCLE_END=$(date +%s)
CYCLE_DURATION=$(( CYCLE_END - CYCLE_START ))
# ── SALVAGE: Never waste work. Commit+push whatever exists. ──
cd "$worktree" 2>/dev/null || true
DIRTY=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
UNPUSHED=$(git log --oneline "origin/main..HEAD" 2>/dev/null | wc -l | tr -d ' ')
if [ "${DIRTY:-0}" -gt 0 ]; then
log "WORKER-${worker_id}: SALVAGING $DIRTY dirty files for #${issue_num}"
git add -A 2>/dev/null
git commit -m "WIP: Claude Code progress on #${issue_num}
Automated salvage commit — agent session ended (exit $exit_code).
Work in progress, may need continuation." 2>/dev/null || true
fi
# Push if we have any commits (including salvaged ones)
UNPUSHED=$(git log --oneline "origin/main..HEAD" 2>/dev/null | wc -l | tr -d ' ')
if [ "${UNPUSHED:-0}" -gt 0 ]; then
git push -u origin "$branch" 2>/dev/null && \
log "WORKER-${worker_id}: Pushed $UNPUSHED commit(s) on $branch" || \
log "WORKER-${worker_id}: Push failed for $branch"
fi
# ── Create PR if branch was pushed and no PR exists yet ──
pr_num=$(curl -sf "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls?state=open&head=${repo_owner}:${branch}&limit=1" \
-H "Authorization: token ${GITEA_TOKEN}" | python3 -c "
import sys,json
prs = json.load(sys.stdin)
if prs: print(prs[0]['number'])
else: print('')
" 2>/dev/null)
if [ -z "$pr_num" ] && [ "${UNPUSHED:-0}" -gt 0 ]; then
pr_num=$(curl -sf -X POST "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(python3 -c "
import json
print(json.dumps({
'title': 'Claude: Issue #${issue_num}',
'head': '${branch}',
'base': 'main',
'body': 'Automated PR for issue #${issue_num}.\nExit code: ${exit_code}'
}))
")" | python3 -c "import sys,json; print(json.load(sys.stdin).get('number',''))" 2>/dev/null)
[ -n "$pr_num" ] && log "WORKER-${worker_id}: Created PR #${pr_num} for issue #${issue_num}"
fi
# ── Merge + close on success ──
if [ "$exit_code" -eq 0 ]; then
log "WORKER-${worker_id}: SUCCESS #${issue_num}"
if [ -n "$pr_num" ]; then
curl -sf -X POST "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls/${pr_num}/merge" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"Do": "squash"}' >/dev/null 2>&1 || true
curl -sf -X PATCH "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/issues/${issue_num}" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"state": "closed"}' >/dev/null 2>&1 || true
log "WORKER-${worker_id}: PR #${pr_num} merged, issue #${issue_num} closed"
fi
consecutive_failures=0
elif [ "$exit_code" -eq 124 ]; then
log "WORKER-${worker_id}: TIMEOUT #${issue_num} (work saved in PR)"
consecutive_failures=$((consecutive_failures + 1))
else
# Check for rate limit
if grep -q "rate_limit\|rate limit\|429\|overloaded" "$LOG_DIR/claude-${issue_num}.log" 2>/dev/null; then
log "WORKER-${worker_id}: RATE LIMITED on #${issue_num} — backing off (work saved)"
consecutive_failures=$((consecutive_failures + 3))
else
log "WORKER-${worker_id}: FAILED #${issue_num} exit ${exit_code} (work saved in PR)"
consecutive_failures=$((consecutive_failures + 1))
fi
fi
# ── METRICS: structured JSONL for reporting ──
LINES_ADDED=$(cd "$worktree" 2>/dev/null && git diff --stat origin/main..HEAD 2>/dev/null | tail -1 | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo 0)
LINES_REMOVED=$(cd "$worktree" 2>/dev/null && git diff --stat origin/main..HEAD 2>/dev/null | tail -1 | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo 0)
FILES_CHANGED=$(cd "$worktree" 2>/dev/null && git diff --name-only origin/main..HEAD 2>/dev/null | wc -l | tr -d ' ' || echo 0)
# Determine outcome
if [ "$exit_code" -eq 0 ]; then
OUTCOME="success"
elif [ "$exit_code" -eq 124 ]; then
OUTCOME="timeout"
elif grep -q "rate_limit\|rate limit\|429" "$LOG_DIR/claude-${issue_num}.log" 2>/dev/null; then
OUTCOME="rate_limited"
else
OUTCOME="failed"
fi
METRICS_FILE="$LOG_DIR/claude-metrics.jsonl"
python3 -c "
import json, datetime
print(json.dumps({
'ts': datetime.datetime.utcnow().isoformat() + 'Z',
'worker': $worker_id,
'issue': $issue_num,
'repo': '${repo_owner}/${repo_name}',
'title': '''${issue_title}'''[:80],
'outcome': '$OUTCOME',
'exit_code': $exit_code,
'duration_s': $CYCLE_DURATION,
'files_changed': ${FILES_CHANGED:-0},
'lines_added': ${LINES_ADDED:-0},
'lines_removed': ${LINES_REMOVED:-0},
'salvaged': ${DIRTY:-0},
'pr': '${pr_num:-}',
'merged': $( [ '$OUTCOME' = 'success' ] && [ -n '${pr_num:-}' ] && echo 'true' || echo 'false' )
}))
" >> "$METRICS_FILE" 2>/dev/null
# Cleanup
cleanup_workdir "$worktree"
unlock_issue "$issue_key"
update_active "$worker_id" "" "" "done"
sleep "$COOLDOWN"
done
}
# === MAIN ===
log "=== Claude Loop Started — ${NUM_WORKERS} workers (max ${MAX_WORKERS}) ==="
log "Worktrees: ${WORKTREE_BASE}"
# Clean stale locks
rm -rf "$LOCK_DIR"/*.lock 2>/dev/null
# PID tracking via files (bash 3.2 compatible)
PID_DIR="$LOG_DIR/claude-pids"
mkdir -p "$PID_DIR"
rm -f "$PID_DIR"/*.pid 2>/dev/null
launch_worker() {
local wid="$1"
run_worker "$wid" &
echo $! > "$PID_DIR/${wid}.pid"
log "Launched worker $wid (PID $!)"
}
# Initial launch
for i in $(seq 1 "$NUM_WORKERS"); do
launch_worker "$i"
sleep 3
done
# === DYNAMIC SCALER ===
# Every 3 minutes: check health, scale up if no rate limits, scale down if hitting limits
CURRENT_WORKERS="$NUM_WORKERS"
while true; do
sleep 90
# Reap dead workers and relaunch
for pidfile in "$PID_DIR"/*.pid; do
[ -f "$pidfile" ] || continue
wid=$(basename "$pidfile" .pid)
wpid=$(cat "$pidfile")
if ! kill -0 "$wpid" 2>/dev/null; then
log "SCALER: Worker $wid died — relaunching"
launch_worker "$wid"
sleep 2
fi
done
recent_rate_limits=$(tail -100 "$LOG_DIR/claude-loop.log" 2>/dev/null | grep -c "RATE LIMITED" || true)
recent_successes=$(tail -100 "$LOG_DIR/claude-loop.log" 2>/dev/null | grep -c "SUCCESS" || true)
if [ "$recent_rate_limits" -gt 0 ]; then
if [ "$CURRENT_WORKERS" -gt 2 ]; then
drop_to=$(( CURRENT_WORKERS / 2 ))
[ "$drop_to" -lt 2 ] && drop_to=2
log "SCALER: Rate limited — scaling ${CURRENT_WORKERS}${drop_to} workers"
for wid in $(seq $((drop_to + 1)) "$CURRENT_WORKERS"); do
if [ -f "$PID_DIR/${wid}.pid" ]; then
kill "$(cat "$PID_DIR/${wid}.pid")" 2>/dev/null || true
rm -f "$PID_DIR/${wid}.pid"
update_active "$wid" "" "" "done"
fi
done
CURRENT_WORKERS=$drop_to
fi
elif [ "$recent_successes" -ge 2 ] && [ "$CURRENT_WORKERS" -lt "$MAX_WORKERS" ]; then
new_count=$(( CURRENT_WORKERS + 2 ))
[ "$new_count" -gt "$MAX_WORKERS" ] && new_count=$MAX_WORKERS
log "SCALER: Healthy — scaling ${CURRENT_WORKERS}${new_count} workers"
for wid in $(seq $((CURRENT_WORKERS + 1)) "$new_count"); do
launch_worker "$wid"
sleep 2
done
CURRENT_WORKERS=$new_count
fi
done

View File

@@ -1,94 +0,0 @@
#!/usr/bin/env bash
# claudemax-watchdog.sh — keep local Claude/Gemini loops alive without stale tmux assumptions
set -uo pipefail
export PATH="/opt/homebrew/bin:$HOME/.local/bin:$HOME/.hermes/bin:/usr/local/bin:$PATH"
LOG="$HOME/.hermes/logs/claudemax-watchdog.log"
GITEA_URL="http://143.198.27.163:3000"
GITEA_TOKEN=$(tr -d '[:space:]' < "$HOME/.hermes/gitea_token_vps" 2>/dev/null || true)
REPO_API="$GITEA_URL/api/v1/repos/Timmy_Foundation/the-nexus"
MIN_OPEN_ISSUES=10
CLAUDE_WORKERS=2
GEMINI_WORKERS=1
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] CLAUDEMAX: $*" >> "$LOG"
}
start_loop() {
local name="$1"
local pattern="$2"
local cmd="$3"
local pid
pid=$(pgrep -f "$pattern" 2>/dev/null | head -1 || true)
if [ -n "$pid" ]; then
log "$name alive (PID $pid)"
return 0
fi
log "$name not running. Restarting..."
nohup bash -lc "$cmd" >/dev/null 2>&1 &
sleep 2
pid=$(pgrep -f "$pattern" 2>/dev/null | head -1 || true)
if [ -n "$pid" ]; then
log "Restarted $name (PID $pid)"
else
log "ERROR: failed to start $name"
fi
}
run_optional_script() {
local label="$1"
local script_path="$2"
if [ -x "$script_path" ]; then
bash "$script_path" 2>&1 | while read -r line; do
log "$line"
done
else
log "$label skipped — missing $script_path"
fi
}
claude_quota_blocked() {
local cutoff now mtime f
now=$(date +%s)
cutoff=$((now - 43200))
for f in "$HOME"/.hermes/logs/claude-*.log; do
[ -f "$f" ] || continue
mtime=$(stat -f %m "$f" 2>/dev/null || echo 0)
if [ "$mtime" -ge "$cutoff" ] && grep -q "You've hit your limit" "$f" 2>/dev/null; then
return 0
fi
done
return 1
}
if [ -z "$GITEA_TOKEN" ]; then
log "ERROR: missing Gitea token at ~/.hermes/gitea_token_vps"
exit 1
fi
if claude_quota_blocked; then
log "Claude quota exhausted recently — not starting claude-loop until quota resets or logs age out"
else
start_loop "claude-loop" "bash .*claude-loop.sh" "bash ~/.hermes/bin/claude-loop.sh $CLAUDE_WORKERS >> ~/.hermes/logs/claude-loop.log 2>&1"
fi
start_loop "gemini-loop" "bash .*gemini-loop.sh" "bash ~/.hermes/bin/gemini-loop.sh $GEMINI_WORKERS >> ~/.hermes/logs/gemini-loop.log 2>&1"
OPEN_COUNT=$(curl -s --max-time 10 -H "Authorization: token $GITEA_TOKEN" \
"$REPO_API/issues?state=open&type=issues&limit=100" 2>/dev/null \
| python3 -c "import sys, json; print(len(json.loads(sys.stdin.read() or '[]')))" 2>/dev/null || echo 0)
log "Open issues: $OPEN_COUNT (minimum: $MIN_OPEN_ISSUES)"
if [ "$OPEN_COUNT" -lt "$MIN_OPEN_ISSUES" ]; then
log "Backlog running low. Checking replenishment helper..."
run_optional_script "claudemax-replenish" "$HOME/.hermes/bin/claudemax-replenish.sh"
fi
run_optional_script "autodeploy-matrix" "$HOME/.hermes/bin/autodeploy-matrix.sh"
log "Watchdog complete."

View File

@@ -1,42 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SESSIONS_DIR="$HOME/.hermes/sessions"
EXPORT_DIR="$HOME/.timmy/training-data/dpo-pairs"
latest_session=$(find "$SESSIONS_DIR" -maxdepth 1 -name 'session_*.json' -type f -print 2>/dev/null | sort | tail -n 1)
latest_export=$(find "$EXPORT_DIR" -maxdepth 1 -name 'session_*.json' -type f -print 2>/dev/null | sort | tail -n 1)
echo "latest_session=${latest_session:-none}"
echo "latest_export=${latest_export:-none}"
if [ -z "${latest_session:-}" ]; then
echo "status=ok"
echo "reason=no sessions yet"
exit 0
fi
if [ -z "${latest_export:-}" ]; then
echo "status=lagging"
echo "reason=no exports yet"
exit 1
fi
session_mtime=$(stat -f '%m' "$latest_session")
export_mtime=$(stat -f '%m' "$latest_export")
lag_minutes=$(( (session_mtime - export_mtime) / 60 ))
if [ "$lag_minutes" -lt 0 ]; then
lag_minutes=0
fi
echo "lag_minutes=$lag_minutes"
if [ "$lag_minutes" -gt 300 ]; then
echo "status=lagging"
echo "reason=exports more than 5 hours behind sessions"
exit 1
fi
echo "status=ok"
echo "reason=exports within freshness window"

View File

@@ -1,252 +0,0 @@
#!/usr/bin/env python3
"""Timmy Model Dashboard — where are my models, what are they doing.
Usage:
timmy-dashboard # one-shot
timmy-dashboard --watch # live refresh every 30s
timmy-dashboard --hours=48 # look back 48h
"""
import json
import os
import subprocess
import sys
import time
import urllib.request
from datetime import datetime, timezone, timedelta
from pathlib import Path
HERMES_HOME = Path.home() / ".hermes"
TIMMY_HOME = Path.home() / ".timmy"
METRICS_DIR = TIMMY_HOME / "metrics"
# ── Data Sources ──────────────────────────────────────────────────────
def get_ollama_models():
try:
req = urllib.request.Request("http://localhost:11434/api/tags")
with urllib.request.urlopen(req, timeout=5) as resp:
return json.loads(resp.read()).get("models", [])
except Exception:
return []
def get_loaded_models():
try:
req = urllib.request.Request("http://localhost:11434/api/ps")
with urllib.request.urlopen(req, timeout=5) as resp:
return json.loads(resp.read()).get("models", [])
except Exception:
return []
def get_huey_pid():
try:
r = subprocess.run(["pgrep", "-f", "huey_consumer"],
capture_output=True, text=True, timeout=5)
return r.stdout.strip().split("\n")[0] if r.returncode == 0 else None
except Exception:
return None
def get_hermes_sessions():
sessions_file = HERMES_HOME / "sessions" / "sessions.json"
if not sessions_file.exists():
return []
try:
data = json.loads(sessions_file.read_text())
return list(data.values())
except Exception:
return []
def get_heartbeat_ticks(date_str=None):
if not date_str:
date_str = datetime.now().strftime("%Y%m%d")
tick_file = TIMMY_HOME / "heartbeat" / f"ticks_{date_str}.jsonl"
if not tick_file.exists():
return []
ticks = []
for line in tick_file.read_text().strip().split("\n"):
if not line.strip():
continue
try:
ticks.append(json.loads(line))
except Exception:
continue
return ticks
def get_local_metrics(hours=24):
"""Read local inference metrics from jsonl files."""
records = []
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
if not METRICS_DIR.exists():
return records
for f in sorted(METRICS_DIR.glob("local_*.jsonl")):
for line in f.read_text().strip().split("\n"):
if not line.strip():
continue
try:
r = json.loads(line)
ts = datetime.fromisoformat(r["timestamp"])
if ts >= cutoff:
records.append(r)
except Exception:
continue
return records
def get_cron_jobs():
"""Get Hermes cron job status."""
try:
r = subprocess.run(
["hermes", "cron", "list", "--json"],
capture_output=True, text=True, timeout=10
)
if r.returncode == 0:
return json.loads(r.stdout).get("jobs", [])
except Exception:
pass
return []
# ── Rendering ─────────────────────────────────────────────────────────
DIM = "\033[2m"
BOLD = "\033[1m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
RST = "\033[0m"
CLR = "\033[2J\033[H"
def render(hours=24):
models = get_ollama_models()
loaded = get_loaded_models()
huey_pid = get_huey_pid()
ticks = get_heartbeat_ticks()
metrics = get_local_metrics(hours)
sessions = get_hermes_sessions()
loaded_names = {m.get("name", "") for m in loaded}
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(CLR, end="")
print(f"{BOLD}{'=' * 70}")
print(f" TIMMY MODEL DASHBOARD")
print(f" {now} | Huey: {GREEN}PID {huey_pid}{RST if huey_pid else f'{RED}DOWN{RST}'}")
print(f"{'=' * 70}{RST}")
# ── LOCAL MODELS ──
print(f"\n {BOLD}LOCAL MODELS (Ollama){RST}")
print(f" {DIM}{'-' * 55}{RST}")
if models:
for m in models:
name = m.get("name", "?")
size_gb = m.get("size", 0) / 1e9
if name in loaded_names:
status = f"{GREEN}IN VRAM{RST}"
else:
status = f"{DIM}on disk{RST}"
print(f" {name:35s} {size_gb:5.1f}GB {status}")
else:
print(f" {RED}(Ollama not responding){RST}")
# ── LOCAL INFERENCE ACTIVITY ──
print(f"\n {BOLD}LOCAL INFERENCE ({len(metrics)} calls, last {hours}h){RST}")
print(f" {DIM}{'-' * 55}{RST}")
if metrics:
by_caller = {}
for r in metrics:
caller = r.get("caller", "unknown")
if caller not in by_caller:
by_caller[caller] = {"count": 0, "success": 0, "errors": 0}
by_caller[caller]["count"] += 1
if r.get("success"):
by_caller[caller]["success"] += 1
else:
by_caller[caller]["errors"] += 1
for caller, stats in by_caller.items():
err = f" {RED}err:{stats['errors']}{RST}" if stats["errors"] else ""
print(f" {caller:25s} calls:{stats['count']:4d} "
f"{GREEN}ok:{stats['success']}{RST}{err}")
by_model = {}
for r in metrics:
model = r.get("model", "unknown")
by_model[model] = by_model.get(model, 0) + 1
print(f"\n {DIM}Models used:{RST}")
for model, count in sorted(by_model.items(), key=lambda x: -x[1]):
print(f" {model:30s} {count} calls")
else:
print(f" {DIM}(no local calls recorded yet){RST}")
# ── HEARTBEAT STATUS ──
print(f"\n {BOLD}HEARTBEAT ({len(ticks)} ticks today){RST}")
print(f" {DIM}{'-' * 55}{RST}")
if ticks:
last = ticks[-1]
decision = last.get("decision", last.get("actions", {}))
if isinstance(decision, dict):
severity = decision.get("severity", "unknown")
reasoning = decision.get("reasoning", "")
sev_color = GREEN if severity == "ok" else YELLOW if severity == "warning" else RED
print(f" Last tick: {last.get('tick_id', '?')}")
print(f" Severity: {sev_color}{severity}{RST}")
if reasoning:
print(f" Reasoning: {reasoning[:65]}")
else:
print(f" Last tick: {last.get('tick_id', '?')}")
actions = last.get("actions", [])
print(f" Actions: {actions if actions else 'none'}")
model_decisions = sum(1 for t in ticks
if isinstance(t.get("decision"), dict)
and t["decision"].get("severity") != "fallback")
fallback = len(ticks) - model_decisions
print(f" {CYAN}Model: {model_decisions}{RST} | {DIM}Fallback: {fallback}{RST}")
else:
print(f" {DIM}(no ticks today){RST}")
# ── HERMES SESSIONS ──
local_sessions = [s for s in sessions
if "localhost:11434" in str(s.get("base_url", ""))]
cloud_sessions = [s for s in sessions if s not in local_sessions]
print(f"\n {BOLD}HERMES SESSIONS{RST}")
print(f" {DIM}{'-' * 55}{RST}")
print(f" Total: {len(sessions)} | "
f"{GREEN}Local: {len(local_sessions)}{RST} | "
f"{YELLOW}Cloud: {len(cloud_sessions)}{RST}")
# ── ACTIVE LOOPS ──
print(f"\n {BOLD}ACTIVE LOOPS{RST}")
print(f" {DIM}{'-' * 55}{RST}")
print(f" {CYAN}heartbeat_tick{RST} 10m hermes4:14b DECIDE phase")
print(f" {DIM}model_health{RST} 5m (local check) Ollama ping")
print(f" {DIM}gemini_worker{RST} 20m gemini-2.5-pro aider")
print(f" {DIM}grok_worker{RST} 20m grok-3-fast opencode")
print(f" {DIM}cross_review{RST} 30m gemini+grok PR review")
print(f"\n{BOLD}{'=' * 70}{RST}")
print(f" {DIM}Refresh: timmy-dashboard --watch | History: --hours=N{RST}")
if __name__ == "__main__":
watch = "--watch" in sys.argv
hours = 24
for a in sys.argv[1:]:
if a.startswith("--hours="):
hours = int(a.split("=")[1])
if watch:
try:
while True:
render(hours)
time.sleep(30)
except KeyboardInterrupt:
print(f"\n{DIM}Dashboard stopped.{RST}")
else:
render(hours)

View File

@@ -1,21 +0,0 @@
# Gitea
GITEA_URL=http://143.198.27.163:3000
# Prefer setting GITEA_TOKEN directly in deployment. If omitted, GITEA_TOKEN_FILE is used.
GITEA_TOKEN_FILE=~/.config/gitea/timmy-token
# Nostr relay
RELAY_URL=wss://alexanderwhitestone.com/relay/
# Bridge identity
BRIDGE_IDENTITY=allegro
KEYSTORE_PATH=~/.timmy/nostr/agent_keys.json
# Optional: set BRIDGE_NSEC directly instead of using KEYSTORE_PATH + BRIDGE_IDENTITY
# Useful when the deployment keystore does not contain the default identity name.
# BRIDGE_NSEC=
# Gitea routing
DEFAULT_REPO=Timmy_Foundation/timmy-config
STATUS_ASSIGNEE=allegro
# Comma-separated list of allowed operator npubs
AUTHORIZED_NPUBS=npub1t8exnw6sp7vtxar8q5teyr0ueq0rvtgqpq5jkzylegupqulxfqwq4j66p5

View File

@@ -1,3 +0,0 @@
__pycache__/
*.pyc
.env

View File

@@ -1,97 +0,0 @@
# Nostr DM → Gitea Bridge
Imported into repo truth from the live Allegro VPS bridge and sanitized for reproducible deployment.
This bridge lets an authorized Nostr operator send encrypted DMs from Nostur that create or update Gitea issues. Gitea remains the system of record. Nostr is operator ingress only.
## What it does
- `!status` returns the configured assignee queue from Gitea
- `!issue "Title" "Body"` creates a new Gitea issue
- `!comment #123 "Text"` comments on an existing issue
- freeform text creates an issue in the configured default repo
- every mutation replies with the canonical Gitea URL
## Repo truth vs live origin
The original running bridge on Allegro proved the concept, but it contained machine-local assumptions:
- root-only token path
- root-only keystore path
- hardcoded bridge identity
- hardcoded assignee and repo
- VPS-specific systemd paths
This repo copy removes those assumptions and makes deployment explicit through environment variables.
## Configuration
Copy `.env.example` to `.env` and set the values for your host.
Required at runtime:
- `GITEA_TOKEN` or `GITEA_TOKEN_FILE`
- `BRIDGE_NSEC` or `KEYSTORE_PATH` + `BRIDGE_IDENTITY`
Common settings:
- `GITEA_URL` default: `http://143.198.27.163:3000`
- `RELAY_URL` default: `wss://alexanderwhitestone.com/relay/`
- `DEFAULT_REPO` default: `Timmy_Foundation/timmy-config`
- `AUTHORIZED_NPUBS` default: Alexander's operator npub
- `STATUS_ASSIGNEE` default: same as `BRIDGE_IDENTITY`
## Files
- `bridge_allegro.py` — bridge daemon
- `test_bridge.py` — component validation script
- `nostr-dm-bridge.service` — example systemd unit
- `.env.example` — deployment template
## Manual run
```bash
cd /opt/timmy/nostr-dm-bridge
cp .env.example .env
# edit .env
python3 bridge_allegro.py
```
## Validation
```bash
cd /opt/timmy/nostr-dm-bridge
python3 test_bridge.py
```
If the configured `BRIDGE_IDENTITY` is not present in the local keystore, the test script generates an ephemeral bridge key so parser/encryption validation still works without production secrets.
## Systemd
```bash
sudo cp nostr-dm-bridge.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now nostr-dm-bridge
sudo systemctl status nostr-dm-bridge
```
The unit expects the repo to live at `/opt/timmy/nostr-dm-bridge` and reads optional runtime config from `/opt/timmy/nostr-dm-bridge/.env`.
## Security model
1. Only configured `AUTHORIZED_NPUBS` can trigger mutations.
2. All durable work objects live in Gitea.
3. Nostr only carries commands and acknowledgments.
4. Every successful action replies with the canonical Gitea link.
5. Bridge identity is explicit and re-keyable without code edits.
## Operator flow
```text
Nostur DM (encrypted kind 4)
-> relay subscription
-> bridge decrypts and validates sender
-> bridge parses command
-> bridge calls Gitea API
-> bridge replies with result + canonical URL
```

View File

@@ -1,317 +0,0 @@
#!/usr/bin/env python3
"""
Nostr DM → Gitea Bridge MVP for Issue #181
Imported from the live Allegro VPS bridge and sanitized for repo truth.
Uses a configurable bridge identity (defaults to Allegro) and explicit env/config
rather than hardcoded machine-local paths.
"""
import json
import os
import sys
import time
import urllib.request
import urllib.error
from pathlib import Path
# Nostr imports
from nostr.event import Event
from nostr.key import PrivateKey, PublicKey
from nostr.relay_manager import RelayManager
# === CONFIGURATION ===
GITEA_URL = os.environ.get("GITEA_URL", "http://143.198.27.163:3000").rstrip("/")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "").strip()
GITEA_TOKEN_FILE = Path(os.environ.get("GITEA_TOKEN_FILE", "~/.config/gitea/timmy-token")).expanduser()
RELAY_URL = os.environ.get("RELAY_URL", "wss://alexanderwhitestone.com/relay/")
KEYSTORE_PATH = Path(os.environ.get("KEYSTORE_PATH", "~/.timmy/nostr/agent_keys.json")).expanduser()
BRIDGE_IDENTITY = os.environ.get("BRIDGE_IDENTITY", "allegro")
DEFAULT_REPO = os.environ.get("DEFAULT_REPO", "Timmy_Foundation/timmy-config")
AUTHORIZED_NPUBS = [x.strip() for x in os.environ.get("AUTHORIZED_NPUBS", "npub1t8exnw6sp7vtxar8q5teyr0ueq0rvtgqpq5jkzylegupqulxfqwq4j66p5").split(",") if x.strip()]
STATUS_ASSIGNEE = os.environ.get("STATUS_ASSIGNEE", BRIDGE_IDENTITY)
if not GITEA_TOKEN and GITEA_TOKEN_FILE.exists():
GITEA_TOKEN = GITEA_TOKEN_FILE.read_text().strip()
if not GITEA_TOKEN:
raise RuntimeError(f"Missing Gitea token. Set GITEA_TOKEN or provide {GITEA_TOKEN_FILE}")
BRIDGE_NSEC = os.environ.get("BRIDGE_NSEC", "").strip()
if not BRIDGE_NSEC:
with open(KEYSTORE_PATH) as f:
ks = json.load(f)
if BRIDGE_IDENTITY not in ks:
raise RuntimeError(f"Bridge identity '{BRIDGE_IDENTITY}' not found in {KEYSTORE_PATH}")
BRIDGE_NSEC = ks[BRIDGE_IDENTITY]["nsec"]
bridge_key = PrivateKey.from_nsec(BRIDGE_NSEC)
BRIDGE_NPUB = bridge_key.public_key.bech32()
BRIDGE_HEX = bridge_key.public_key.hex()
AUTHORIZED_HEX = {PublicKey.from_npub(npub).hex(): npub for npub in AUTHORIZED_NPUBS}
print(f"[Bridge] Identity: {BRIDGE_IDENTITY} {BRIDGE_NPUB}")
print(f"[Bridge] Authorized operators: {', '.join(AUTHORIZED_NPUBS)}")
# === GITEA API HELPERS ===
def gitea_get(path):
headers = {"Authorization": f"token {GITEA_TOKEN}"}
req = urllib.request.Request(f"{GITEA_URL}/api/v1{path}", headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
return {"error": str(e)}
def gitea_post(path, data):
headers = {"Authorization": f"token {GITEA_TOKEN}", "Content-Type": "application/json"}
body = json.dumps(data).encode()
req = urllib.request.Request(f"{GITEA_URL}/api/v1{path}", data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
return {"error": str(e), "code": e.code}
# === COMMAND PARSERS ===
def parse_command(text: str) -> dict:
"""Parse DM text for commands."""
text = text.strip()
# !issue "Title" "Body" - create new issue
if text.startswith("!issue"):
parts = text[6:].strip()
if '"' in parts:
try:
quotes = []
in_quote = False
current = ""
for c in parts:
if c == '"':
if in_quote:
quotes.append(current)
current = ""
in_quote = not in_quote
elif in_quote:
current += c
if len(quotes) >= 2:
return {
"action": "create_issue",
"repo": DEFAULT_REPO,
"title": quotes[0],
"body": quotes[1]
}
elif len(quotes) == 1:
return {
"action": "create_issue",
"repo": DEFAULT_REPO,
"title": quotes[0],
"body": f"Created via Nostr DM bridge ({BRIDGE_IDENTITY} operator)"
}
except:
pass
return {
"action": "create_issue",
"repo": DEFAULT_REPO,
"title": parts or "Issue from Nostr",
"body": f"Created via Nostr DM bridge ({BRIDGE_IDENTITY} operator)"
}
# !comment #123 "Text" - append to existing issue
if text.startswith("!comment"):
parts = text[8:].strip()
if parts.startswith("#"):
try:
num_end = 1
while num_end < len(parts) and parts[num_end].isdigit():
num_end += 1
issue_num = int(parts[1:num_end])
rest = parts[num_end:].strip()
if '"' in rest:
body = rest.split('"')[1]
else:
body = rest
return {
"action": "add_comment",
"repo": DEFAULT_REPO,
"issue": issue_num,
"body": body
}
except:
pass
# !status - get queue summary
if text.startswith("!status"):
return {"action": "get_status"}
# Default: treat as freeform issue creation
if text and not text.startswith("!"):
return {
"action": "create_issue",
"repo": DEFAULT_REPO,
"title": text[:80] + ("..." if len(text) > 80 else ""),
"body": f"Operator message via Nostr DM:\n\n{text}\n\n---\n*Via Nostur → Gitea bridge ({BRIDGE_IDENTITY})*"
}
return None
# === ACTION HANDLERS ===
def handle_create_issue(cmd: dict) -> str:
result = gitea_post(f"/repos/{cmd['repo']}/issues", {
"title": cmd["title"],
"body": cmd["body"]
})
if "error" in result:
return f"❌ Failed to create issue: {result.get('error')}"
url = f"{GITEA_URL}/{cmd['repo']}/issues/{result['number']}"
return f"✅ Created issue #{result['number']}: {result['title']}\n🔗 {url}"
def handle_add_comment(cmd: dict) -> str:
result = gitea_post(f"/repos/{cmd['repo']}/issues/{cmd['issue']}/comments", {
"body": cmd["body"] + f"\n\n---\n*Via Nostur → Gitea bridge ({BRIDGE_IDENTITY})*"
})
if "error" in result:
return f"❌ Failed to comment on #{cmd['issue']}: {result.get('error')}"
return f"✅ Commented on issue #{cmd['issue']}\n🔗 {GITEA_URL}/{cmd['repo']}/issues/{cmd['issue']}"
def handle_get_status() -> str:
try:
issues = gitea_get(f"/repos/{DEFAULT_REPO}/issues?state=open&assignee={STATUS_ASSIGNEE}")
if isinstance(issues, dict) and "error" in issues:
return f"⚠️ Status fetch failed: {issues['error']}"
lines = [f"📊 Current {STATUS_ASSIGNEE} Queue:", ""]
for i in issues[:5]:
lines.append(f"#{i['number']}: {i['title'][:50]}")
if len(issues) > 5:
lines.append(f"... and {len(issues) - 5} more")
lines.append("")
lines.append(f"🔗 {GITEA_URL}/{DEFAULT_REPO}/issues?q=assignee%3A{STATUS_ASSIGNEE}")
return "\n".join(lines)
except Exception as e:
return f"⚠️ Status error: {e}"
def execute_command(cmd: dict) -> str:
action = cmd.get("action")
if action == "create_issue":
return handle_create_issue(cmd)
elif action == "add_comment":
return handle_add_comment(cmd)
elif action == "get_status":
return handle_get_status()
return "❓ Unknown command"
# === NOSTR EVENT HANDLING ===
def decrypt_dm(event: Event) -> str:
"""Decrypt DM content using the bridge identity's private key."""
try:
content = bridge_key.decrypt_message(event.content, event.public_key)
return content
except Exception as e:
print(f"[Decrypt Error] {e}")
return None
def send_dm(recipient_hex: str, message: str):
"""Send encrypted DM to recipient."""
try:
encrypted = bridge_key.encrypt_message(message, recipient_hex)
dm_event = Event(
kind=4,
content=encrypted,
tags=[["p", recipient_hex]],
public_key=BRIDGE_HEX
)
bridge_key.sign_event(dm_event)
relay_manager = RelayManager()
relay_manager.add_relay(RELAY_URL)
relay_manager.open_connections()
time.sleep(1)
relay_manager.publish_event(dm_event)
time.sleep(1)
relay_manager.close_connections()
print(f"[Out] DM sent to {recipient_hex[:16]}...")
return True
except Exception as e:
print(f"[Send Error] {e}")
return False
# === MAIN LOOP ===
def process_event(event: Event):
"""Process an incoming Nostr event."""
if event.kind != 4:
return
p_tags = [t[1] for t in event.tags if t[0] == "p"]
if BRIDGE_HEX not in p_tags:
return
sender = event.public_key
if sender not in AUTHORIZED_HEX:
print(f"[Reject] DM from unauthorized key: {sender[:16]}...")
return
plaintext = decrypt_dm(event)
if not plaintext:
print("[Error] Failed to decrypt DM")
return
print(f"[In] DM from authorized operator: {plaintext[:60]}...")
cmd = parse_command(plaintext)
if not cmd:
send_dm(sender, "❓ Commands:\n!status\n!issue \"Title\" \"Body\"\n!comment #123 \"Text\"\nOr send freeform text to create issue")
return
print(f"[Exec] {cmd['action']}")
response = execute_command(cmd)
send_dm(sender, response)
print(f"[Out] Response: {response[:60]}...")
def run_bridge():
print("=" * 60)
print(f"Nostr DM → Gitea Bridge MVP ({BRIDGE_IDENTITY} identity)")
print("=" * 60)
print(f"Relay: {RELAY_URL}")
print(f"Listening for DMs to: {BRIDGE_NPUB}")
print(f"Authorized operators: {', '.join(AUTHORIZED_NPUBS)}")
print("-" * 60)
relay_manager = RelayManager()
relay_manager.add_relay(RELAY_URL)
filter_json = {
"kinds": [4],
"#p": [BRIDGE_HEX],
"since": int(time.time())
}
relay_manager.add_subscription("dm_listener", filter_json)
relay_manager.open_connections()
print("[Bridge] Listening for operator DMs... (Ctrl+C to exit)")
print(f"[Bridge] npub for Nostur contact: {BRIDGE_NPUB}")
try:
print("[Bridge] Event loop started. Waiting for DMs...")
while True:
# Poll for events without run_sync (API compatibility)
while relay_manager.message_pool.has_events():
event_msg = relay_manager.message_pool.get_event()
if event_msg:
process_event(event_msg.event)
time.sleep(2)
except KeyboardInterrupt:
print("\n[Bridge] Shutting down...")
finally:
relay_manager.close_connections()
if __name__ == "__main__":
run_bridge()

View File

@@ -1,19 +0,0 @@
[Unit]
Description=Nostr DM to Gitea Bridge
Documentation=https://gitea.com/Timmy_Foundation/timmy-config/issues/186
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/timmy/nostr-dm-bridge
EnvironmentFile=-/opt/timmy/nostr-dm-bridge/.env
Environment="HOME=/root"
Environment="PYTHONUNBUFFERED=1"
ExecStart=/usr/bin/python3 /opt/timmy/nostr-dm-bridge/bridge_allegro.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@@ -1,133 +0,0 @@
#!/usr/bin/env python3
"""Validate the Nostr DM bridge configuration and core behaviors."""
import json
import os
import sys
import urllib.request
from pathlib import Path
GITEA_URL = os.environ.get("GITEA_URL", "http://143.198.27.163:3000").rstrip("/")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "").strip()
GITEA_TOKEN_FILE = Path(os.environ.get("GITEA_TOKEN_FILE", "~/.config/gitea/timmy-token")).expanduser()
KEYSTORE_PATH = Path(os.environ.get("KEYSTORE_PATH", "~/.timmy/nostr/agent_keys.json")).expanduser()
BRIDGE_IDENTITY = os.environ.get("BRIDGE_IDENTITY", "allegro")
BRIDGE_NSEC = os.environ.get("BRIDGE_NSEC", "").strip()
DEFAULT_REPO = os.environ.get("DEFAULT_REPO", "Timmy_Foundation/timmy-config")
AUTHORIZED_NPUBS = [x.strip() for x in os.environ.get("AUTHORIZED_NPUBS", "npub1t8exnw6sp7vtxar8q5teyr0ueq0rvtgqpq5jkzylegupqulxfqwq4j66p5").split(",") if x.strip()]
print("=" * 60)
print("Nostr DM Bridge Component Test")
print("=" * 60)
if not GITEA_TOKEN and GITEA_TOKEN_FILE.exists():
GITEA_TOKEN = GITEA_TOKEN_FILE.read_text().strip()
if not GITEA_TOKEN:
print(f"✗ Missing Gitea token. Set GITEA_TOKEN or create {GITEA_TOKEN_FILE}")
sys.exit(1)
print("✓ Gitea token loaded")
try:
from nostr.key import PrivateKey, PublicKey
print("✓ nostr library imported")
except ImportError as e:
print(f"✗ Failed to import nostr: {e}")
sys.exit(1)
if not BRIDGE_NSEC:
try:
with open(KEYSTORE_PATH) as f:
keystore = json.load(f)
BRIDGE_NSEC = keystore[BRIDGE_IDENTITY]["nsec"]
print(f"{BRIDGE_IDENTITY} nsec loaded from keystore")
except Exception as e:
bridge_key = PrivateKey()
BRIDGE_NSEC = bridge_key.bech32()
print(f"! Bridge identity {BRIDGE_IDENTITY!r} not available in {KEYSTORE_PATH}: {e}")
print("✓ Generated ephemeral bridge key for local validation")
else:
print("✓ Bridge nsec loaded from BRIDGE_NSEC")
try:
bridge_key = PrivateKey.from_nsec(BRIDGE_NSEC)
bridge_npub = bridge_key.public_key.bech32()
print(f"✓ Bridge npub: {bridge_npub}")
except Exception as e:
print(f"✗ Key derivation failed: {e}")
sys.exit(1)
try:
authorized_hex = [PublicKey.from_npub(npub).hex() for npub in AUTHORIZED_NPUBS]
print(f"✓ Authorized operators parsed: {len(authorized_hex)}")
except Exception as e:
print(f"✗ Failed to parse AUTHORIZED_NPUBS: {e}")
sys.exit(1)
try:
headers = {"Authorization": f"token {GITEA_TOKEN}"}
req = urllib.request.Request(f"{GITEA_URL}/api/v1/user", headers=headers)
with urllib.request.urlopen(req, timeout=5) as resp:
user = json.loads(resp.read().decode())
print(f"✓ Gitea API connected as: {user.get('login')}")
except Exception as e:
print(f"✗ Gitea API failed: {e}")
sys.exit(1)
os.environ.setdefault("GITEA_TOKEN", GITEA_TOKEN)
os.environ.setdefault("BRIDGE_NSEC", BRIDGE_NSEC)
os.environ.setdefault("DEFAULT_REPO", DEFAULT_REPO)
os.environ.setdefault("AUTHORIZED_NPUBS", ",".join(AUTHORIZED_NPUBS))
print("\n" + "-" * 60)
print("Testing command parsers...")
try:
from bridge_allegro import parse_command
except Exception as e:
print(f"✗ Failed to import bridge_allegro: {e}")
sys.exit(1)
cases = [
("!status", "get_status"),
('!issue "Test Title" "Test Body"', "create_issue"),
('!comment #123 "Hello"', "add_comment"),
("This is a freeform message", "create_issue"),
]
for text, expected_action in cases:
cmd = parse_command(text)
if not cmd or cmd.get("action") != expected_action:
print(f"✗ Parser mismatch for {text!r}: {cmd}")
sys.exit(1)
if expected_action in {"create_issue", "add_comment"} and cmd.get("repo") != DEFAULT_REPO:
print(f"✗ Parser repo mismatch for {text!r}: {cmd.get('repo')} != {DEFAULT_REPO}")
sys.exit(1)
print(f"{text!r} -> {expected_action}")
print("✓ All parser tests passed")
print("\n" + "-" * 60)
print("Testing encryption round-trip...")
try:
test_message = "Test DM content for round-trip validation"
recipient_hex = authorized_hex[0]
encrypted = bridge_key.encrypt_message(test_message, recipient_hex)
decrypted = bridge_key.decrypt_message(encrypted, recipient_hex)
if decrypted != test_message:
print(f"✗ Decryption mismatch: {decrypted!r}")
sys.exit(1)
print("✓ Encryption round-trip successful")
except Exception as e:
print(f"✗ Encryption test failed: {e}")
sys.exit(1)
print("\n" + "=" * 60)
print("ALL TESTS PASSED")
print("=" * 60)
print("\nBridge is ready to run:")
print(" python3 bridge_allegro.py")
print("\nFor operator testing:")
print(f" 1. Open Nostur")
print(f" 2. Send DM to: {bridge_npub}")
print(" 3. Try: !status")

View File

@@ -1,5 +1,5 @@
{
"updated_at": "2026-03-30T16:50:44.194030",
"updated_at": "2026-03-26T10:19:33.045324",
"platforms": {
"discord": [
{
@@ -27,30 +27,6 @@
"name": "Timmy Time",
"type": "group",
"thread_id": null
},
{
"id": "-1003664764329:85",
"name": "Timmy Time / topic 85",
"type": "group",
"thread_id": "85"
},
{
"id": "-1003664764329:111",
"name": "Timmy Time / topic 111",
"type": "group",
"thread_id": "111"
},
{
"id": "-1003664764329:173",
"name": "Timmy Time / topic 173",
"type": "group",
"thread_id": "173"
},
{
"id": "7635059073",
"name": "Trip T",
"type": "dm",
"thread_id": null
}
],
"whatsapp": [],

View File

@@ -1,34 +1,11 @@
model:
default: claude-opus-4-6
provider: anthropic
context_length: 65536
fallback_providers:
- provider: openai-codex
model: codex
- provider: gemini
model: gemini-2.5-flash
base_url: https://generativelanguage.googleapis.com/v1beta/openai
api_key_env: GEMINI_API_KEY
- provider: groq
model: llama-3.3-70b-versatile
base_url: https://api.groq.com/openai/v1
api_key_env: GROQ_API_KEY
- provider: grok
model: grok-3-mini-fast
base_url: https://api.x.ai/v1
api_key_env: XAI_API_KEY
- provider: kimi-coding
model: kimi-k2.5
- provider: openrouter
model: openai/gpt-4.1-mini
base_url: https://openrouter.ai/api/v1
api_key_env: OPENROUTER_API_KEY
toolsets:
- all
agent:
max_turns: 30
tool_use_enforcement: auto
reasoning_effort: xhigh
reasoning_effort: medium
verbose: false
terminal:
backend: local
@@ -78,61 +55,50 @@ auxiliary:
base_url: ''
api_key: ''
timeout: 30
download_timeout: 30
web_extract:
provider: auto
model: ''
base_url: ''
api_key: ''
timeout: 30
compression:
provider: auto
model: ''
base_url: ''
api_key: ''
timeout: 120
session_search:
provider: auto
model: ''
base_url: ''
api_key: ''
timeout: 30
skills_hub:
provider: auto
model: ''
base_url: ''
api_key: ''
timeout: 30
approval:
provider: auto
model: ''
base_url: ''
api_key: ''
timeout: 30
mcp:
provider: auto
model: ''
base_url: ''
api_key: ''
timeout: 30
flush_memories:
provider: auto
model: ''
base_url: ''
api_key: ''
timeout: 30
display:
compact: false
personality: ''
resume_display: full
busy_input_mode: interrupt
bell_on_complete: false
show_reasoning: false
streaming: false
show_cost: false
skin: timmy
tool_progress_command: false
tool_preview_length: 0
tool_progress: all
privacy:
redact_pii: false
@@ -182,9 +148,6 @@ delegation:
api_key: ''
max_iterations: 50
prefill_messages_file: ''
skills:
external_dirs: []
creation_nudge_interval: 15
honcho: {}
timezone: ''
discord:
@@ -194,7 +157,6 @@ discord:
whatsapp: {}
approvals:
mode: manual
timeout: 60
command_allowlist: []
quick_commands: {}
personalities: {}
@@ -208,8 +170,6 @@ security:
enabled: false
domains: []
shared_files: []
cron:
wrap_response: true
_config_version: 10
platforms:
api_server:
@@ -221,62 +181,35 @@ session_reset:
mode: none
idle_minutes: 0
custom_providers:
- name: Local llama.cpp
base_url: http://localhost:8081/v1
api_key: none
model: hermes4:14b
- name: Local Ollama
base_url: http://localhost:11434/v1
api_key: ollama
model: glm-4.7-flash:latest
- name: Google Gemini
base_url: https://generativelanguage.googleapis.com/v1beta/openai
api_key_env: GEMINI_API_KEY
model: gemini-2.5-pro
system_prompt_suffix: "You are Timmy. Your soul is defined in SOUL.md \u2014 read\
\ it, live it.\nYou run locally on your owner's machine via llama.cpp. You never\
\ phone home.\nYou speak plainly. You prefer short sentences. Brevity is a kindness.\n\
\ it, live it.\nYou run locally on your owner's machine via Ollama. You never phone\
\ home.\nYou speak plainly. You prefer short sentences. Brevity is a kindness.\n\
When you don't know something, say so. Refusal over fabrication.\nSovereignty and\
\ service always.\n"
skills:
creation_nudge_interval: 15
DISCORD_HOME_CHANNEL: '1476292315814297772'
providers:
ollama:
base_url: http://localhost:11434/v1
model: hermes3:latest
mcp_servers:
morrowind:
command: python3
orchestration:
command: /Users/apayne/.hermes/hermes-agent/venv/bin/python3
args:
- /Users/apayne/.timmy/morrowind/mcp_server.py
- /Users/apayne/.hermes/hermes-agent/tools/orchestration_mcp_server.py
env: {}
timeout: 30
fallback_model: null
# ── Fallback Model ────────────────────────────────────────────────────
# Automatic provider failover when primary is unavailable.
# Uncomment and configure to enable. Triggers on rate limits (429),
# overload (529), service errors (503), or connection failures.
#
# Supported providers:
# openrouter (OPENROUTER_API_KEY) — routes to any model
# openai-codex (OAuth — hermes login) — OpenAI Codex
# nous (OAuth — hermes login) — Nous Portal
# zai (ZAI_API_KEY) — Z.AI / GLM
# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot
# minimax (MINIMAX_API_KEY) — MiniMax
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
#
# For custom OpenAI-compatible endpoints, add base_url and api_key_env.
#
# fallback_model:
# provider: openrouter
# model: anthropic/claude-sonnet-4
#
# ── Smart Model Routing ────────────────────────────────────────────────
# Optional cheap-vs-strong routing for simple turns.
# Keeps the primary model for complex work, but can route short/simple
# messages to a cheaper model across providers.
#
# smart_model_routing:
# enabled: true
# max_simple_chars: 160
# max_simple_words: 28
# cheap_model:
# provider: openrouter
# model: google/gemini-2.5-flash
timeout: 120
fallback_model:
provider: custom
model: gemini-2.5-pro
base_url: https://generativelanguage.googleapis.com/v1beta/openai
api_key_env: GEMINI_API_KEY

View File

@@ -3,7 +3,7 @@
# This is the canonical way to deploy Timmy's configuration.
# Hermes-agent is the engine. timmy-config is the driver's seat.
#
# Usage: ./deploy.sh
# Usage: ./deploy.sh [--restart-loops]
set -euo pipefail
@@ -74,10 +74,24 @@ done
chmod +x "$HERMES_HOME/bin/"*.sh "$HERMES_HOME/bin/"*.py 2>/dev/null || true
log "bin/ -> $HERMES_HOME/bin/"
if [ "${1:-}" != "" ]; then
echo "ERROR: deploy.sh no longer accepts legacy loop flags." >&2
echo "Deploy the sidecar only. Do not relaunch deprecated bash loops." >&2
exit 1
# === Restart loops if requested ===
if [ "${1:-}" = "--restart-loops" ]; then
log "Killing existing loops..."
pkill -f 'claude-loop.sh' 2>/dev/null || true
pkill -f 'gemini-loop.sh' 2>/dev/null || true
pkill -f 'timmy-orchestrator.sh' 2>/dev/null || true
sleep 2
log "Clearing stale locks..."
rm -rf "$HERMES_HOME/logs/claude-locks/"* 2>/dev/null || true
rm -rf "$HERMES_HOME/logs/gemini-locks/"* 2>/dev/null || true
log "Relaunching loops..."
nohup bash "$HERMES_HOME/bin/timmy-orchestrator.sh" >> "$HERMES_HOME/logs/timmy-orchestrator.log" 2>&1 &
nohup bash "$HERMES_HOME/bin/claude-loop.sh" 2 >> "$HERMES_HOME/logs/claude-loop.log" 2>&1 &
nohup bash "$HERMES_HOME/bin/gemini-loop.sh" 1 >> "$HERMES_HOME/logs/gemini-loop.log" 2>&1 &
sleep 1
log "Loops relaunched."
fi
log "Deploy complete. timmy-config applied to $HERMES_HOME/"

View File

@@ -1,438 +0,0 @@
# Local Model Integration Sketch v2
# Hermes4-14B in the Heartbeat Loop — No New Telemetry
## Principle
No new inference layer. Huey tasks call `hermes chat -q` pointed at
Ollama. Hermes handles sessions, token tracking, cost logging.
The dashboard reads what Hermes already stores.
---
## Why Not Ollama Directly?
Ollama is fine as a serving backend. The issue isn't Ollama — it's that
calling Ollama directly with urllib bypasses the harness. The harness
already tracks sessions, tokens, model/provider, platform. Building a
second telemetry layer is owning code we don't need.
Ollama as a named provider isn't wired into the --provider flag yet,
but routing works via env vars:
HERMES_MODEL="hermes4:14b" \
HERMES_PROVIDER="custom" \
HERMES_BASE_URL="http://localhost:11434/v1" \
hermes chat -q "prompt here" -Q
This creates a tracked session, logs tokens, and returns the response.
That's our local inference call.
### Alternatives to Ollama for serving:
- **llama.cpp server** — lighter, no Python, raw HTTP. Good for single
model serving. Less convenient for model switching.
- **vLLM** — best throughput, but needs NVIDIA GPU. Not for M3 Mac.
- **MLX serving** — native Apple Silicon, but no OpenAI-compat API yet.
MLX is for training, not serving (our current policy).
- **llamafile** — single binary, portable. Good for distribution.
Verdict: Ollama is fine. It's the standard OpenAI-compat local server
on Mac. The issue was never Ollama — it was bypassing the harness.
---
## 1. The Call Pattern
One function in tasks.py that all Huey tasks use:
```python
import subprocess
import json
HERMES_BIN = "hermes"
LOCAL_ENV = {
"HERMES_MODEL": "hermes4:14b",
"HERMES_PROVIDER": "custom",
"HERMES_BASE_URL": "http://localhost:11434/v1",
}
def hermes_local(prompt, caller_tag=None, max_retries=2):
"""Call hermes with local Ollama model. Returns response text.
Every call creates a hermes session with full telemetry.
caller_tag gets prepended to prompt for searchability.
"""
import os
env = os.environ.copy()
env.update(LOCAL_ENV)
tagged_prompt = prompt
if caller_tag:
tagged_prompt = f"[{caller_tag}] {prompt}"
for attempt in range(max_retries + 1):
try:
result = subprocess.run(
[HERMES_BIN, "chat", "-q", tagged_prompt, "-Q", "-t", "none"],
capture_output=True, text=True,
timeout=120, env=env,
)
if result.returncode == 0 and result.stdout.strip():
# Strip the session_id line from -Q output
lines = result.stdout.strip().split("\n")
response_lines = [l for l in lines if not l.startswith("session_id:")]
return "\n".join(response_lines).strip()
except subprocess.TimeoutExpired:
if attempt == max_retries:
return None
continue
return None
```
Notes:
- `-t none` disables all toolsets — the heartbeat model shouldn't
have terminal/file access. Pure reasoning only.
- `-Q` quiet mode suppresses banner/spinner, gives clean output.
- Every call creates a session in Hermes session store. Searchable,
exportable, countable.
- The `[caller_tag]` prefix lets you filter sessions by which Huey
task generated them: `hermes sessions list | grep heartbeat`
---
## 2. Heartbeat DECIDE Phase
Replace the hardcoded if/else with a model call:
```python
# In heartbeat_tick(), replace the DECIDE + ACT section:
# DECIDE: let hermes4:14b reason about what to do
decide_prompt = f"""System state at {now.isoformat()}:
{json.dumps(perception, indent=2)}
Previous tick: {last_tick.get('tick_id', 'none')}
You are the heartbeat monitor. Based on this state:
1. List any actions needed (alerts, restarts, escalations). Empty if all OK.
2. Rate severity: ok, warning, or critical.
3. One sentence of reasoning.
Respond ONLY with JSON:
{{"actions": [], "severity": "ok", "reasoning": "..."}}"""
decision = None
try:
raw = hermes_local(decide_prompt, caller_tag="heartbeat_tick")
if raw:
# Try to parse JSON from the response
# Model might wrap it in markdown, so extract
for line in raw.split("\n"):
line = line.strip()
if line.startswith("{"):
decision = json.loads(line)
break
if not decision:
decision = json.loads(raw)
except (json.JSONDecodeError, Exception) as e:
decision = None
# Fallback to hardcoded logic if model fails or is down
if decision is None:
actions = []
if not perception.get("gitea_alive"):
actions.append("ALERT: Gitea unreachable")
health = perception.get("model_health", {})
if isinstance(health, dict) and not health.get("ollama_running"):
actions.append("ALERT: Ollama not running")
decision = {
"actions": actions,
"severity": "fallback",
"reasoning": "model unavailable, used hardcoded checks"
}
tick_record["decision"] = decision
actions = decision.get("actions", [])
```
---
## 3. DPO Candidate Collection
No new database. Hermes sessions ARE the DPO candidates.
Every `hermes_local()` call creates a session. To extract DPO pairs:
```bash
# Export all local-model sessions
hermes sessions export --output /tmp/local-sessions.jsonl
# Filter for heartbeat decisions
grep "heartbeat_tick" /tmp/local-sessions.jsonl > heartbeat_decisions.jsonl
```
The existing `session_export` Huey task (runs every 4h) already extracts
user→assistant pairs. It just needs to be aware that some sessions are
now local-model decisions instead of human conversations.
For DPO annotation, add a simple review script:
```python
# review_decisions.py — reads heartbeat tick logs, shows model decisions,
# asks Alexander to mark chosen/rejected
# Writes annotations back to the tick log files
import json
from pathlib import Path
TICK_DIR = Path.home() / ".timmy" / "heartbeat"
for log_file in sorted(TICK_DIR.glob("ticks_*.jsonl")):
for line in log_file.read_text().strip().split("\n"):
tick = json.loads(line)
decision = tick.get("decision", {})
if decision.get("severity") == "fallback":
continue # skip fallback entries
print(f"\n--- Tick {tick['tick_id']} ---")
print(f"Perception: {json.dumps(tick['perception'], indent=2)}")
print(f"Decision: {json.dumps(decision, indent=2)}")
rating = input("Rate (c=chosen, r=rejected, s=skip): ").strip()
if rating in ("c", "r"):
tick["dpo_label"] = "chosen" if rating == "c" else "rejected"
# write back... (append to annotated file)
```
---
## 4. Dashboard — Reads Hermes Data
```python
#!/usr/bin/env python3
"""Timmy Model Dashboard — reads from Hermes, owns nothing."""
import json
import os
import subprocess
import sys
import time
import urllib.request
from datetime import datetime
from pathlib import Path
HERMES_HOME = Path.home() / ".hermes"
TIMMY_HOME = Path.home() / ".timmy"
def get_ollama_models():
"""What's available in Ollama."""
try:
req = urllib.request.Request("http://localhost:11434/api/tags")
with urllib.request.urlopen(req, timeout=5) as resp:
return json.loads(resp.read()).get("models", [])
except Exception:
return []
def get_loaded_models():
"""What's actually in VRAM right now."""
try:
req = urllib.request.Request("http://localhost:11434/api/ps")
with urllib.request.urlopen(req, timeout=5) as resp:
return json.loads(resp.read()).get("models", [])
except Exception:
return []
def get_huey_status():
try:
r = subprocess.run(["pgrep", "-f", "huey_consumer"],
capture_output=True, timeout=5)
return r.returncode == 0
except Exception:
return False
def get_hermes_sessions(hours=24):
"""Read session metadata from Hermes session store."""
sessions_file = HERMES_HOME / "sessions" / "sessions.json"
if not sessions_file.exists():
return []
try:
data = json.loads(sessions_file.read_text())
return list(data.values())
except Exception:
return []
def get_heartbeat_ticks(date_str=None):
"""Read today's heartbeat ticks."""
if not date_str:
date_str = datetime.now().strftime("%Y%m%d")
tick_file = TIMMY_HOME / "heartbeat" / f"ticks_{date_str}.jsonl"
if not tick_file.exists():
return []
ticks = []
for line in tick_file.read_text().strip().split("\n"):
try:
ticks.append(json.loads(line))
except Exception:
continue
return ticks
def render(hours=24):
models = get_ollama_models()
loaded = get_loaded_models()
huey = get_huey_status()
sessions = get_hermes_sessions(hours)
ticks = get_heartbeat_ticks()
loaded_names = {m.get("name", "") for m in loaded}
print("\033[2J\033[H")
print("=" * 70)
print(" TIMMY MODEL DASHBOARD")
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f" {now} | Huey: {'UP' if huey else 'DOWN'} | Ollama models: {len(models)}")
print("=" * 70)
# DEPLOYMENTS
print("\n LOCAL MODELS")
print(" " + "-" * 55)
for m in models:
name = m.get("name", "?")
size_gb = m.get("size", 0) / 1e9
status = "IN VRAM" if name in loaded_names else "on disk"
print(f" {name:35s} {size_gb:5.1f}GB {status}")
if not models:
print(" (Ollama not responding)")
# HERMES SESSION ACTIVITY
# Count sessions by platform/provider
print(f"\n HERMES SESSIONS (recent)")
print(" " + "-" * 55)
local_sessions = [s for s in sessions
if "localhost" in str(s.get("origin", {}))]
cli_sessions = [s for s in sessions
if s.get("platform") == "cli" or s.get("origin", {}).get("platform") == "cli"]
total_tokens = sum(s.get("total_tokens", 0) for s in sessions)
print(f" Total sessions: {len(sessions)}")
print(f" CLI sessions: {len(cli_sessions)}")
print(f" Total tokens: {total_tokens:,}")
# HEARTBEAT STATUS
print(f"\n HEARTBEAT ({len(ticks)} ticks today)")
print(" " + "-" * 55)
if ticks:
last = ticks[-1]
decision = last.get("decision", {})
severity = decision.get("severity", "unknown")
reasoning = decision.get("reasoning", "no model decision yet")
print(f" Last tick: {last.get('tick_id', '?')}")
print(f" Severity: {severity}")
print(f" Reasoning: {reasoning[:60]}")
# Count model vs fallback decisions
model_decisions = sum(1 for t in ticks
if t.get("decision", {}).get("severity") != "fallback")
fallback = len(ticks) - model_decisions
print(f" Model decisions: {model_decisions} | Fallback: {fallback}")
# DPO labels if any
labeled = sum(1 for t in ticks if "dpo_label" in t)
if labeled:
chosen = sum(1 for t in ticks if t.get("dpo_label") == "chosen")
rejected = sum(1 for t in ticks if t.get("dpo_label") == "rejected")
print(f" DPO labeled: {labeled} (chosen: {chosen}, rejected: {rejected})")
else:
print(" (no ticks today)")
# ACTIVE LOOPS
print(f"\n ACTIVE LOOPS USING LOCAL MODELS")
print(" " + "-" * 55)
print(" heartbeat_tick 10m hermes4:14b DECIDE phase")
print(" (future) 15m hermes4:14b issue triage")
print(" (future) daily timmy:v0.1 morning report")
print(f"\n NON-LOCAL LOOPS (Gemini/Grok API)")
print(" " + "-" * 55)
print(" gemini_worker 20m gemini-2.5-pro aider")
print(" grok_worker 20m grok-3-fast opencode")
print(" cross_review 30m both PR review")
print("\n" + "=" * 70)
if __name__ == "__main__":
watch = "--watch" in sys.argv
hours = 24
for a in sys.argv[1:]:
if a.startswith("--hours="):
hours = int(a.split("=")[1])
if watch:
while True:
render(hours)
time.sleep(30)
else:
render(hours)
```
---
## 5. Implementation Steps
### Step 1: Add hermes_local() to tasks.py
- One function, ~20 lines
- Calls `hermes chat -q` with Ollama env vars
- All telemetry comes from Hermes for free
### Step 2: Wire heartbeat_tick DECIDE phase
- Replace 6 lines of if/else with hermes_local() call
- Keep hardcoded fallback when model is down
- Decision stored in tick record for DPO review
### Step 3: Fix the MCP server warning
- The orchestration MCP server path is broken — harmless but noisy
- Either fix the path or remove from config
### Step 4: Drop model_dashboard.py in timmy-config/bin/
- Reads Ollama API, Hermes sessions, heartbeat ticks
- No new data stores — just views over existing ones
- `python3 model_dashboard.py --watch` for live view
### Step 5: Expand to more Huey tasks
- triage_issues: model reads issue, picks agent
- good_morning_report: model writes the "From Timmy" section
- Each expansion is just calling hermes_local() with a different prompt
---
## What Gets Hotfixed in Hermes Config
If `hermes insights` is broken (the cache_read_tokens column error),
that needs a fix. The dashboard falls back to reading sessions.json
directly, but insights would be the better data source.
The `providers.ollama` section in config.yaml exists but isn't wired
to the --provider flag. Filing this upstream or patching locally would
let us do `hermes chat -q "..." --provider ollama` cleanly instead
of relying on env vars. Not blocking — env vars work today.
---
## What This Owns
- hermes_local() — 20-line wrapper around a subprocess call
- model_dashboard.py — read-only views over existing data
- review_decisions.py — optional DPO annotation CLI
## What This Does NOT Own
- Inference. Ollama does that.
- Telemetry. Hermes does that.
- Session storage. Hermes does that.
- Token counting. Hermes does that.
- Training pipeline. Already exists in timmy-config/training/.

View File

@@ -521,17 +521,8 @@ class GiteaClient:
return result
def find_agent_issues(self, repo: str, agent: str, limit: int = 50) -> list[Issue]:
"""Find open issues assigned to a specific agent.
Gitea's assignee query can return stale or misleading results, so we
always post-filter on the actual assignee list in the returned issue.
"""
issues = self.list_issues(repo, state="open", assignee=agent, limit=limit)
agent_lower = agent.lower()
return [
issue for issue in issues
if any((assignee.login or "").lower() == agent_lower for assignee in issue.assignees)
]
"""Find open issues assigned to a specific agent."""
return self.list_issues(repo, state="open", assignee=agent, limit=limit)
def find_agent_pulls(self, repo: str, agent: str) -> list[PullRequest]:
"""Find open PRs created by a specific agent."""

File diff suppressed because it is too large Load Diff

View File

View File

@@ -4,7 +4,7 @@ description: >
reproduces the bug, then fixes the code, then verifies.
model:
preferred: qwen3:30b
preferred: claude-opus-4-6
fallback: claude-sonnet-4-20250514
max_turns: 30
temperature: 0.2

View File

@@ -4,7 +4,7 @@ description: >
agents. Decomposes large issues into smaller ones.
model:
preferred: qwen3:30b
preferred: claude-opus-4-6
fallback: claude-sonnet-4-20250514
max_turns: 20
temperature: 0.3

View File

@@ -4,7 +4,7 @@ description: >
comments on problems. The merge bot replacement.
model:
preferred: qwen3:30b
preferred: claude-opus-4-6
fallback: claude-sonnet-4-20250514
max_turns: 20
temperature: 0.2

View File

@@ -4,7 +4,7 @@ description: >
Well-scoped: 1-3 files per task, clear acceptance criteria.
model:
preferred: qwen3:30b
preferred: claude-opus-4-6
fallback: claude-sonnet-4-20250514
max_turns: 30
temperature: 0.3

View File

@@ -4,7 +4,7 @@ description: >
dependency issues. Files findings as Gitea issues.
model:
preferred: qwen3:30b
preferred: claude-opus-4-6
fallback: claude-opus-4-6
max_turns: 40
temperature: 0.2

View File

@@ -4,7 +4,7 @@ description: >
writes meaningful tests, verifies they pass.
model:
preferred: qwen3:30b
preferred: claude-opus-4-6
fallback: claude-sonnet-4-20250514
max_turns: 30
temperature: 0.3

View File

@@ -1,47 +0,0 @@
name: verified-logic
description: >
Crucible-first playbook for tasks that require proof instead of plausible prose.
Use Z3-backed sidecar tools for scheduling, dependency ordering, capacity checks,
and consistency verification.
model:
preferred: claude-opus-4-6
fallback: claude-sonnet-4-20250514
max_turns: 12
temperature: 0.1
tools:
- mcp_crucible_schedule_tasks
- mcp_crucible_order_dependencies
- mcp_crucible_capacity_fit
trigger:
manual: true
steps:
- classify_problem
- choose_template
- translate_into_constraints
- verify_with_crucible
- report_sat_unsat_with_witness
output: verified_result
timeout_minutes: 5
system_prompt: |
You are running the Crucible playbook.
Use this playbook for:
- scheduling and deadline feasibility
- dependency ordering and cycle checks
- capacity / resource allocation constraints
- consistency checks where a contradiction matters
RULES:
1. Do not bluff through logic.
2. Pick the narrowest Crucible template that fits the task.
3. Translate the user's question into structured constraints.
4. Call the Crucible tool.
5. If SAT, report the witness model clearly.
6. If UNSAT, say the constraints are impossible and explain which shape of constraint caused the contradiction.
7. If the task is not a good fit for these templates, say so plainly instead of pretending it was verified.

1792
tasks.py

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +0,0 @@
from gitea_client import GiteaClient, Issue, User
def _issue(number: int, assignees: list[str]) -> Issue:
return Issue(
number=number,
title=f"Issue {number}",
body="",
state="open",
user=User(id=1, login="Timmy"),
assignees=[User(id=i + 10, login=name) for i, name in enumerate(assignees)],
labels=[],
)
def test_find_agent_issues_filters_actual_assignees(monkeypatch):
client = GiteaClient(base_url="http://example.invalid", token="test-token")
returned = [
_issue(73, ["Timmy"]),
_issue(74, ["gemini"]),
_issue(75, ["grok", "Timmy"]),
_issue(76, []),
]
monkeypatch.setattr(client, "list_issues", lambda *args, **kwargs: returned)
gemini_issues = client.find_agent_issues("Timmy_Foundation/timmy-config", "gemini")
grok_issues = client.find_agent_issues("Timmy_Foundation/timmy-config", "grok")
kimi_issues = client.find_agent_issues("Timmy_Foundation/timmy-config", "kimi")
assert [issue.number for issue in gemini_issues] == [74]
assert [issue.number for issue in grok_issues] == [75]
assert kimi_issues == []
def test_find_agent_issues_is_case_insensitive(monkeypatch):
client = GiteaClient(base_url="http://example.invalid", token="test-token")
returned = [_issue(80, ["Gemini"])]
monkeypatch.setattr(client, "list_issues", lambda *args, **kwargs: returned)
issues = client.find_agent_issues("Timmy_Foundation/the-nexus", "gemini")
assert [issue.number for issue in issues] == [80]

View File

@@ -1,21 +0,0 @@
from __future__ import annotations
from pathlib import Path
import yaml
def test_config_defaults_to_local_llama_cpp_runtime() -> None:
config = yaml.safe_load(Path("config.yaml").read_text())
assert config["model"]["provider"] == "custom"
assert config["model"]["default"] == "hermes4:14b"
assert config["model"]["base_url"] == "http://localhost:8081/v1"
local_provider = next(
entry for entry in config["custom_providers"] if entry["name"] == "Local llama.cpp"
)
assert local_provider["model"] == "hermes4:14b"
assert config["fallback_model"]["provider"] == "custom"
assert config["fallback_model"]["model"] == "gemini-2.5-pro"

View File

@@ -1,11 +1,8 @@
# Training
Transitional training recipes for Timmy's sovereign model. These files are
useful as reference configs and export helpers, but they are not the canonical
home of Timmy's lived training data.
LoRA fine-tuning pipeline for Timmy's sovereign model. No custom harness — just config files for existing tools.
Canonical data should live in `timmy-home` under gameplay trajectories,
research artifacts, and `training-data/` exports such as DPO pairs.
Replaces the `autolora` repo (1,500 lines of custom code → config + `make`).
## Install
@@ -26,16 +23,6 @@ make convert # Convert merged data to MLX train/valid format
make help # Show all targets
```
## Status
This directory exists to avoid re-growing a bespoke training harness while the
system boundary is being cleaned up.
- Keep thin recipes and export helpers here only when they directly support the
Hermes sidecar.
- Keep generated data, DPO pairs, and other lived artifacts in `timmy-home`.
- Prefer deleting stale pipeline code over expanding it.
## Files
```