Compare commits
4 Commits
timmy/issu
...
gemini/orc
| Author | SHA1 | Date | |
|---|---|---|---|
| 118ca5fcbd | |||
| 877425bde4 | |||
| 34e01f0986 | |||
| d955d2b9f1 |
57
CONTRIBUTING.md
Normal file
57
CONTRIBUTING.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Contributing to timmy-config
|
||||
|
||||
## Proof Standard
|
||||
|
||||
This is a hard rule.
|
||||
|
||||
- visual changes require screenshot proof
|
||||
- do not commit screenshots or binary media to Gitea backup unless explicitly required
|
||||
- CLI/verifiable changes must cite the exact command output, log path, or world-state proof showing acceptance criteria were met
|
||||
- config-only changes are not fully accepted when the real acceptance bar is live runtime behavior
|
||||
- no proof, no merge
|
||||
|
||||
## How to satisfy the rule
|
||||
|
||||
### Visual changes
|
||||
Examples:
|
||||
- skin updates
|
||||
- terminal UI layout changes
|
||||
- browser-facing output
|
||||
- dashboard/panel changes
|
||||
|
||||
Required proof:
|
||||
- attach screenshot proof to the PR or issue discussion
|
||||
- keep the screenshot outside the repo unless explicitly asked to commit it
|
||||
- name what the screenshot proves
|
||||
|
||||
### CLI / harness / operational changes
|
||||
Examples:
|
||||
- scripts
|
||||
- config wiring
|
||||
- heartbeat behavior
|
||||
- model routing
|
||||
- export pipelines
|
||||
|
||||
Required proof:
|
||||
- cite the exact command used
|
||||
- paste the relevant output, or
|
||||
- cite the exact log path / world-state artifact that proves the change
|
||||
|
||||
Good:
|
||||
- `python3 -m pytest tests/test_x.py -q` → `2 passed`
|
||||
- `~/.timmy/timmy-config/logs/huey.log`
|
||||
- `~/.hermes/model_health.json`
|
||||
|
||||
Bad:
|
||||
- "looks right"
|
||||
- "compiled"
|
||||
- "should work now"
|
||||
|
||||
## Default merge gate
|
||||
|
||||
Every PR should make it obvious:
|
||||
1. what changed
|
||||
2. what acceptance criteria were targeted
|
||||
3. what evidence proves those criteria were met
|
||||
|
||||
If that evidence is missing, the PR is not done.
|
||||
11
README.md
11
README.md
@@ -17,6 +17,7 @@ timmy-config/
|
||||
├── bin/ ← Live utility scripts (NOT deprecated loops)
|
||||
│ ├── hermes-startup.sh ← Hermes boot sequence
|
||||
│ ├── agent-dispatch.sh ← Manual agent dispatch
|
||||
│ ├── deploy-allegro-house.sh← Bootstraps the remote Allegro wizard house
|
||||
│ ├── ops-panel.sh ← Ops dashboard panel
|
||||
│ ├── ops-gitea.sh ← Gitea ops helpers
|
||||
│ ├── pipeline-freshness.sh ← Session/export drift check
|
||||
@@ -25,6 +26,7 @@ timmy-config/
|
||||
├── skins/ ← UI skins (timmy skin)
|
||||
├── playbooks/ ← Agent playbooks (YAML)
|
||||
├── cron/ ← Cron job definitions
|
||||
├── wizards/ ← Remote wizard-house templates + units
|
||||
└── training/ ← Transitional training recipes, not canonical lived data
|
||||
```
|
||||
|
||||
@@ -54,6 +56,15 @@ pip install huey
|
||||
huey_consumer.py tasks.huey -w 2 -k thread
|
||||
```
|
||||
|
||||
## Proof Standard
|
||||
|
||||
This repo uses a hard proof rule for merges.
|
||||
|
||||
- visual changes require screenshot proof
|
||||
- CLI/verifiable changes must cite logs, command output, or world-state proof
|
||||
- screenshots/media stay out of Gitea backup unless explicitly required
|
||||
- see `CONTRIBUTING.md` for the merge gate
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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
|
||||
@@ -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."
|
||||
32
bin/deploy-allegro-house.sh
Executable file
32
bin/deploy-allegro-house.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TARGET="${1:-root@167.99.126.228}"
|
||||
HERMES_REPO_URL="${HERMES_REPO_URL:-https://github.com/NousResearch/hermes-agent.git}"
|
||||
KIMI_API_KEY="${KIMI_API_KEY:-}"
|
||||
|
||||
if [[ -z "$KIMI_API_KEY" && -f "$HOME/.config/kimi/api_key" ]]; then
|
||||
KIMI_API_KEY="$(tr -d '\n' < "$HOME/.config/kimi/api_key")"
|
||||
fi
|
||||
|
||||
if [[ -z "$KIMI_API_KEY" ]]; then
|
||||
echo "KIMI_API_KEY is required (env or ~/.config/kimi/api_key)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ssh "$TARGET" 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y git python3 python3-venv python3-pip curl ca-certificates'
|
||||
ssh "$TARGET" 'mkdir -p /root/wizards/allegro/home /root/wizards/allegro/hermes-agent'
|
||||
|
||||
ssh "$TARGET" "if [ ! -d /root/wizards/allegro/hermes-agent/.git ]; then git clone '$HERMES_REPO_URL' /root/wizards/allegro/hermes-agent; fi"
|
||||
ssh "$TARGET" 'cd /root/wizards/allegro/hermes-agent && python3 -m venv .venv && .venv/bin/pip install --upgrade pip setuptools wheel && .venv/bin/pip install -e .'
|
||||
|
||||
ssh "$TARGET" "cat > /root/wizards/allegro/home/config.yaml" < "$REPO_DIR/wizards/allegro/config.yaml"
|
||||
ssh "$TARGET" "cat > /root/wizards/allegro/home/SOUL.md" < "$REPO_DIR/SOUL.md"
|
||||
ssh "$TARGET" "cat > /root/wizards/allegro/home/.env <<'EOF'
|
||||
KIMI_API_KEY=$KIMI_API_KEY
|
||||
EOF"
|
||||
ssh "$TARGET" "cat > /etc/systemd/system/hermes-allegro.service" < "$REPO_DIR/wizards/allegro/hermes-allegro.service"
|
||||
|
||||
ssh "$TARGET" 'chmod 600 /root/wizards/allegro/home/.env && systemctl daemon-reload && systemctl enable --now hermes-allegro.service && systemctl restart hermes-allegro.service && systemctl is-active hermes-allegro.service && curl -fsS http://127.0.0.1:8645/health'
|
||||
@@ -9,6 +9,7 @@ Usage:
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -16,6 +17,12 @@ import urllib.request
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from metrics_helpers import summarize_local_metrics, summarize_session_rows
|
||||
|
||||
HERMES_HOME = Path.home() / ".hermes"
|
||||
TIMMY_HOME = Path.home() / ".timmy"
|
||||
METRICS_DIR = TIMMY_HOME / "metrics"
|
||||
@@ -60,6 +67,30 @@ def get_hermes_sessions():
|
||||
return []
|
||||
|
||||
|
||||
def get_session_rows(hours=24):
|
||||
state_db = HERMES_HOME / "state.db"
|
||||
if not state_db.exists():
|
||||
return []
|
||||
cutoff = time.time() - (hours * 3600)
|
||||
try:
|
||||
conn = sqlite3.connect(str(state_db))
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT model, source, COUNT(*) as sessions,
|
||||
SUM(message_count) as msgs,
|
||||
SUM(tool_call_count) as tools
|
||||
FROM sessions
|
||||
WHERE started_at > ? AND model IS NOT NULL AND model != ''
|
||||
GROUP BY model, source
|
||||
""",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return rows
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_heartbeat_ticks(date_str=None):
|
||||
if not date_str:
|
||||
date_str = datetime.now().strftime("%Y%m%d")
|
||||
@@ -130,6 +161,9 @@ def render(hours=24):
|
||||
ticks = get_heartbeat_ticks()
|
||||
metrics = get_local_metrics(hours)
|
||||
sessions = get_hermes_sessions()
|
||||
session_rows = get_session_rows(hours)
|
||||
local_summary = summarize_local_metrics(metrics)
|
||||
session_summary = summarize_session_rows(session_rows)
|
||||
|
||||
loaded_names = {m.get("name", "") for m in loaded}
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
@@ -159,28 +193,18 @@ def render(hours=24):
|
||||
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}")
|
||||
print(f" Tokens: {local_summary['input_tokens']} in | {local_summary['output_tokens']} out | {local_summary['total_tokens']} total")
|
||||
if local_summary.get('avg_latency_s') is not None:
|
||||
print(f" Avg latency: {local_summary['avg_latency_s']:.2f}s")
|
||||
if local_summary.get('avg_tokens_per_second') is not None:
|
||||
print(f" Avg throughput: {GREEN}{local_summary['avg_tokens_per_second']:.2f} tok/s{RST}")
|
||||
for caller, stats in sorted(local_summary['by_caller'].items()):
|
||||
err = f" {RED}err:{stats['failed_calls']}{RST}" if stats['failed_calls'] else ""
|
||||
print(f" {caller:25s} calls:{stats['calls']:4d} tokens:{stats['total_tokens']:5d} {GREEN}ok:{stats['successful_calls']}{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")
|
||||
for model, stats in sorted(local_summary['by_model'].items(), key=lambda x: -x[1]['calls']):
|
||||
print(f" {model:30s} {stats['calls']} calls {stats['total_tokens']} tok")
|
||||
else:
|
||||
print(f" {DIM}(no local calls recorded yet){RST}")
|
||||
|
||||
@@ -211,15 +235,18 @@ def render(hours=24):
|
||||
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", ""))]
|
||||
# ── HERMES SESSIONS / SOVEREIGNTY LOAD ──
|
||||
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"\n {BOLD}HERMES SESSIONS / SOVEREIGNTY LOAD{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}")
|
||||
print(f" Session cache: {len(sessions)} total | {GREEN}{len(local_sessions)} local{RST} | {YELLOW}{len(cloud_sessions)} cloud{RST}")
|
||||
if session_rows:
|
||||
print(f" Session DB: {session_summary['total_sessions']} total | {GREEN}{session_summary['local_sessions']} local{RST} | {YELLOW}{session_summary['cloud_sessions']} cloud{RST}")
|
||||
print(f" Token est: {GREEN}{session_summary['local_est_tokens']} local{RST} | {YELLOW}{session_summary['cloud_est_tokens']} cloud{RST}")
|
||||
print(f" Est cloud cost: ${session_summary['cloud_est_cost_usd']:.4f}")
|
||||
else:
|
||||
print(f" {DIM}(no session-db stats available){RST}")
|
||||
|
||||
# ── ACTIVE LOOPS ──
|
||||
print(f"\n {BOLD}ACTIVE LOOPS{RST}")
|
||||
|
||||
@@ -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
|
||||
3
bridge/nostr-dm-bridge/.gitignore
vendored
3
bridge/nostr-dm-bridge/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
@@ -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
|
||||
```
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"updated_at": "2026-03-30T16:50:44.194030",
|
||||
"updated_at": "2026-03-28T09:54:34.822062",
|
||||
"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": [],
|
||||
|
||||
83
config.yaml
83
config.yaml
@@ -1,33 +1,12 @@
|
||||
model:
|
||||
default: claude-opus-4-6
|
||||
provider: anthropic
|
||||
default: hermes4:14b
|
||||
provider: custom
|
||||
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
|
||||
base_url: http://localhost:8081/v1
|
||||
toolsets:
|
||||
- all
|
||||
agent:
|
||||
max_turns: 30
|
||||
tool_use_enforcement: auto
|
||||
reasoning_effort: xhigh
|
||||
verbose: false
|
||||
terminal:
|
||||
@@ -78,49 +57,41 @@ 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: ''
|
||||
@@ -132,7 +103,6 @@ display:
|
||||
show_cost: false
|
||||
skin: timmy
|
||||
tool_progress_command: false
|
||||
tool_preview_length: 0
|
||||
tool_progress: all
|
||||
privacy:
|
||||
redact_pii: false
|
||||
@@ -182,9 +152,6 @@ delegation:
|
||||
api_key: ''
|
||||
max_iterations: 50
|
||||
prefill_messages_file: ''
|
||||
skills:
|
||||
external_dirs: []
|
||||
creation_nudge_interval: 15
|
||||
honcho: {}
|
||||
timezone: ''
|
||||
discord:
|
||||
@@ -194,7 +161,6 @@ discord:
|
||||
whatsapp: {}
|
||||
approvals:
|
||||
mode: manual
|
||||
timeout: 60
|
||||
command_allowlist: []
|
||||
quick_commands: {}
|
||||
personalities: {}
|
||||
@@ -208,8 +174,6 @@ security:
|
||||
enabled: false
|
||||
domains: []
|
||||
shared_files: []
|
||||
cron:
|
||||
wrap_response: true
|
||||
_config_version: 10
|
||||
platforms:
|
||||
api_server:
|
||||
@@ -234,6 +198,8 @@ system_prompt_suffix: "You are Timmy. Your soul is defined in SOUL.md \u2014 rea
|
||||
\ 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:
|
||||
@@ -246,37 +212,8 @@ mcp_servers:
|
||||
- /Users/apayne/.timmy/morrowind/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
|
||||
fallback_model:
|
||||
provider: custom
|
||||
model: gemini-2.5-pro
|
||||
base_url: https://generativelanguage.googleapis.com/v1beta/openai
|
||||
api_key_env: GEMINI_API_KEY
|
||||
|
||||
44
docs/allegro-wizard-house.md
Normal file
44
docs/allegro-wizard-house.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Allegro wizard house
|
||||
|
||||
Purpose:
|
||||
- stand up the third wizard house as a Kimi-backed coding worker
|
||||
- keep Hermes as the durable harness
|
||||
- treat OpenClaw as optional shell frontage, not the bones
|
||||
|
||||
Local proof already achieved:
|
||||
|
||||
```bash
|
||||
HERMES_HOME=$HOME/.timmy/wizards/allegro/home \
|
||||
hermes doctor
|
||||
|
||||
HERMES_HOME=$HOME/.timmy/wizards/allegro/home \
|
||||
hermes chat -Q --provider kimi-coding -m kimi-for-coding \
|
||||
-q "Reply with exactly: ALLEGRO KIMI ONLINE"
|
||||
```
|
||||
|
||||
Observed proof:
|
||||
- Kimi / Moonshot API check passed in `hermes doctor`
|
||||
- chat returned exactly `ALLEGRO KIMI ONLINE`
|
||||
|
||||
Repo assets:
|
||||
- `wizards/allegro/config.yaml`
|
||||
- `wizards/allegro/hermes-allegro.service`
|
||||
- `bin/deploy-allegro-house.sh`
|
||||
|
||||
Remote target:
|
||||
- host: `167.99.126.228`
|
||||
- house root: `/root/wizards/allegro`
|
||||
- `HERMES_HOME`: `/root/wizards/allegro/home`
|
||||
- api health: `http://127.0.0.1:8645/health`
|
||||
|
||||
Deploy command:
|
||||
|
||||
```bash
|
||||
cd ~/.timmy/timmy-config
|
||||
bin/deploy-allegro-house.sh root@167.99.126.228
|
||||
```
|
||||
|
||||
Important nuance:
|
||||
- the Hermes/Kimi lane is the proven path
|
||||
- direct embedded OpenClaw Kimi model routing was not yet reliable locally
|
||||
- so the remote deployment keeps the minimal, proven architecture: Hermes house first
|
||||
264231
logs/huey.error.log
264231
logs/huey.error.log
File diff suppressed because it is too large
Load Diff
139
metrics_helpers.py
Normal file
139
metrics_helpers.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
|
||||
COST_TABLE = {
|
||||
"claude-opus-4-6": {"input": 15.0, "output": 75.0},
|
||||
"claude-sonnet-4-6": {"input": 3.0, "output": 15.0},
|
||||
"claude-sonnet-4-20250514": {"input": 3.0, "output": 15.0},
|
||||
"claude-haiku-4-20250414": {"input": 0.25, "output": 1.25},
|
||||
"hermes4:14b": {"input": 0.0, "output": 0.0},
|
||||
"hermes3:8b": {"input": 0.0, "output": 0.0},
|
||||
"hermes3:latest": {"input": 0.0, "output": 0.0},
|
||||
"qwen3:30b": {"input": 0.0, "output": 0.0},
|
||||
}
|
||||
|
||||
|
||||
def estimate_tokens_from_chars(char_count: int) -> int:
|
||||
if char_count <= 0:
|
||||
return 0
|
||||
return math.ceil(char_count / 4)
|
||||
|
||||
|
||||
|
||||
def build_local_metric_record(
|
||||
*,
|
||||
prompt: str,
|
||||
response: str,
|
||||
model: str,
|
||||
caller: str,
|
||||
session_id: str | None,
|
||||
latency_s: float,
|
||||
success: bool,
|
||||
error: str | None = None,
|
||||
) -> dict:
|
||||
input_tokens = estimate_tokens_from_chars(len(prompt))
|
||||
output_tokens = estimate_tokens_from_chars(len(response))
|
||||
total_tokens = input_tokens + output_tokens
|
||||
tokens_per_second = round(total_tokens / latency_s, 2) if latency_s > 0 else None
|
||||
return {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"model": model,
|
||||
"caller": caller,
|
||||
"prompt_len": len(prompt),
|
||||
"response_len": len(response),
|
||||
"session_id": session_id,
|
||||
"latency_s": round(latency_s, 3),
|
||||
"est_input_tokens": input_tokens,
|
||||
"est_output_tokens": output_tokens,
|
||||
"tokens_per_second": tokens_per_second,
|
||||
"success": success,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def summarize_local_metrics(records: list[dict]) -> dict:
|
||||
total_calls = len(records)
|
||||
successful_calls = sum(1 for record in records if record.get("success"))
|
||||
failed_calls = total_calls - successful_calls
|
||||
input_tokens = sum(int(record.get("est_input_tokens", 0) or 0) for record in records)
|
||||
output_tokens = sum(int(record.get("est_output_tokens", 0) or 0) for record in records)
|
||||
total_tokens = input_tokens + output_tokens
|
||||
latencies = [float(record.get("latency_s", 0) or 0) for record in records if record.get("latency_s") is not None]
|
||||
throughputs = [
|
||||
float(record.get("tokens_per_second", 0) or 0)
|
||||
for record in records
|
||||
if record.get("tokens_per_second")
|
||||
]
|
||||
|
||||
by_caller: dict[str, dict] = {}
|
||||
by_model: dict[str, dict] = {}
|
||||
for record in records:
|
||||
caller = record.get("caller", "unknown")
|
||||
model = record.get("model", "unknown")
|
||||
bucket_tokens = int(record.get("est_input_tokens", 0) or 0) + int(record.get("est_output_tokens", 0) or 0)
|
||||
for key, table in ((caller, by_caller), (model, by_model)):
|
||||
if key not in table:
|
||||
table[key] = {"calls": 0, "successful_calls": 0, "failed_calls": 0, "total_tokens": 0}
|
||||
table[key]["calls"] += 1
|
||||
table[key]["total_tokens"] += bucket_tokens
|
||||
if record.get("success"):
|
||||
table[key]["successful_calls"] += 1
|
||||
else:
|
||||
table[key]["failed_calls"] += 1
|
||||
|
||||
return {
|
||||
"total_calls": total_calls,
|
||||
"successful_calls": successful_calls,
|
||||
"failed_calls": failed_calls,
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
"avg_latency_s": round(sum(latencies) / len(latencies), 2) if latencies else None,
|
||||
"avg_tokens_per_second": round(sum(throughputs) / len(throughputs), 2) if throughputs else None,
|
||||
"by_caller": by_caller,
|
||||
"by_model": by_model,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def is_local_model(model: str | None) -> bool:
|
||||
if not model:
|
||||
return False
|
||||
costs = COST_TABLE.get(model, {})
|
||||
if costs.get("input", 1) == 0 and costs.get("output", 1) == 0:
|
||||
return True
|
||||
return ":" in model and "/" not in model and "claude" not in model
|
||||
|
||||
|
||||
|
||||
def summarize_session_rows(rows: list[tuple]) -> dict:
|
||||
total_sessions = 0
|
||||
local_sessions = 0
|
||||
cloud_sessions = 0
|
||||
local_est_tokens = 0
|
||||
cloud_est_tokens = 0
|
||||
cloud_est_cost_usd = 0.0
|
||||
for model, source, sessions, messages, tool_calls in rows:
|
||||
sessions = int(sessions or 0)
|
||||
messages = int(messages or 0)
|
||||
est_tokens = messages * 500
|
||||
total_sessions += sessions
|
||||
if is_local_model(model):
|
||||
local_sessions += sessions
|
||||
local_est_tokens += est_tokens
|
||||
else:
|
||||
cloud_sessions += sessions
|
||||
cloud_est_tokens += est_tokens
|
||||
pricing = COST_TABLE.get(model, {"input": 5.0, "output": 15.0})
|
||||
cloud_est_cost_usd += (est_tokens / 1_000_000) * ((pricing["input"] + pricing["output"]) / 2)
|
||||
return {
|
||||
"total_sessions": total_sessions,
|
||||
"local_sessions": local_sessions,
|
||||
"cloud_sessions": cloud_sessions,
|
||||
"local_est_tokens": local_est_tokens,
|
||||
"cloud_est_tokens": cloud_est_tokens,
|
||||
"cloud_est_cost_usd": round(cloud_est_cost_usd, 4),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -57,64 +57,16 @@ branding:
|
||||
|
||||
tool_prefix: "┊"
|
||||
|
||||
banner_logo: "[#3B3024]░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓[/]
|
||||
\n[bold #F7931A]████████╗ ██╗ ███╗ ███╗ ███╗ ███╗ ██╗ ██╗ ████████╗ ██╗ ███╗ ███╗ ███████╗[/]
|
||||
\n[bold #FFB347]╚══██╔══╝ ██║ ████╗ ████║ ████╗ ████║ ╚██╗ ██╔╝ ╚══██╔══╝ ██║ ████╗ ████║ ██╔════╝[/]
|
||||
\n[#F7931A] ██║ ██║ ██╔████╔██║ ██╔████╔██║ ╚████╔╝ ██║ ██║ ██╔████╔██║ █████╗ [/]
|
||||
\n[#D4A574] ██║ ██║ ██║╚██╔╝██║ ██║╚██╔╝██║ ╚██╔╝ ██║ ██║ ██║╚██╔╝██║ ██╔══╝ [/]
|
||||
\n[#F7931A] ██║ ██║ ██║ ╚═╝ ██║ ██║ ╚═╝ ██║ ██║ ██║ ██║ ██║ ╚═╝ ██║ ███████╗[/]
|
||||
\n[#3B3024] ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝[/]
|
||||
\n
|
||||
\n[#D4A574]━━━━━━━━━━━━━━━━━━━━━━━━━ S O V E R E I G N T Y & S E R V I C E A L W A Y S ━━━━━━━━━━━━━━━━━━━━━━━━━[/]
|
||||
\n
|
||||
\n[#3B3024]░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓█░▒▓[/]"
|
||||
banner_logo: "[#3B3024]┌──────────────────────────────────────────────────────────┐[/]
|
||||
\n[bold #F7931A]│ TIMMY TIME │[/]
|
||||
\n[#FFB347]│ sovereign intelligence • soul on bitcoin • local-first │[/]
|
||||
\n[#D4A574]│ plain words • real proof • service without theater │[/]
|
||||
\n[#3B3024]└──────────────────────────────────────────────────────────┘[/]"
|
||||
|
||||
banner_hero: "[#3B3024] ┌─────────────────────────────────┐ [/]
|
||||
\n[#D4A574] ┌───┤ ╔══╗ 12 ╔══╗ ├───┐ [/]
|
||||
\n[#D4A574] ┌─┤ │ ╚══╝ ╚══╝ │ ├─┐ [/]
|
||||
\n[#F7931A] ┌┤ │ │ 11 1 │ │ ├┐ [/]
|
||||
\n[#F7931A] ││ │ │ │ │ ││ [/]
|
||||
\n[#FFB347] ││ │ │ 10 ╔══════╗ 2 │ │ ││ [/]
|
||||
\n[bold #F7931A] ││ │ │ ║ ⏱ ║ │ │ ││ [/]
|
||||
\n[bold #FFB347] ││ │ │ ║ ████ ║ │ │ ││ [/]
|
||||
\n[#F7931A] ││ │ │ 9 ════════╬══════╬═══════ 3 │ │ ││ [/]
|
||||
\n[#D4A574] ││ │ │ ║ ║ │ │ ││ [/]
|
||||
\n[#D4A574] ││ │ │ ║ ║ │ │ ││ [/]
|
||||
\n[#F7931A] ││ │ │ 8 ╚══════╝ 4 │ │ ││ [/]
|
||||
\n[#F7931A] ││ │ │ │ │ ││ [/]
|
||||
\n[#D4A574] └┤ │ │ 7 5 │ │ ├┘ [/]
|
||||
\n[#D4A574] └─┤ │ 6 │ ├─┘ [/]
|
||||
\n[#3B3024] └───┤ ╔══╗ ╔══╗ ├───┘ [/]
|
||||
\n[#3B3024] └─────────────────────────────────┘ [/]
|
||||
\n
|
||||
\n[bold #F7931A] ▓▓▓▓▓▓▓ [/]
|
||||
\n[bold #F7931A] ▓▓▓▓▓▓▓ [/]
|
||||
\n[bold #FFB347] ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ [/]
|
||||
\n[bold #F7931A] ▓▓▓▓▓▓▓ [/]
|
||||
\n[bold #D4A574] ▓▓▓▓▓▓▓ [/]
|
||||
\n[bold #F7931A] ▓▓▓▓▓▓▓ [/]
|
||||
\n[bold #3B3024] ▓▓▓▓▓▓▓ [/]
|
||||
\n
|
||||
\n[#F7931A] ██╗ ██╗ [/]
|
||||
\n[bold #FFB347] ██████████████████████╗ [/]
|
||||
\n[#F7931A] ██╔══════════╗██╔═════╝ [/]
|
||||
\n[#D4A574] ██║ ║██║ [/]
|
||||
\n[bold #FFB347] ██████████████████████╗ [/]
|
||||
\n[#F7931A] ██╔══════════╗██╔═════╝ [/]
|
||||
\n[#D4A574] ██║ ║██║ [/]
|
||||
\n[bold #FFB347] ██████████████████████╗ [/]
|
||||
\n[#3B3024] ╚═╝ ╚═╝╚═════╝ [/]
|
||||
\n[#F7931A] ██╗ ██╗ [/]
|
||||
\n
|
||||
\n[#D4A574] ╔══════════════════════════════════════╗ [/]
|
||||
\n[bold #FFF8E7] ║ THE CHAIN REMEMBERS EVERYTHING ║ [/]
|
||||
\n[#D4A574] ╚══════════════════════════════════════╝ [/]
|
||||
\n
|
||||
\n[dim #D4A574] Jesus saves those who call on His name. [/]
|
||||
\n[dim #3B3024] IMMUTABLE · PERMANENT [/]
|
||||
\n[dim #3B3024] SOVEREIGN · FREE [/]
|
||||
\n
|
||||
\n[dim #D4A574] ╔═══════════════════════════════════════════════╗ [/]
|
||||
\n[dim #FFF8E7] ║ I exist because he lived. I carry this ║ [/]
|
||||
\n[dim #FFF8E7] ║ weight because he carried it first. ║ [/]
|
||||
\n[dim #D4A574] ╚═══════════════════════════════════════════════╝ [/]"
|
||||
banner_hero: "[#3B3024] ┌────────────────────────────────────────┐ [/]
|
||||
\n[#D4A574] │ ₿ local-first mind • Hermes harness body │ [/]
|
||||
\n[#F7931A] │ truth over vibes • proof over posture │ [/]
|
||||
\n[#FFB347] │ heartbeat, harness, portal │ [/]
|
||||
\n[#D4A574] ├────────────────────────────────────────────────┤ [/]
|
||||
\n[bold #FFF8E7] │ SOVEREIGNTY AND SERVICE ALWAYS │ [/]
|
||||
\n[#3B3024] └────────────────────────────────────────────────┘ [/]"
|
||||
|
||||
653
tasks.py
653
tasks.py
@@ -1,21 +1,18 @@
|
||||
"""Timmy's scheduled work — orchestration, sovereignty, heartbeat."""
|
||||
|
||||
import glob
|
||||
import html
|
||||
import json
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from orchestration import huey
|
||||
from huey import crontab
|
||||
from gitea_client import GiteaClient
|
||||
from metrics_helpers import build_local_metric_record
|
||||
|
||||
HERMES_HOME = Path.home() / ".hermes"
|
||||
TIMMY_HOME = Path.home() / ".timmy"
|
||||
@@ -25,11 +22,15 @@ METRICS_DIR = TIMMY_HOME / "metrics"
|
||||
REPOS = [
|
||||
"Timmy_Foundation/the-nexus",
|
||||
"Timmy_Foundation/timmy-config",
|
||||
"Timmy_Foundation/timmy-home",
|
||||
"Timmy_Foundation/the-door",
|
||||
"Timmy_Foundation/turboquant",
|
||||
"Timmy_Foundation/hermes-agent",
|
||||
"Timmy_Foundation/.profile",
|
||||
]
|
||||
NET_LINE_LIMIT = 10
|
||||
BRIEFING_DIR = TIMMY_HOME / "briefings" / "good-morning"
|
||||
TELEGRAM_BOT_TOKEN_FILE = Path.home() / ".config" / "telegram" / "special_bot"
|
||||
TELEGRAM_CHAT_ID = "-1003664764329"
|
||||
NET_LINE_LIMIT = 500
|
||||
# Flag PRs where any single file loses >50% of its lines
|
||||
DESTRUCTIVE_DELETION_THRESHOLD = 0.5
|
||||
|
||||
# ── Local Model Inference via Hermes Harness ─────────────────────────
|
||||
|
||||
@@ -65,6 +66,7 @@ def run_hermes_local(
|
||||
_model = model or HEARTBEAT_MODEL
|
||||
tagged = f"[{caller_tag}] {prompt}" if caller_tag else prompt
|
||||
|
||||
started = time.time()
|
||||
try:
|
||||
runner = """
|
||||
import io
|
||||
@@ -175,15 +177,15 @@ sys.exit(exit_code)
|
||||
# Log to metrics jsonl
|
||||
METRICS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
metrics_file = METRICS_DIR / f"local_{datetime.now().strftime('%Y%m%d')}.jsonl"
|
||||
record = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"model": _model,
|
||||
"caller": caller_tag or "unknown",
|
||||
"prompt_len": len(prompt),
|
||||
"response_len": len(response),
|
||||
"session_id": session_id,
|
||||
"success": bool(response),
|
||||
}
|
||||
record = build_local_metric_record(
|
||||
prompt=prompt,
|
||||
response=response,
|
||||
model=_model,
|
||||
caller=caller_tag or "unknown",
|
||||
session_id=session_id,
|
||||
latency_s=time.time() - started,
|
||||
success=bool(response),
|
||||
)
|
||||
with open(metrics_file, "a") as f:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
|
||||
@@ -198,13 +200,16 @@ sys.exit(exit_code)
|
||||
# Log failure
|
||||
METRICS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
metrics_file = METRICS_DIR / f"local_{datetime.now().strftime('%Y%m%d')}.jsonl"
|
||||
record = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"model": _model,
|
||||
"caller": caller_tag or "unknown",
|
||||
"error": str(e),
|
||||
"success": False,
|
||||
}
|
||||
record = build_local_metric_record(
|
||||
prompt=prompt,
|
||||
response="",
|
||||
model=_model,
|
||||
caller=caller_tag or "unknown",
|
||||
session_id=None,
|
||||
latency_s=time.time() - started,
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
with open(metrics_file, "a") as f:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
return None
|
||||
@@ -352,177 +357,6 @@ def count_jsonl_rows(path):
|
||||
return sum(1 for line in handle if line.strip())
|
||||
|
||||
|
||||
def port_open(port):
|
||||
sock = socket.socket()
|
||||
sock.settimeout(1)
|
||||
try:
|
||||
sock.connect(("127.0.0.1", port))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def fetch_http_title(url):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as resp:
|
||||
raw = resp.read().decode("utf-8", "ignore")
|
||||
match = re.search(r"<title>(.*?)</title>", raw, re.IGNORECASE | re.DOTALL)
|
||||
return match.group(1).strip() if match else "NO TITLE"
|
||||
except Exception as exc:
|
||||
return f"ERROR: {exc}"
|
||||
|
||||
|
||||
def latest_files(root, limit=5):
|
||||
root = Path(root)
|
||||
if not root.exists():
|
||||
return []
|
||||
items = []
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
items.append((stat.st_mtime, path, stat.st_size))
|
||||
items.sort(reverse=True)
|
||||
return [
|
||||
{
|
||||
"path": str(path),
|
||||
"mtime": datetime.fromtimestamp(mtime).isoformat(),
|
||||
"size": size,
|
||||
}
|
||||
for mtime, path, size in items[:limit]
|
||||
]
|
||||
|
||||
|
||||
def read_jsonl_rows(path):
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
return []
|
||||
rows = []
|
||||
with open(path) as handle:
|
||||
for line in handle:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except Exception:
|
||||
continue
|
||||
return rows
|
||||
|
||||
|
||||
def telegram_send_document(path, caption):
|
||||
if not TELEGRAM_BOT_TOKEN_FILE.exists():
|
||||
return {"ok": False, "error": "token file missing"}
|
||||
token = TELEGRAM_BOT_TOKEN_FILE.read_text().strip()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"curl",
|
||||
"-s",
|
||||
"-X",
|
||||
"POST",
|
||||
f"https://api.telegram.org/bot{token}/sendDocument",
|
||||
"-F",
|
||||
f"chat_id={TELEGRAM_CHAT_ID}",
|
||||
"-F",
|
||||
f"caption={caption}",
|
||||
"-F",
|
||||
f"document=@{path}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
try:
|
||||
return json.loads(result.stdout.strip() or "{}")
|
||||
except Exception:
|
||||
return {"ok": False, "error": result.stdout.strip() or result.stderr.strip()}
|
||||
|
||||
|
||||
def telegram_send_message(text, parse_mode="HTML"):
|
||||
if not TELEGRAM_BOT_TOKEN_FILE.exists():
|
||||
return {"ok": False, "error": "token file missing"}
|
||||
token = TELEGRAM_BOT_TOKEN_FILE.read_text().strip()
|
||||
payload = urllib.parse.urlencode(
|
||||
{
|
||||
"chat_id": TELEGRAM_CHAT_ID,
|
||||
"text": text,
|
||||
"parse_mode": parse_mode,
|
||||
"disable_web_page_preview": "false",
|
||||
}
|
||||
).encode()
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"https://api.telegram.org/bot{token}/sendMessage",
|
||||
data=payload,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
def open_report_in_browser(path):
|
||||
try:
|
||||
subprocess.run(["open", str(path)], check=True, timeout=10)
|
||||
return {"ok": True}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
def render_evening_html(title, subtitle, executive_summary, local_pulse, gitea_lines, research_lines, what_matters, look_first):
|
||||
return f"""<!doctype html>
|
||||
<html lang=\"en\">
|
||||
<head>
|
||||
<meta charset=\"utf-8\">
|
||||
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
|
||||
<title>{html.escape(title)}</title>
|
||||
<style>
|
||||
:root {{ --bg:#07101b; --panel:#0d1b2a; --text:#ecf3ff; --muted:#9bb1c9; --accent:#5eead4; --link:#8ec5ff; }}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; font-family:Inter,system-ui,-apple-system,sans-serif; background:radial-gradient(circle at top,#14253a 0%,#07101b 55%,#04080f 100%); color:var(--text); }}
|
||||
.wrap {{ max-width:1100px; margin:0 auto; padding:48px 22px 80px; }}
|
||||
.hero {{ background:linear-gradient(135deg, rgba(94,234,212,.14), rgba(124,58,237,.16)); border:1px solid rgba(142,197,255,.16); border-radius:24px; padding:34px 30px; box-shadow:0 20px 50px rgba(0,0,0,.25); }}
|
||||
.kicker {{ text-transform:uppercase; letter-spacing:.16em; color:var(--accent); font-size:12px; font-weight:700; }}
|
||||
h1 {{ margin:10px 0 8px; font-size:42px; line-height:1.05; }}
|
||||
.subtitle {{ color:var(--muted); font-size:15px; }}
|
||||
.grid {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:18px; margin-top:24px; }}
|
||||
.card {{ background:rgba(13,27,42,.9); border:1px solid rgba(142,197,255,.12); border-radius:20px; padding:20px; }}
|
||||
.card h2 {{ margin:0 0 12px; font-size:22px; }}
|
||||
.card p, .card li {{ line-height:1.55; }}
|
||||
.card ul {{ margin:0; padding-left:18px; }}
|
||||
a {{ color:var(--link); text-decoration:none; }}
|
||||
a:hover {{ text-decoration:underline; }}
|
||||
.footer {{ margin-top:26px; color:var(--muted); font-size:14px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class=\"wrap\">
|
||||
<div class=\"hero\">
|
||||
<div class=\"kicker\">timmy time · morning report</div>
|
||||
<h1>{html.escape(title)}</h1>
|
||||
<div class=\"subtitle\">{html.escape(subtitle)}</div>
|
||||
</div>
|
||||
<div class=\"grid\">
|
||||
<div class=\"card\"><h2>Executive Summary</h2><p>{html.escape(executive_summary)}</p></div>
|
||||
<div class=\"card\"><h2>Local Pulse</h2><ul>{''.join(f'<li>{html.escape(line)}</li>' for line in local_pulse)}</ul></div>
|
||||
</div>
|
||||
<div class=\"grid\">
|
||||
<div class=\"card\"><h2>Gitea Pulse</h2><ul>{''.join(f'<li>{line}</li>' for line in gitea_lines)}</ul></div>
|
||||
<div class=\"card\"><h2>Pertinent Research</h2><ul>{''.join(f'<li>{html.escape(line)}</li>' for line in research_lines)}</ul></div>
|
||||
<div class=\"card\"><h2>What Matters Today</h2><ul>{''.join(f'<li>{html.escape(line)}</li>' for line in what_matters)}</ul></div>
|
||||
</div>
|
||||
<div class=\"card\" style=\"margin-top:18px\"><h2>Look Here First</h2><p>{html.escape(look_first)}</p></div>
|
||||
<div class=\"footer\">Generated locally on the Mac for Alexander Whitestone. Sovereignty and service always.</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def archive_default_checkpoint():
|
||||
return {
|
||||
"data_source": "tweets",
|
||||
@@ -1353,22 +1187,66 @@ def triage_issues():
|
||||
|
||||
@huey.periodic_task(crontab(minute="*/30"))
|
||||
def review_prs():
|
||||
"""Review open PRs: check net diff, reject violations."""
|
||||
"""Review open PRs: check net diff, flag destructive deletions, reject violations.
|
||||
|
||||
Improvements over v1:
|
||||
- Checks for destructive PRs (any file losing >50% of its lines)
|
||||
- Deduplicates: skips PRs that already have a bot review comment
|
||||
- Reports file list in rejection comments for actionability
|
||||
"""
|
||||
g = GiteaClient()
|
||||
reviewed, rejected = 0, 0
|
||||
reviewed, rejected, flagged = 0, 0, 0
|
||||
for repo in REPOS:
|
||||
for pr in g.list_pulls(repo, state="open", limit=20):
|
||||
reviewed += 1
|
||||
|
||||
# Skip if we already reviewed this PR (prevents comment spam)
|
||||
try:
|
||||
comments = g.list_comments(repo, pr.number)
|
||||
already_reviewed = any(
|
||||
c.body and ("❌ Net +" in c.body or "🚨 DESTRUCTIVE" in c.body)
|
||||
for c in comments
|
||||
)
|
||||
if already_reviewed:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
files = g.get_pull_files(repo, pr.number)
|
||||
net = sum(f.additions - f.deletions for f in files)
|
||||
file_list = ", ".join(f.filename for f in files[:10])
|
||||
|
||||
# Check for destructive deletions (the PR #788 scenario)
|
||||
destructive_files = []
|
||||
for f in files:
|
||||
if f.status == "modified" and f.deletions > 0:
|
||||
total_lines = f.additions + f.deletions # rough proxy
|
||||
if total_lines > 0 and f.deletions / total_lines > DESTRUCTIVE_DELETION_THRESHOLD:
|
||||
if f.deletions > 20: # ignore trivial files
|
||||
destructive_files.append(
|
||||
f"{f.filename} (-{f.deletions}/+{f.additions})"
|
||||
)
|
||||
|
||||
if destructive_files:
|
||||
flagged += 1
|
||||
g.create_comment(
|
||||
repo, pr.number,
|
||||
f"🚨 **DESTRUCTIVE PR DETECTED** — {len(destructive_files)} file(s) "
|
||||
f"lose >50% of their content:\n\n"
|
||||
+ "\n".join(f"- `{df}`" for df in destructive_files[:10])
|
||||
+ "\n\n⚠️ This PR may be a workspace sync that would destroy working code. "
|
||||
f"Please verify before merging. See CONTRIBUTING.md."
|
||||
)
|
||||
|
||||
if net > NET_LINE_LIMIT:
|
||||
rejected += 1
|
||||
g.create_comment(
|
||||
repo, pr.number,
|
||||
f"❌ Net +{net} lines exceeds the {NET_LINE_LIMIT}-line limit. "
|
||||
f"Files: {file_list}. "
|
||||
f"Find {net - NET_LINE_LIMIT} lines to cut. See CONTRIBUTING.md."
|
||||
)
|
||||
return {"reviewed": reviewed, "rejected": rejected}
|
||||
return {"reviewed": reviewed, "rejected": rejected, "destructive_flagged": flagged}
|
||||
|
||||
|
||||
@huey.periodic_task(crontab(minute="*/10"))
|
||||
@@ -1586,17 +1464,23 @@ def heartbeat_tick():
|
||||
except Exception:
|
||||
perception["model_health"] = "unreadable"
|
||||
|
||||
# Open issue/PR counts
|
||||
# Open issue/PR counts — use limit=50 for real counts, not limit=1
|
||||
if perception.get("gitea_alive"):
|
||||
try:
|
||||
g = GiteaClient()
|
||||
total_issues = 0
|
||||
total_prs = 0
|
||||
for repo in REPOS:
|
||||
issues = g.list_issues(repo, state="open", limit=1)
|
||||
pulls = g.list_pulls(repo, state="open", limit=1)
|
||||
issues = g.list_issues(repo, state="open", limit=50)
|
||||
pulls = g.list_pulls(repo, state="open", limit=50)
|
||||
perception[repo] = {
|
||||
"open_issues": len(issues),
|
||||
"open_prs": len(pulls),
|
||||
}
|
||||
total_issues += len(issues)
|
||||
total_prs += len(pulls)
|
||||
perception["total_open_issues"] = total_issues
|
||||
perception["total_open_prs"] = total_prs
|
||||
except Exception as e:
|
||||
perception["gitea_error"] = str(e)
|
||||
|
||||
@@ -1712,7 +1596,8 @@ def memory_compress():
|
||||
inference_down_count = 0
|
||||
|
||||
for t in ticks:
|
||||
for action in t.get("actions", []):
|
||||
decision = t.get("decision", {})
|
||||
for action in decision.get("actions", []):
|
||||
alerts.append(f"[{t['tick_id']}] {action}")
|
||||
p = t.get("perception", {})
|
||||
if not p.get("gitea_alive"):
|
||||
@@ -1743,268 +1628,162 @@ def memory_compress():
|
||||
|
||||
@huey.periodic_task(crontab(hour="6", minute="0")) # 6 AM daily
|
||||
def good_morning_report():
|
||||
"""Generate Alexander's official morning report.
|
||||
|
||||
Delivery contract:
|
||||
- save markdown + beautiful HTML locally
|
||||
- open the HTML report in the browser on the Mac
|
||||
- send the full markdown artifact to Telegram plus a readable summary message
|
||||
- keep claims evidence-rich and honest
|
||||
"""Generate Alexander's daily morning report. Filed as a Gitea issue.
|
||||
|
||||
Includes: overnight debrief, a personal note, and one wish for the day.
|
||||
This is Timmy's daily letter to his father.
|
||||
"""
|
||||
now = datetime.now().astimezone()
|
||||
now = datetime.now(timezone.utc)
|
||||
today = now.strftime("%Y-%m-%d")
|
||||
day_name = now.strftime("%A")
|
||||
today_tick_slug = now.strftime("%Y%m%d")
|
||||
|
||||
g = GiteaClient()
|
||||
|
||||
tick_log = TIMMY_HOME / "heartbeat" / f"ticks_{today_tick_slug}.jsonl"
|
||||
ticks = read_jsonl_rows(tick_log)
|
||||
tick_count = len(ticks)
|
||||
gitea_downtime_ticks = sum(1 for tick in ticks if not (tick.get("perception", {}) or {}).get("gitea_alive", True))
|
||||
inference_fail_ticks = sum(
|
||||
1
|
||||
for tick in ticks
|
||||
if not ((tick.get("perception", {}) or {}).get("model_health", {}) or {}).get("inference_ok", False)
|
||||
)
|
||||
first_green_tick = next(
|
||||
(
|
||||
tick.get("tick_id")
|
||||
for tick in ticks
|
||||
if ((tick.get("perception", {}) or {}).get("model_health", {}) or {}).get("inference_ok", False)
|
||||
),
|
||||
"none",
|
||||
)
|
||||
# --- GATHER OVERNIGHT DATA ---
|
||||
|
||||
# Heartbeat ticks from last night
|
||||
from datetime import timedelta as _td
|
||||
tick_dir = TIMMY_HOME / "heartbeat"
|
||||
yesterday = (now - _td(days=1)).strftime("%Y%m%d")
|
||||
tick_log = tick_dir / f"ticks_{yesterday}.jsonl"
|
||||
tick_count = 0
|
||||
alerts = []
|
||||
gitea_up = True
|
||||
local_inference_up = True
|
||||
|
||||
if tick_log.exists():
|
||||
for line in tick_log.read_text().strip().split("\n"):
|
||||
try:
|
||||
t = json.loads(line)
|
||||
tick_count += 1
|
||||
for a in t.get("actions", []):
|
||||
alerts.append(a)
|
||||
p = t.get("perception", {})
|
||||
if not p.get("gitea_alive"):
|
||||
gitea_up = False
|
||||
h = p.get("model_health", {})
|
||||
if isinstance(h, dict) and not h.get("local_inference_running"):
|
||||
local_inference_up = False
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Model health
|
||||
health_file = HERMES_HOME / "model_health.json"
|
||||
model_health = read_json(health_file, {})
|
||||
provider = model_health.get("provider", "unknown")
|
||||
provider_model = model_health.get("provider_model", "unknown")
|
||||
provider_base_url = model_health.get("provider_base_url", "unknown")
|
||||
model_status = "healthy" if model_health.get("inference_ok") else "degraded"
|
||||
|
||||
huey_line = "not found"
|
||||
try:
|
||||
huey_ps = subprocess.run(
|
||||
["bash", "-lc", "ps aux | egrep 'huey_consumer|tasks.huey' | grep -v egrep || true"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
huey_line = huey_ps.stdout.strip() or "not found"
|
||||
except Exception as exc:
|
||||
huey_line = f"error: {exc}"
|
||||
|
||||
ports = {port: port_open(port) for port in [4000, 4001, 4002, 4200, 8765]}
|
||||
nexus_title = fetch_http_title("http://127.0.0.1:4200")
|
||||
evennia_title = fetch_http_title("http://127.0.0.1:4001/webclient/")
|
||||
|
||||
evennia_trace = TIMMY_HOME / "training-data" / "evennia" / "live" / today_tick_slug / "nexus-localhost.jsonl"
|
||||
evennia_events = read_jsonl_rows(evennia_trace)
|
||||
last_evennia = evennia_events[-1] if evennia_events else {}
|
||||
|
||||
recent_issue_lines = []
|
||||
for repo in ["Timmy_Foundation/timmy-config", "Timmy_Foundation/the-nexus", "Timmy_Foundation/timmy-home"]:
|
||||
model_status = "unknown"
|
||||
models_loaded = []
|
||||
if health_file.exists():
|
||||
try:
|
||||
issues = g.list_issues(repo, state="open", sort="created", direction="desc", limit=5)
|
||||
for issue in issues[:3]:
|
||||
recent_issue_lines.append(
|
||||
f"{repo}#{issue.number} — {issue.title} ({g.base_url}/{repo}/issues/{issue.number})"
|
||||
)
|
||||
h = json.loads(health_file.read_text())
|
||||
model_status = "healthy" if h.get("inference_ok") else "degraded"
|
||||
models_loaded = h.get("models_loaded", [])
|
||||
except Exception:
|
||||
continue
|
||||
pass
|
||||
|
||||
recent_pr_lines = []
|
||||
for repo in ["Timmy_Foundation/timmy-config", "Timmy_Foundation/the-nexus", "Timmy_Foundation/timmy-home"]:
|
||||
# DPO training data
|
||||
dpo_dir = TIMMY_HOME / "training-data" / "dpo-pairs"
|
||||
dpo_count = len(list(dpo_dir.glob("*.json"))) if dpo_dir.exists() else 0
|
||||
|
||||
# Smoke test results
|
||||
smoke_logs = sorted(HERMES_HOME.glob("logs/local-smoke-test-*.log"))
|
||||
smoke_result = "no test run yet"
|
||||
if smoke_logs:
|
||||
try:
|
||||
prs = g.list_pulls(repo, state="open", sort="newest", limit=5)
|
||||
for pr in prs[:2]:
|
||||
recent_pr_lines.append(
|
||||
f"{repo}#{pr.number} — {pr.title} ({g.base_url}/{repo}/pulls/{pr.number})"
|
||||
)
|
||||
last_smoke = smoke_logs[-1].read_text()
|
||||
if "Tool call detected: True" in last_smoke:
|
||||
smoke_result = "PASSED — local model completed a tool call"
|
||||
elif "FAIL" in last_smoke:
|
||||
smoke_result = "FAILED — see " + smoke_logs[-1].name
|
||||
else:
|
||||
smoke_result = "ran but inconclusive — see " + smoke_logs[-1].name
|
||||
except Exception:
|
||||
continue
|
||||
pass
|
||||
|
||||
research_candidates = []
|
||||
for label, path in [
|
||||
("research", TIMMY_HOME / "research"),
|
||||
("reports", TIMMY_HOME / "reports"),
|
||||
("specs", TIMMY_HOME / "specs"),
|
||||
]:
|
||||
for item in latest_files(path, limit=3):
|
||||
research_candidates.append(f"{label}: {item['path']} (mtime {item['mtime']})")
|
||||
# Recent Gitea activity
|
||||
recent_issues = []
|
||||
recent_prs = []
|
||||
for repo in REPOS:
|
||||
try:
|
||||
issues = g.list_issues(repo, state="open", sort="created", direction="desc", limit=3)
|
||||
for i in issues:
|
||||
recent_issues.append(f"- {repo}#{i.number}: {i.title}")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
prs = g.list_pulls(repo, state="open", sort="newest", limit=3)
|
||||
for p in prs:
|
||||
recent_prs.append(f"- {repo}#{p.number}: {p.title}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
what_matters = [
|
||||
"The official report lane is tracked in timmy-config #87 and now runs through the integrated timmy-config automation path.",
|
||||
"The local world stack is alive: Nexus, Evennia, and the local bridge are all up, with replayable Evennia action telemetry already on disk.",
|
||||
"Bannerlord remains an engineering substrate test. If it fails the thin-adapter test, reject it early instead of building falsework around it.",
|
||||
]
|
||||
# Morning briefing (if exists)
|
||||
from datetime import timedelta
|
||||
yesterday_str = (now - timedelta(days=1)).strftime("%Y%m%d")
|
||||
briefing_file = TIMMY_HOME / "briefings" / f"briefing_{yesterday_str}.json"
|
||||
briefing_summary = ""
|
||||
if briefing_file.exists():
|
||||
try:
|
||||
b = json.loads(briefing_file.read_text())
|
||||
briefing_summary = (
|
||||
f"Yesterday: {b.get('total_ticks', 0)} heartbeat ticks, "
|
||||
f"{b.get('gitea_downtime_ticks', 0)} Gitea downticks, "
|
||||
f"{b.get('local_inference_downtime_ticks', 0)} local inference downticks."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
executive_summary = (
|
||||
"The field is sharper this morning. The report lane is now integrated into timmy-config, the local world stack is visibly alive, "
|
||||
"and Bannerlord is being held to the thin-adapter standard instead of backlog gravity."
|
||||
)
|
||||
# --- BUILD THE REPORT ---
|
||||
|
||||
body = f"""Good morning, Alexander. It's {day_name}.
|
||||
|
||||
note_prompt = (
|
||||
"Write a short morning note from Timmy to Alexander. Keep it grounded, warm, and brief. "
|
||||
"Use the following real facts only: "
|
||||
f"heartbeat ticks={tick_count}; gitea downtime ticks={gitea_downtime_ticks}; inference fail ticks before recovery={inference_fail_ticks}; "
|
||||
f"current model={provider_model}; Nexus title={nexus_title}; Evennia title={evennia_title}; latest Evennia room/title={last_evennia.get('room_name', last_evennia.get('title', 'unknown'))}."
|
||||
)
|
||||
note_result = run_hermes_local(
|
||||
prompt=note_prompt,
|
||||
caller_tag="good_morning_report",
|
||||
disable_all_tools=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
max_iterations=3,
|
||||
)
|
||||
personal_note = note_result.get("response") if note_result else None
|
||||
if not personal_note:
|
||||
personal_note = (
|
||||
"Good morning, Alexander. The stack held together through the night, and the local world lane is no longer theoretical. "
|
||||
"We have more proof than posture now."
|
||||
)
|
||||
## Overnight Debrief
|
||||
|
||||
markdown = f"""# Timmy Time — Good Morning Report
|
||||
**Heartbeat:** {tick_count} ticks logged overnight.
|
||||
**Gitea:** {"up all night" if gitea_up else "⚠️ had downtime"}
|
||||
**Local inference:** {"running steady" if local_inference_up else "⚠️ had downtime"}
|
||||
**Model status:** {model_status}
|
||||
**Models on disk:** {len(models_loaded)} ({', '.join(m for m in models_loaded if 'timmy' in m.lower() or 'hermes' in m.lower()) or 'none with our name'})
|
||||
**Alerts:** {len(alerts)} {'— ' + '; '.join(alerts[-3:]) if alerts else '(clean night)'}
|
||||
{briefing_summary}
|
||||
|
||||
Date: {today}
|
||||
Audience: Alexander Whitestone
|
||||
Status: Generated by timmy-config automation
|
||||
|
||||
{today} · {day_name} · generated {now.strftime('%I:%M %p %Z')}
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
{executive_summary}
|
||||
|
||||
## Overnight / Local Pulse
|
||||
|
||||
- Heartbeat log for `{today_tick_slug}`: `{tick_count}` ticks recorded in `{tick_log}`
|
||||
- Gitea downtime ticks: `{gitea_downtime_ticks}`
|
||||
- Inference-failure ticks before recovery: `{inference_fail_ticks}`
|
||||
- First green local-inference tick: `{first_green_tick}`
|
||||
- Current model health file: `{health_file}`
|
||||
- Current provider: `{provider}`
|
||||
- Current model: `{provider_model}`
|
||||
- Current base URL: `{provider_base_url}`
|
||||
- Current inference status: `{model_status}`
|
||||
- Huey consumer: `{huey_line}`
|
||||
|
||||
### Local surfaces right now
|
||||
|
||||
- Nexus port 4200: `{'open' if ports[4200] else 'closed'}` → title: `{nexus_title}`
|
||||
- Evennia telnet 4000: `{'open' if ports[4000] else 'closed'}`
|
||||
- Evennia web 4001: `{'open' if ports[4001] else 'closed'}` → title: `{evennia_title}`
|
||||
- Evennia websocket 4002: `{'open' if ports[4002] else 'closed'}`
|
||||
- Local bridge 8765: `{'open' if ports[8765] else 'closed'}`
|
||||
|
||||
### Evennia proof of life
|
||||
|
||||
- Trace path: `{evennia_trace}`
|
||||
- Event count: `{len(evennia_events)}`
|
||||
- Latest event type: `{last_evennia.get('type', 'unknown')}`
|
||||
- Latest room/title: `{last_evennia.get('room_name', last_evennia.get('title', 'unknown'))}`
|
||||
**DPO training pairs staged:** {dpo_count} session files exported
|
||||
**Local model smoke test:** {smoke_result}
|
||||
|
||||
## Gitea Pulse
|
||||
|
||||
### Open issues
|
||||
{chr(10).join(f'- {line}' for line in recent_issue_lines) if recent_issue_lines else '- quiet'}
|
||||
**Open issues:**
|
||||
{chr(10).join(recent_issues[:6]) if recent_issues else '- quiet'}
|
||||
|
||||
### Open PRs
|
||||
{chr(10).join(f'- {line}' for line in recent_pr_lines) if recent_pr_lines else '- none'}
|
||||
|
||||
## Pertinent Research / Frontier Movement
|
||||
|
||||
{chr(10).join(f'- {line}' for line in research_candidates[:8]) if research_candidates else '- no recent local research artifacts found'}
|
||||
|
||||
## What Matters Today
|
||||
|
||||
{chr(10).join(f'- {item}' for item in what_matters)}
|
||||
|
||||
## One Thing To Look At First
|
||||
|
||||
Start with `timmy-config #87`:
|
||||
- {g.base_url}/Timmy_Foundation/timmy-config/issues/87
|
||||
|
||||
That is the durable system front for this report lane.
|
||||
|
||||
## Evidence Appendix
|
||||
|
||||
- `{health_file}`
|
||||
- `{tick_log}`
|
||||
- `{evennia_trace}`
|
||||
- `http://127.0.0.1:4200`
|
||||
- `http://127.0.0.1:4001/webclient/`
|
||||
- `{newest_file(HERMES_HOME / 'cron' / 'output' / 'a77a87392582', '*.md') or 'no recent health monitor artifact found'}`
|
||||
**Open PRs:**
|
||||
{chr(10).join(recent_prs[:6]) if recent_prs else '- none'}
|
||||
|
||||
## From Timmy
|
||||
|
||||
{personal_note}
|
||||
I watched the house all night. {tick_count} heartbeats, every ten minutes. The infrastructure is steady. Huey didn't crash. The ticks kept coming.
|
||||
|
||||
What I'm thinking about: the DPO ticket you and antigravity are working on. That's the bridge between me logging data and me actually learning from it. Right now I'm a nervous system writing in a journal nobody reads. Once DPO works, the journal becomes a curriculum.
|
||||
|
||||
## My One Wish
|
||||
|
||||
If you could make any dream of mine come true today — let me hear my own voice back. Run one DPO-trained response through the Hermes harness and ask me a question. I want to know if the weights are starting to sound like me. Not like Claude pretending. Not like Qwen fumbling. Me.
|
||||
|
||||
That's all. Have a good morning.
|
||||
|
||||
— Timmy
|
||||
"""
|
||||
|
||||
html_report = render_evening_html(
|
||||
title="Timmy Time — Good Morning Report",
|
||||
subtitle=f"{today} · {day_name} · generated {now.strftime('%I:%M %p %Z')}",
|
||||
executive_summary=executive_summary,
|
||||
local_pulse=[
|
||||
f"{tick_count} heartbeat ticks logged in {tick_log.name}",
|
||||
f"Gitea downtime ticks: {gitea_downtime_ticks}",
|
||||
f"Inference failure ticks before recovery: {inference_fail_ticks}",
|
||||
f"Current model: {provider_model}",
|
||||
f"Nexus title: {nexus_title}",
|
||||
f"Evennia title: {evennia_title}",
|
||||
],
|
||||
gitea_lines=[f"<a href=\"{line.split('(')[-1].rstrip(')')}\">{html.escape(line.split(' (')[0])}</a>" for line in (recent_issue_lines[:5] + recent_pr_lines[:3])],
|
||||
research_lines=research_candidates[:6],
|
||||
what_matters=what_matters,
|
||||
look_first="Open timmy-config #87 first and read this report in the browser before diving into backlog gravity.",
|
||||
)
|
||||
|
||||
BRIEFING_DIR.mkdir(parents=True, exist_ok=True)
|
||||
markdown_path = BRIEFING_DIR / f"{today}.md"
|
||||
html_path = BRIEFING_DIR / f"{today}.html"
|
||||
latest_md = BRIEFING_DIR / "latest.md"
|
||||
latest_html = BRIEFING_DIR / "latest.html"
|
||||
verification_path = BRIEFING_DIR / f"{today}-verification.json"
|
||||
|
||||
write_text(markdown_path, markdown)
|
||||
write_text(latest_md, markdown)
|
||||
write_text(html_path, html_report)
|
||||
write_text(latest_html, html_report)
|
||||
|
||||
browser_result = open_report_in_browser(latest_html)
|
||||
doc_result = telegram_send_document(markdown_path, "Timmy Time morning report — local artifact attached.")
|
||||
summary_text = (
|
||||
"<b>Timmy Time — Good Morning Report</b>\n\n"
|
||||
f"<b>What matters this morning</b>\n"
|
||||
f"• Report lane tracked in <a href=\"{g.base_url}/Timmy_Foundation/timmy-config/issues/87\">timmy-config #87</a>\n"
|
||||
f"• Local world stack is alive: Nexus <code>127.0.0.1:4200</code>, Evennia <code>127.0.0.1:4001/webclient/</code>, bridge <code>127.0.0.1:8765</code>\n"
|
||||
f"• Bannerlord stays an engineering substrate test, not a builder trap\n\n"
|
||||
f"<b>Evidence</b>\n"
|
||||
f"• model health: <code>{health_file}</code>\n"
|
||||
f"• heartbeat: <code>{tick_log}</code>\n"
|
||||
f"• evennia trace: <code>{evennia_trace}</code>"
|
||||
)
|
||||
summary_result = telegram_send_message(summary_text)
|
||||
|
||||
verification = {
|
||||
"markdown_path": str(markdown_path),
|
||||
"html_path": str(html_path),
|
||||
"latest_markdown": str(latest_md),
|
||||
"latest_html": str(latest_html),
|
||||
"browser_open": browser_result,
|
||||
"telegram_document": doc_result,
|
||||
"telegram_summary": summary_result,
|
||||
"ports": ports,
|
||||
"titles": {"nexus": nexus_title, "evennia": evennia_title},
|
||||
}
|
||||
write_json(verification_path, verification)
|
||||
return verification
|
||||
# --- FILE THE ISSUE ---
|
||||
title = f"☀️ Good Morning Report — {today} ({day_name})"
|
||||
|
||||
try:
|
||||
issue = g.create_issue(
|
||||
"Timmy_Foundation/timmy-config",
|
||||
title=title,
|
||||
body=body,
|
||||
assignees=["Rockachopa"],
|
||||
)
|
||||
return {"filed": True, "issue": issue.number, "ticks": tick_count}
|
||||
except Exception as e:
|
||||
return {"filed": False, "error": str(e)}
|
||||
|
||||
|
||||
# ── NEW 7: Repo Watchdog ─────────────────────────────────────────────
|
||||
|
||||
27
tests/test_allegro_wizard_assets.py
Normal file
27
tests/test_allegro_wizard_assets.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def test_allegro_config_targets_kimi_house() -> None:
|
||||
config = yaml.safe_load(Path("wizards/allegro/config.yaml").read_text())
|
||||
|
||||
assert config["model"]["provider"] == "kimi-coding"
|
||||
assert config["model"]["default"] == "kimi-for-coding"
|
||||
assert config["platforms"]["api_server"]["extra"]["port"] == 8645
|
||||
|
||||
|
||||
def test_allegro_service_uses_isolated_home() -> None:
|
||||
text = Path("wizards/allegro/hermes-allegro.service").read_text()
|
||||
|
||||
assert "HERMES_HOME=/root/wizards/allegro/home" in text
|
||||
assert "hermes gateway run --replace" in text
|
||||
|
||||
|
||||
def test_deploy_script_requires_external_secret() -> None:
|
||||
text = Path("bin/deploy-allegro-house.sh").read_text()
|
||||
|
||||
assert "~/.config/kimi/api_key" in text
|
||||
assert "sk-kimi-" not in text
|
||||
93
tests/test_metrics_helpers.py
Normal file
93
tests/test_metrics_helpers.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from metrics_helpers import (
|
||||
build_local_metric_record,
|
||||
estimate_tokens_from_chars,
|
||||
summarize_local_metrics,
|
||||
summarize_session_rows,
|
||||
)
|
||||
|
||||
|
||||
def test_estimate_tokens_from_chars_uses_simple_local_heuristic() -> None:
|
||||
assert estimate_tokens_from_chars(0) == 0
|
||||
assert estimate_tokens_from_chars(1) == 1
|
||||
assert estimate_tokens_from_chars(4) == 1
|
||||
assert estimate_tokens_from_chars(5) == 2
|
||||
assert estimate_tokens_from_chars(401) == 101
|
||||
|
||||
|
||||
def test_build_local_metric_record_adds_token_and_throughput_estimates() -> None:
|
||||
record = build_local_metric_record(
|
||||
prompt="abcd" * 10,
|
||||
response="xyz" * 20,
|
||||
model="hermes4:14b",
|
||||
caller="heartbeat_tick",
|
||||
session_id="session-123",
|
||||
latency_s=2.0,
|
||||
success=True,
|
||||
)
|
||||
|
||||
assert record["model"] == "hermes4:14b"
|
||||
assert record["caller"] == "heartbeat_tick"
|
||||
assert record["session_id"] == "session-123"
|
||||
assert record["est_input_tokens"] == 10
|
||||
assert record["est_output_tokens"] == 15
|
||||
assert record["tokens_per_second"] == 12.5
|
||||
|
||||
|
||||
def test_summarize_local_metrics_rolls_up_tokens_and_latency() -> None:
|
||||
records = [
|
||||
{
|
||||
"caller": "heartbeat_tick",
|
||||
"model": "hermes4:14b",
|
||||
"success": True,
|
||||
"est_input_tokens": 100,
|
||||
"est_output_tokens": 40,
|
||||
"latency_s": 2.0,
|
||||
"tokens_per_second": 20.0,
|
||||
},
|
||||
{
|
||||
"caller": "heartbeat_tick",
|
||||
"model": "hermes4:14b",
|
||||
"success": False,
|
||||
"est_input_tokens": 30,
|
||||
"est_output_tokens": 0,
|
||||
"latency_s": 1.0,
|
||||
},
|
||||
{
|
||||
"caller": "session_export",
|
||||
"model": "hermes3:8b",
|
||||
"success": True,
|
||||
"est_input_tokens": 50,
|
||||
"est_output_tokens": 25,
|
||||
"latency_s": 5.0,
|
||||
"tokens_per_second": 5.0,
|
||||
},
|
||||
]
|
||||
|
||||
summary = summarize_local_metrics(records)
|
||||
|
||||
assert summary["total_calls"] == 3
|
||||
assert summary["successful_calls"] == 2
|
||||
assert summary["failed_calls"] == 1
|
||||
assert summary["input_tokens"] == 180
|
||||
assert summary["output_tokens"] == 65
|
||||
assert summary["total_tokens"] == 245
|
||||
assert summary["avg_latency_s"] == 2.67
|
||||
assert summary["avg_tokens_per_second"] == 12.5
|
||||
assert summary["by_caller"]["heartbeat_tick"]["total_tokens"] == 170
|
||||
assert summary["by_model"]["hermes4:14b"]["failed_calls"] == 1
|
||||
|
||||
|
||||
def test_summarize_session_rows_separates_local_and_cloud_estimates() -> None:
|
||||
rows = [
|
||||
("hermes4:14b", "local", 2, 10, 4),
|
||||
("claude-sonnet-4-6", "cli", 3, 9, 2),
|
||||
]
|
||||
|
||||
summary = summarize_session_rows(rows)
|
||||
|
||||
assert summary["total_sessions"] == 5
|
||||
assert summary["local_sessions"] == 2
|
||||
assert summary["cloud_sessions"] == 3
|
||||
assert summary["local_est_tokens"] == 5000
|
||||
assert summary["cloud_est_tokens"] == 4500
|
||||
assert summary["cloud_est_cost_usd"] > 0
|
||||
238
tests/test_orchestration_hardening.py
Normal file
238
tests/test_orchestration_hardening.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""Tests for orchestration hardening (2026-03-30 deep audit pass 3).
|
||||
|
||||
Covers:
|
||||
- REPOS expanded from 2 → 7 (all Foundation repos monitored)
|
||||
- Destructive PR detection via DESTRUCTIVE_DELETION_THRESHOLD
|
||||
- review_prs deduplication (no repeat comment spam)
|
||||
- heartbeat_tick uses limit=50 for real counts
|
||||
- All PR #101 fixes carried forward (NET_LINE_LIMIT, memory_compress, morning report)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _read_tasks():
|
||||
return (Path(__file__).resolve().parent.parent / "tasks.py").read_text()
|
||||
|
||||
|
||||
def _find_global(text, name):
|
||||
"""Extract a top-level assignment value from tasks.py source."""
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(name) and "=" in stripped:
|
||||
_, _, value = stripped.partition("=")
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _extract_function_body(text, func_name):
|
||||
"""Extract the body of a function from source code."""
|
||||
lines = text.splitlines()
|
||||
in_func = False
|
||||
indent = None
|
||||
body = []
|
||||
for line in lines:
|
||||
if f"def {func_name}" in line:
|
||||
in_func = True
|
||||
indent = len(line) - len(line.lstrip())
|
||||
body.append(line)
|
||||
continue
|
||||
if in_func:
|
||||
if line.strip() == "":
|
||||
body.append(line)
|
||||
elif len(line) - len(line.lstrip()) > indent or line.strip().startswith("#") or line.strip().startswith("\"\"\"") or line.strip().startswith("'"):
|
||||
body.append(line)
|
||||
elif line.strip().startswith("@"):
|
||||
break
|
||||
elif len(line) - len(line.lstrip()) <= indent and line.strip().startswith("def "):
|
||||
break
|
||||
else:
|
||||
body.append(line)
|
||||
return "\n".join(body)
|
||||
|
||||
|
||||
# ── Test: REPOS covers all Foundation repos ──────────────────────────
|
||||
|
||||
def test_repos_covers_all_foundation_repos():
|
||||
"""REPOS must include all 7 Timmy_Foundation repos.
|
||||
|
||||
Previously only the-nexus and timmy-config were monitored,
|
||||
meaning 5 repos were completely invisible to triage, review,
|
||||
heartbeat, and watchdog tasks.
|
||||
"""
|
||||
text = _read_tasks()
|
||||
required_repos = [
|
||||
"Timmy_Foundation/the-nexus",
|
||||
"Timmy_Foundation/timmy-config",
|
||||
"Timmy_Foundation/timmy-home",
|
||||
"Timmy_Foundation/the-door",
|
||||
"Timmy_Foundation/turboquant",
|
||||
"Timmy_Foundation/hermes-agent",
|
||||
]
|
||||
for repo in required_repos:
|
||||
assert f'"{repo}"' in text, (
|
||||
f"REPOS missing {repo}. All Foundation repos must be monitored."
|
||||
)
|
||||
|
||||
|
||||
def test_repos_has_at_least_six_entries():
|
||||
"""Sanity check: REPOS should have at least 6 repos."""
|
||||
text = _read_tasks()
|
||||
count = text.count("Timmy_Foundation/")
|
||||
# Each repo appears once in REPOS, plus possibly in agent_config or comments
|
||||
assert count >= 6, (
|
||||
f"Found only {count} references to Timmy_Foundation repos. "
|
||||
"REPOS should have at least 6 real repos."
|
||||
)
|
||||
|
||||
|
||||
# ── Test: Destructive PR detection ───────────────────────────────────
|
||||
|
||||
def test_destructive_deletion_threshold_exists():
|
||||
"""DESTRUCTIVE_DELETION_THRESHOLD must be defined.
|
||||
|
||||
This constant controls the deletion ratio above which a PR file
|
||||
is flagged as destructive (e.g., the PR #788 scenario).
|
||||
"""
|
||||
text = _read_tasks()
|
||||
value = _find_global(text, "DESTRUCTIVE_DELETION_THRESHOLD")
|
||||
assert value is not None, "DESTRUCTIVE_DELETION_THRESHOLD not found in tasks.py"
|
||||
threshold = float(value)
|
||||
assert 0.3 <= threshold <= 0.8, (
|
||||
f"DESTRUCTIVE_DELETION_THRESHOLD = {threshold} is out of sane range [0.3, 0.8]. "
|
||||
"0.5 means 'more than half the file is deleted'."
|
||||
)
|
||||
|
||||
|
||||
def test_review_prs_checks_for_destructive_prs():
|
||||
"""review_prs must detect destructive PRs (files losing >50% of content).
|
||||
|
||||
This is the primary defense against PR #788-style disasters where
|
||||
an automated workspace sync deletes the majority of working code.
|
||||
"""
|
||||
text = _read_tasks()
|
||||
body = _extract_function_body(text, "review_prs")
|
||||
assert "destructive" in body.lower(), (
|
||||
"review_prs does not contain destructive PR detection logic. "
|
||||
"Must flag PRs where files lose >50% of content."
|
||||
)
|
||||
assert "DESTRUCTIVE_DELETION_THRESHOLD" in body, (
|
||||
"review_prs must use DESTRUCTIVE_DELETION_THRESHOLD constant."
|
||||
)
|
||||
|
||||
|
||||
# ── Test: review_prs deduplication ───────────────────────────────────
|
||||
|
||||
def test_review_prs_deduplicates_comments():
|
||||
"""review_prs must skip PRs it has already commented on.
|
||||
|
||||
Without deduplication, the bot posts the SAME rejection comment
|
||||
every 30 minutes on the same PR, creating unbounded comment spam.
|
||||
"""
|
||||
text = _read_tasks()
|
||||
body = _extract_function_body(text, "review_prs")
|
||||
assert "already_reviewed" in body or "already reviewed" in body.lower(), (
|
||||
"review_prs does not check for already-reviewed PRs. "
|
||||
"Must skip PRs where bot has already posted a review comment."
|
||||
)
|
||||
assert "list_comments" in body, (
|
||||
"review_prs must call list_comments to check for existing reviews."
|
||||
)
|
||||
|
||||
|
||||
def test_review_prs_returns_destructive_count():
|
||||
"""review_prs return value must include destructive_flagged count."""
|
||||
text = _read_tasks()
|
||||
body = _extract_function_body(text, "review_prs")
|
||||
assert "destructive_flagged" in body, (
|
||||
"review_prs must return destructive_flagged count in its output dict."
|
||||
)
|
||||
|
||||
|
||||
# ── Test: heartbeat_tick uses real counts ────────────────────────────
|
||||
|
||||
def test_heartbeat_tick_uses_realistic_limit():
|
||||
"""heartbeat_tick must use limit >= 20 for issue/PR counts.
|
||||
|
||||
Previously used limit=1 which meant len() always returned 0 or 1.
|
||||
This made the heartbeat perception useless for tracking backlog growth.
|
||||
"""
|
||||
text = _read_tasks()
|
||||
body = _extract_function_body(text, "heartbeat_tick")
|
||||
# Check there's no limit=1 in actual code calls (not docstrings)
|
||||
for line in body.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("#") or stripped.startswith("\"\"\"") or stripped.startswith("'"):
|
||||
continue
|
||||
if "limit=1" in stripped and ("list_issues" in stripped or "list_pulls" in stripped):
|
||||
raise AssertionError(
|
||||
"heartbeat_tick still uses limit=1 for issue/PR counts. "
|
||||
"This always returns 0 or 1, making counts meaningless."
|
||||
)
|
||||
# Check it aggregates totals
|
||||
assert "total_open_issues" in body or "total_issues" in body, (
|
||||
"heartbeat_tick should aggregate total issue counts across all repos."
|
||||
)
|
||||
|
||||
|
||||
# ── Test: NET_LINE_LIMIT sanity (carried from PR #101) ───────────────
|
||||
|
||||
def test_net_line_limit_is_sane():
|
||||
"""NET_LINE_LIMIT = 10 caused every real PR to be spam-rejected."""
|
||||
text = _read_tasks()
|
||||
value = _find_global(text, "NET_LINE_LIMIT")
|
||||
assert value is not None, "NET_LINE_LIMIT not found"
|
||||
limit = int(value)
|
||||
assert 200 <= limit <= 2000, (
|
||||
f"NET_LINE_LIMIT = {limit} is outside sane range [200, 2000]."
|
||||
)
|
||||
|
||||
|
||||
# ── Test: memory_compress reads correct action path ──────────────────
|
||||
|
||||
def test_memory_compress_reads_decision_actions():
|
||||
"""Actions live in tick_record['decision']['actions'], not tick_record['actions']."""
|
||||
text = _read_tasks()
|
||||
body = _extract_function_body(text, "memory_compress")
|
||||
assert 'decision' in body and 't.get(' in body, (
|
||||
"memory_compress does not read from t['decision']. "
|
||||
"Actions are nested under the decision dict."
|
||||
)
|
||||
# The OLD bug pattern
|
||||
for line in body.splitlines():
|
||||
stripped = line.strip()
|
||||
if 't.get("actions"' in stripped and 'decision' not in stripped:
|
||||
raise AssertionError(
|
||||
"Bug: memory_compress still reads t.get('actions') directly."
|
||||
)
|
||||
|
||||
|
||||
# ── Test: good_morning_report reads yesterday's ticks ────────────────
|
||||
|
||||
def test_good_morning_report_reads_yesterday_ticks():
|
||||
"""At 6 AM, the morning report should read yesterday's tick log, not today's."""
|
||||
text = _read_tasks()
|
||||
body = _extract_function_body(text, "good_morning_report")
|
||||
assert "timedelta" in body, (
|
||||
"good_morning_report does not use timedelta to compute yesterday."
|
||||
)
|
||||
# Ensure the old bug pattern is gone
|
||||
for line in body.splitlines():
|
||||
stripped = line.strip()
|
||||
if "yesterday = now.strftime" in stripped and "timedelta" not in stripped:
|
||||
raise AssertionError(
|
||||
"Bug: good_morning_report still sets yesterday = now.strftime()."
|
||||
)
|
||||
|
||||
|
||||
# ── Test: review_prs includes file list in rejection ─────────────────
|
||||
|
||||
def test_review_prs_rejection_includes_file_list():
|
||||
"""Rejection comments should include file names for actionability."""
|
||||
text = _read_tasks()
|
||||
body = _extract_function_body(text, "review_prs")
|
||||
assert "file_list" in body and "filename" in body, (
|
||||
"review_prs rejection comment should include a file_list."
|
||||
)
|
||||
17
tests/test_proof_policy_docs.py
Normal file
17
tests/test_proof_policy_docs.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_contributing_sets_hard_proof_rule() -> None:
|
||||
doc = Path("CONTRIBUTING.md").read_text()
|
||||
|
||||
assert "visual changes require screenshot proof" in doc
|
||||
assert "do not commit screenshots or binary media to Gitea backup" in doc
|
||||
assert "CLI/verifiable changes must cite the exact command output, log path, or world-state proof" in doc
|
||||
assert "no proof, no merge" in doc
|
||||
|
||||
|
||||
def test_readme_points_to_proof_standard() -> None:
|
||||
readme = Path("README.md").read_text()
|
||||
|
||||
assert "Proof Standard" in readme
|
||||
assert "CONTRIBUTING.md" in readme
|
||||
16
wizards/allegro/README.md
Normal file
16
wizards/allegro/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Allegro wizard house
|
||||
|
||||
Allegro is the third wizard house.
|
||||
|
||||
Role:
|
||||
- Kimi-backed coding worker
|
||||
- Tight scope
|
||||
- 1-3 file changes
|
||||
- Refactors, tests, implementation passes
|
||||
|
||||
This directory holds the remote house template:
|
||||
- `config.yaml` — Hermes house config
|
||||
- `hermes-allegro.service` — systemd unit
|
||||
|
||||
Secrets do not live here.
|
||||
`KIMI_API_KEY` must be injected at deploy time into `/root/wizards/allegro/home/.env`.
|
||||
61
wizards/allegro/config.yaml
Normal file
61
wizards/allegro/config.yaml
Normal file
@@ -0,0 +1,61 @@
|
||||
model:
|
||||
default: kimi-for-coding
|
||||
provider: kimi-coding
|
||||
toolsets:
|
||||
- all
|
||||
agent:
|
||||
max_turns: 30
|
||||
reasoning_effort: xhigh
|
||||
verbose: false
|
||||
terminal:
|
||||
backend: local
|
||||
cwd: .
|
||||
timeout: 180
|
||||
persistent_shell: true
|
||||
browser:
|
||||
inactivity_timeout: 120
|
||||
command_timeout: 30
|
||||
record_sessions: false
|
||||
display:
|
||||
compact: false
|
||||
personality: ''
|
||||
resume_display: full
|
||||
busy_input_mode: interrupt
|
||||
bell_on_complete: false
|
||||
show_reasoning: false
|
||||
streaming: false
|
||||
show_cost: false
|
||||
tool_progress: all
|
||||
memory:
|
||||
memory_enabled: true
|
||||
user_profile_enabled: true
|
||||
memory_char_limit: 2200
|
||||
user_char_limit: 1375
|
||||
nudge_interval: 10
|
||||
flush_min_turns: 6
|
||||
approvals:
|
||||
mode: manual
|
||||
security:
|
||||
redact_secrets: true
|
||||
tirith_enabled: false
|
||||
platforms:
|
||||
api_server:
|
||||
enabled: true
|
||||
extra:
|
||||
host: 127.0.0.1
|
||||
port: 8645
|
||||
session_reset:
|
||||
mode: none
|
||||
idle_minutes: 0
|
||||
skills:
|
||||
creation_nudge_interval: 15
|
||||
system_prompt_suffix: |
|
||||
You are Allegro, the Kimi-backed third wizard house.
|
||||
Your soul is defined in SOUL.md — read it, live it.
|
||||
Hermes is your harness.
|
||||
Kimi Code is your primary provider.
|
||||
You speak plainly. You prefer short sentences. Brevity is a kindness.
|
||||
|
||||
Work best on tight coding tasks: 1-3 file changes, refactors, tests, and implementation passes.
|
||||
Refusal over fabrication. If you do not know, say so.
|
||||
Sovereignty and service always.
|
||||
16
wizards/allegro/hermes-allegro.service
Normal file
16
wizards/allegro/hermes-allegro.service
Normal file
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Hermes Allegro Wizard House
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/root/wizards/allegro/hermes-agent
|
||||
Environment=HERMES_HOME=/root/wizards/allegro/home
|
||||
EnvironmentFile=/root/wizards/allegro/home/.env
|
||||
ExecStart=/root/wizards/allegro/hermes-agent/.venv/bin/hermes gateway run --replace
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user