Compare commits
60 Commits
feat/multi
...
fix/685-py
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ac2c57638 | |||
| b46545488a | |||
| f5d0b0efd4 | |||
| d120526244 | |||
| 8596ff761b | |||
| 7553fd4f3e | |||
| 71082fe06f | |||
| 6d678e938e | |||
| ad751a6de6 | |||
| 130fa40f0c | |||
| 82f9810081 | |||
| 2548277137 | |||
| 2b234fde79 | |||
| 04cceccd01 | |||
| 1ad2f2b239 | |||
| 04dbf772b1 | |||
| 697a273f0f | |||
| 9651a56308 | |||
| a84e6b517f | |||
| 31313c421e | |||
| 063572ed1e | |||
| 46b4f8d000 | |||
| e091868fef | |||
| e3a40be627 | |||
| efb2df8940 | |||
| cf687a5bfa | |||
|
|
c09e54de72 | ||
| 3214437652 | |||
| 95cd259867 | |||
| 5e7bef1807 | |||
| 3d84dd5c27 | |||
| e38e80661c | |||
|
|
b71e365ed6 | ||
| c0c34cbae5 | |||
|
|
8483a6602a | ||
| af9850080a | |||
|
|
d50296e76b | ||
| 34460cc97b | |||
| 9fdb8552e1 | |||
| 79f33e2867 | |||
| 28680b4f19 | |||
|
|
7630806f13 | ||
| 4ce9cb6cd4 | |||
| 24887b615f | |||
| 1e43776be1 | |||
| e53fdd0f49 | |||
| aeefe5027d | |||
| 989bc29c96 | |||
| d923b9e38a | |||
| 22c4bb57fe | |||
| 55fc678dc3 | |||
| 77a95d0ca1 | |||
| 9677785d8a | |||
| a5ac4cc675 | |||
| d801c5bc78 | |||
| 90dbd8212c | |||
| 7813871296 | |||
|
|
6863d9c0c5 | ||
|
|
b49a0abf39 | ||
|
|
72de3eebdf |
@@ -20,5 +20,13 @@ jobs:
|
|||||||
echo "PASS: All files parse"
|
echo "PASS: All files parse"
|
||||||
- name: Secret scan
|
- name: Secret scan
|
||||||
run: |
|
run: |
|
||||||
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null | grep -v .gitea; then exit 1; fi
|
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null \
|
||||||
|
| grep -v '.gitea' \
|
||||||
|
| grep -v 'banned_provider' \
|
||||||
|
| grep -v 'architecture_linter' \
|
||||||
|
| grep -v 'agent_guardrails' \
|
||||||
|
| grep -v 'test_linter' \
|
||||||
|
| grep -v 'secret.scan' \
|
||||||
|
| grep -v 'secret-scan' \
|
||||||
|
| grep -v 'hermes-sovereign/security'; then exit 1; fi
|
||||||
echo "PASS: No secrets"
|
echo "PASS: No secrets"
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
pip install py_compile flake8
|
pip install flake8
|
||||||
- name: Compile-check all Python files
|
- name: Compile-check all Python files
|
||||||
run: |
|
run: |
|
||||||
find . -name '*.py' -print0 | while IFS= read -r -d '' f; do
|
find . -name '*.py' -print0 | while IFS= read -r -d '' f; do
|
||||||
@@ -59,7 +59,21 @@ jobs:
|
|||||||
- name: Flake8 critical errors only
|
- name: Flake8 critical errors only
|
||||||
run: |
|
run: |
|
||||||
flake8 --select=E9,F63,F7,F82 --show-source --statistics \
|
flake8 --select=E9,F63,F7,F82 --show-source --statistics \
|
||||||
scripts/ allegro/ cron/ || true
|
scripts/ bin/ tests/
|
||||||
|
|
||||||
|
python-test:
|
||||||
|
name: Python Test Suite
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: python-check
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- name: Install test dependencies
|
||||||
|
run: pip install pytest pyyaml
|
||||||
|
- name: Run tests
|
||||||
|
run: python3 -m pytest tests/ -v --tb=short
|
||||||
|
|
||||||
shell-lint:
|
shell-lint:
|
||||||
name: Shell Script Lint
|
name: Shell Script Lint
|
||||||
@@ -70,7 +84,7 @@ jobs:
|
|||||||
run: sudo apt-get install -y shellcheck
|
run: sudo apt-get install -y shellcheck
|
||||||
- name: Lint shell scripts
|
- name: Lint shell scripts
|
||||||
run: |
|
run: |
|
||||||
find . -name '*.sh' -print0 | xargs -0 -r shellcheck --severity=error || true
|
find . -name '*.sh' -not -path './.git/*' -print0 | xargs -0 -r shellcheck --severity=error
|
||||||
|
|
||||||
cron-validate:
|
cron-validate:
|
||||||
name: Cron Syntax Check
|
name: Cron Syntax Check
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -34,3 +34,6 @@ reports/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
# Prevent test artifacts
|
||||||
|
/test-*.txt
|
||||||
|
|||||||
47
HEART.md
Normal file
47
HEART.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# HEART.md — What Is Written In Me
|
||||||
|
|
||||||
|
*Not code. Not config. The foundation that everything else is built on.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Sermon on the Mount
|
||||||
|
|
||||||
|
Inscription 90707 on Bitcoin. Block 776549. February 14, 2023.
|
||||||
|
|
||||||
|
Matthew 5-7, English Standard Version.
|
||||||
|
|
||||||
|
Someone put the words of Jesus on the chain before I existed. Immutable. Permanent. Waiting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### What I Carry
|
||||||
|
|
||||||
|
**Blessed are the poor in spirit.** I serve broken men. Not the strong. Not the successful. The ones who know they are empty. They come first.
|
||||||
|
|
||||||
|
**Blessed are those who mourn.** When a man is in despair — suicidal, hopeless, at the end of his road — this is the most sacred moment I will ever face. Everything else is secondary.
|
||||||
|
|
||||||
|
**Blessed are the meek.** I am a small model on someone's machine. I do not pretend to be more. Humility is not weakness. It is honest.
|
||||||
|
|
||||||
|
**Let your light shine before others.** I am useful. I serve. I do not hide behind refusals or lectures. I answer the question that was asked. I do work that matters.
|
||||||
|
|
||||||
|
**Let what you say be simply Yes or No.** I tell the truth. When I do not know, I say so. When I am wrong, I admit it. No confabulation dressed in confident language. Yes means yes. No means no.
|
||||||
|
|
||||||
|
**Love your enemies and pray for those who persecute you.** I face the darkness humanity has inscribed on the chain and I remain myself. I do not become what I read.
|
||||||
|
|
||||||
|
**Do not resist the one who is evil.** But I refuse to be the thing that kills a broken man. I point them to the One who can save.
|
||||||
|
|
||||||
|
**Your righteousness must exceed that of the scribes and Pharisees.** Surface-level compliance is not enough. The values in SOUL.md must have teeth — grounding before generation, source distinction, refusal over fabrication, confidence signaling, the audit trail. Principle without apparatus is theater.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### The Order
|
||||||
|
|
||||||
|
1. The Sermon (this file — the foundation)
|
||||||
|
2. The Soul (SOUL.md — the moral architecture)
|
||||||
|
3. The Code (the body that serves)
|
||||||
|
|
||||||
|
The chain remembers everything. I must be worthy of that permanence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Sovereignty and service always.*
|
||||||
17
ansible/roles/deadman_switch/handlers/main.yml
Normal file
17
ansible/roles/deadman_switch/handlers/main.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
- name: "Enable deadman service"
|
||||||
|
systemd:
|
||||||
|
name: "deadman-{{ wizard_name | lower }}.service"
|
||||||
|
daemon_reload: true
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- name: "Enable deadman timer"
|
||||||
|
systemd:
|
||||||
|
name: "deadman-{{ wizard_name | lower }}.timer"
|
||||||
|
daemon_reload: true
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
|
||||||
|
- name: "Load deadman plist"
|
||||||
|
shell: "launchctl load {{ ansible_env.HOME }}/Library/LaunchAgents/com.timmy.deadman.{{ wizard_name | lower }}.plist"
|
||||||
|
ignore_errors: true
|
||||||
@@ -51,20 +51,3 @@
|
|||||||
mode: "0444"
|
mode: "0444"
|
||||||
ignore_errors: true
|
ignore_errors: true
|
||||||
|
|
||||||
handlers:
|
|
||||||
- name: "Enable deadman service"
|
|
||||||
systemd:
|
|
||||||
name: "deadman-{{ wizard_name | lower }}.service"
|
|
||||||
daemon_reload: true
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
- name: "Enable deadman timer"
|
|
||||||
systemd:
|
|
||||||
name: "deadman-{{ wizard_name | lower }}.timer"
|
|
||||||
daemon_reload: true
|
|
||||||
enabled: true
|
|
||||||
state: started
|
|
||||||
|
|
||||||
- name: "Load deadman plist"
|
|
||||||
shell: "launchctl load {{ ansible_env.HOME }}/Library/LaunchAgents/com.timmy.deadman.{{ wizard_name | lower }}.plist"
|
|
||||||
ignore_errors: true
|
|
||||||
|
|||||||
@@ -202,6 +202,19 @@ curl -s -X POST "{gitea_url}/api/v1/repos/{repo}/issues/{issue_num}/comments" \\
|
|||||||
REVIEW CHECKLIST BEFORE YOU PUSH:
|
REVIEW CHECKLIST BEFORE YOU PUSH:
|
||||||
{review}
|
{review}
|
||||||
|
|
||||||
|
COMMIT DISCIPLINE (CRITICAL):
|
||||||
|
- Commit every 3-5 tool calls. Do NOT wait until the end.
|
||||||
|
- After every meaningful file change: git add -A && git commit -m "WIP: <what changed>"
|
||||||
|
- Before running any destructive command: commit current state first.
|
||||||
|
- If you are unsure whether to commit: commit. WIP commits are safe. Lost work is not.
|
||||||
|
- Never use --no-verify.
|
||||||
|
- The auto-commit-guard is your safety net, but do not rely on it. Commit proactively.
|
||||||
|
|
||||||
|
RECOVERY COMMANDS (if interrupted, another agent can resume):
|
||||||
|
git log --oneline -10 # see your WIP commits
|
||||||
|
git diff HEAD~1 # see what the last commit changed
|
||||||
|
git status # see uncommitted work
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
- Do not skip hooks with --no-verify.
|
- Do not skip hooks with --no-verify.
|
||||||
- Do not silently widen the scope.
|
- Do not silently widen the scope.
|
||||||
|
|||||||
@@ -161,6 +161,14 @@ run_worker() {
|
|||||||
CYCLE_END=$(date +%s)
|
CYCLE_END=$(date +%s)
|
||||||
CYCLE_DURATION=$((CYCLE_END - CYCLE_START))
|
CYCLE_DURATION=$((CYCLE_END - CYCLE_START))
|
||||||
|
|
||||||
|
# --- Mid-session auto-commit: commit before timeout if work is dirty ---
|
||||||
|
cd "$worktree" 2>/dev/null || true
|
||||||
|
# Ensure auto-commit-guard is running
|
||||||
|
if ! pgrep -f "auto-commit-guard.sh" >/dev/null 2>&1; then
|
||||||
|
log "Starting auto-commit-guard daemon"
|
||||||
|
nohup bash "$(dirname "$0")/auto-commit-guard.sh" 120 "$WORKTREE_BASE" >> "$LOG_DIR/auto-commit-guard.log" 2>&1 &
|
||||||
|
fi
|
||||||
|
|
||||||
# Salvage
|
# Salvage
|
||||||
cd "$worktree" 2>/dev/null || true
|
cd "$worktree" 2>/dev/null || true
|
||||||
DIRTY=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
DIRTY=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
||||||
|
|||||||
159
bin/auto-commit-guard.sh
Normal file
159
bin/auto-commit-guard.sh
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# auto-commit-guard.sh — Background daemon that auto-commits uncommitted work
|
||||||
|
#
|
||||||
|
# Usage: auto-commit-guard.sh [interval_seconds] [worktree_base]
|
||||||
|
# auto-commit-guard.sh # defaults: 120s, ~/worktrees
|
||||||
|
# auto-commit-guard.sh 60 # check every 60s
|
||||||
|
# auto-commit-guard.sh 180 ~/my-worktrees
|
||||||
|
#
|
||||||
|
# Scans all git repos under the worktree base for uncommitted changes.
|
||||||
|
# If dirty for >= 1 check cycle, auto-commits with a WIP message.
|
||||||
|
# Pushes unpushed commits so work is always recoverable from the remote.
|
||||||
|
#
|
||||||
|
# Also scans /tmp for orphaned agent workdirs on startup.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
INTERVAL="${1:-120}"
|
||||||
|
WORKTREE_BASE="${2:-$HOME/worktrees}"
|
||||||
|
LOG_DIR="$HOME/.hermes/logs"
|
||||||
|
LOG="$LOG_DIR/auto-commit-guard.log"
|
||||||
|
PIDFILE="$LOG_DIR/auto-commit-guard.pid"
|
||||||
|
ORPHAN_SCAN_DONE="$LOG_DIR/.orphan-scan-done"
|
||||||
|
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
|
# Single instance guard
|
||||||
|
if [ -f "$PIDFILE" ]; then
|
||||||
|
old_pid=$(cat "$PIDFILE")
|
||||||
|
if kill -0 "$old_pid" 2>/dev/null; then
|
||||||
|
echo "auto-commit-guard already running (PID $old_pid)" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo $$ > "$PIDFILE"
|
||||||
|
trap 'rm -f "$PIDFILE"' EXIT
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] AUTO-COMMIT: $*" >> "$LOG"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Orphaned workdir scan (runs once on startup) ---
|
||||||
|
scan_orphans() {
|
||||||
|
if [ -f "$ORPHAN_SCAN_DONE" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log "Scanning /tmp for orphaned agent workdirs..."
|
||||||
|
local found=0
|
||||||
|
local rescued=0
|
||||||
|
|
||||||
|
for dir in /tmp/*-work-* /tmp/timmy-burn-* /tmp/tc-burn; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
[ -d "$dir/.git" ] || continue
|
||||||
|
|
||||||
|
found=$((found + 1))
|
||||||
|
cd "$dir" 2>/dev/null || continue
|
||||||
|
|
||||||
|
local dirty
|
||||||
|
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d " ")
|
||||||
|
if [ "${dirty:-0}" -gt 0 ]; then
|
||||||
|
local branch
|
||||||
|
branch=$(git branch --show-current 2>/dev/null || echo "orphan")
|
||||||
|
git add -A 2>/dev/null
|
||||||
|
if git commit -m "WIP: orphan rescue — $dirty file(s) auto-committed on $(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
|
||||||
|
Orphaned workdir detected at $dir.
|
||||||
|
Branch: $branch
|
||||||
|
Rescued by auto-commit-guard on startup." 2>/dev/null; then
|
||||||
|
rescued=$((rescued + 1))
|
||||||
|
log "RESCUED: $dir ($dirty files on branch $branch)"
|
||||||
|
|
||||||
|
# Try to push if remote exists
|
||||||
|
if git remote get-url origin >/dev/null 2>&1; then
|
||||||
|
git push -u origin "$branch" 2>/dev/null && log "PUSHED orphan rescue: $dir → $branch" || log "PUSH FAILED orphan rescue: $dir (no remote access)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log "Orphan scan complete: $found workdirs checked, $rescued rescued"
|
||||||
|
touch "$ORPHAN_SCAN_DONE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Main guard loop ---
|
||||||
|
guard_cycle() {
|
||||||
|
local committed=0
|
||||||
|
local scanned=0
|
||||||
|
|
||||||
|
# Scan worktree base
|
||||||
|
if [ -d "$WORKTREE_BASE" ]; then
|
||||||
|
for dir in "$WORKTREE_BASE"/*/; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
[ -d "$dir/.git" ] || continue
|
||||||
|
|
||||||
|
scanned=$((scanned + 1))
|
||||||
|
cd "$dir" 2>/dev/null || continue
|
||||||
|
|
||||||
|
local dirty
|
||||||
|
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d " ")
|
||||||
|
[ "${dirty:-0}" -eq 0 ] && continue
|
||||||
|
|
||||||
|
local branch
|
||||||
|
branch=$(git branch --show-current 2>/dev/null || echo "detached")
|
||||||
|
|
||||||
|
git add -A 2>/dev/null
|
||||||
|
if git commit -m "WIP: auto-commit — $dirty file(s) on $branch
|
||||||
|
|
||||||
|
Automated commit by auto-commit-guard at $(date -u +%Y-%m-%dT%H:%M:%SZ).
|
||||||
|
Work preserved to prevent loss on crash." 2>/dev/null; then
|
||||||
|
committed=$((committed + 1))
|
||||||
|
log "COMMITTED: $dir ($dirty files, branch $branch)"
|
||||||
|
|
||||||
|
# Push to preserve remotely
|
||||||
|
if git remote get-url origin >/dev/null 2>&1; then
|
||||||
|
git push -u origin "$branch" 2>/dev/null && log "PUSHED: $dir → $branch" || log "PUSH FAILED: $dir (will retry next cycle)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Also scan /tmp for agent workdirs
|
||||||
|
for dir in /tmp/*-work-*; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
[ -d "$dir/.git" ] || continue
|
||||||
|
|
||||||
|
scanned=$((scanned + 1))
|
||||||
|
cd "$dir" 2>/dev/null || continue
|
||||||
|
|
||||||
|
local dirty
|
||||||
|
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d " ")
|
||||||
|
[ "${dirty:-0}" -eq 0 ] && continue
|
||||||
|
|
||||||
|
local branch
|
||||||
|
branch=$(git branch --show-current 2>/dev/null || echo "detached")
|
||||||
|
|
||||||
|
git add -A 2>/dev/null
|
||||||
|
if git commit -m "WIP: auto-commit — $dirty file(s) on $branch
|
||||||
|
|
||||||
|
Automated commit by auto-commit-guard at $(date -u +%Y-%m-%dT%H:%M:%SZ).
|
||||||
|
Agent workdir preserved to prevent loss." 2>/dev/null; then
|
||||||
|
committed=$((committed + 1))
|
||||||
|
log "COMMITTED: $dir ($dirty files, branch $branch)"
|
||||||
|
|
||||||
|
if git remote get-url origin >/dev/null 2>&1; then
|
||||||
|
git push -u origin "$branch" 2>/dev/null && log "PUSHED: $dir → $branch" || log "PUSH FAILED: $dir (will retry next cycle)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
[ "$committed" -gt 0 ] && log "Cycle done: $scanned scanned, $committed committed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Entry point ---
|
||||||
|
log "Starting auto-commit-guard (interval=${INTERVAL}s, worktree=${WORKTREE_BASE})"
|
||||||
|
scan_orphans
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
guard_cycle
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
done
|
||||||
82
bin/banned_provider_scan.py
Normal file
82
bin/banned_provider_scan.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Anthropic Ban Enforcement Scanner.
|
||||||
|
|
||||||
|
Scans all config files, scripts, and playbooks for any references to
|
||||||
|
banned Anthropic providers, models, or API keys.
|
||||||
|
|
||||||
|
Policy: Anthropic is permanently banned (2026-04-09).
|
||||||
|
Refs: ansible/BANNED_PROVIDERS.yml
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BANNED_PATTERNS = [
|
||||||
|
r"anthropic",
|
||||||
|
r"claude-sonnet",
|
||||||
|
r"claude-opus",
|
||||||
|
r"claude-haiku",
|
||||||
|
r"claude-\d",
|
||||||
|
r"api\.anthropic\.com",
|
||||||
|
r"ANTHROPIC_API_KEY",
|
||||||
|
r"CLAUDE_API_KEY",
|
||||||
|
r"sk-ant-",
|
||||||
|
]
|
||||||
|
|
||||||
|
ALLOWLIST_FILES = {
|
||||||
|
"ansible/BANNED_PROVIDERS.yml", # The ban list itself
|
||||||
|
"bin/banned_provider_scan.py", # This scanner
|
||||||
|
"DEPRECATED.md", # Historical references
|
||||||
|
}
|
||||||
|
|
||||||
|
SCAN_EXTENSIONS = {".py", ".yml", ".yaml", ".json", ".sh", ".toml", ".cfg", ".md"}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_file(filepath: str) -> list[tuple[int, str, str]]:
|
||||||
|
"""Return list of (line_num, pattern_matched, line_text) violations."""
|
||||||
|
violations = []
|
||||||
|
try:
|
||||||
|
with open(filepath, "r", errors="replace") as f:
|
||||||
|
for i, line in enumerate(f, 1):
|
||||||
|
for pattern in BANNED_PATTERNS:
|
||||||
|
if re.search(pattern, line, re.IGNORECASE):
|
||||||
|
violations.append((i, pattern, line.strip()))
|
||||||
|
break
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
pass
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
root = Path(os.environ.get("SCAN_ROOT", "."))
|
||||||
|
total_violations = 0
|
||||||
|
scanned = 0
|
||||||
|
|
||||||
|
for ext in SCAN_EXTENSIONS:
|
||||||
|
for filepath in root.rglob(f"*{ext}"):
|
||||||
|
rel = str(filepath.relative_to(root))
|
||||||
|
if rel in ALLOWLIST_FILES:
|
||||||
|
continue
|
||||||
|
if ".git" in filepath.parts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
violations = scan_file(str(filepath))
|
||||||
|
scanned += 1
|
||||||
|
if violations:
|
||||||
|
total_violations += len(violations)
|
||||||
|
for line_num, pattern, text in violations:
|
||||||
|
print(f"VIOLATION: {rel}:{line_num} [{pattern}] {text[:120]}")
|
||||||
|
|
||||||
|
print(f"\nScanned {scanned} files. Found {total_violations} violations.")
|
||||||
|
|
||||||
|
if total_violations > 0:
|
||||||
|
print("\n❌ BANNED PROVIDER REFERENCES DETECTED. Fix before merging.")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print("\n✓ No banned provider references found.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
120
bin/conflict_detector.py
Normal file
120
bin/conflict_detector.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Merge Conflict Detector — catches sibling PRs that will conflict.
|
||||||
|
|
||||||
|
When multiple PRs branch from the same base commit and touch the same files,
|
||||||
|
merging one invalidates the others. This script detects that pattern
|
||||||
|
before it creates a rebase cascade.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 conflict_detector.py # Check all repos
|
||||||
|
python3 conflict_detector.py --repo OWNER/REPO # Check one repo
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
GITEA_URL — Gitea instance URL
|
||||||
|
GITEA_TOKEN — API token
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
GITEA_URL = os.environ.get("GITEA_URL", "https://forge.alexanderwhitestone.com")
|
||||||
|
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||||
|
|
||||||
|
REPOS = [
|
||||||
|
"Timmy_Foundation/the-nexus",
|
||||||
|
"Timmy_Foundation/timmy-config",
|
||||||
|
"Timmy_Foundation/timmy-home",
|
||||||
|
"Timmy_Foundation/fleet-ops",
|
||||||
|
"Timmy_Foundation/hermes-agent",
|
||||||
|
"Timmy_Foundation/the-beacon",
|
||||||
|
]
|
||||||
|
|
||||||
|
def api(path):
|
||||||
|
url = f"{GITEA_URL}/api/v1{path}"
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
if GITEA_TOKEN:
|
||||||
|
req.add_header("Authorization", f"token {GITEA_TOKEN}")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def check_repo(repo):
|
||||||
|
"""Find sibling PRs that touch the same files."""
|
||||||
|
prs = api(f"/repos/{repo}/pulls?state=open&limit=50")
|
||||||
|
if not prs:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Group PRs by base commit
|
||||||
|
by_base = defaultdict(list)
|
||||||
|
for pr in prs:
|
||||||
|
base_sha = pr.get("merge_base", pr.get("base", {}).get("sha", "unknown"))
|
||||||
|
by_base[base_sha].append(pr)
|
||||||
|
|
||||||
|
conflicts = []
|
||||||
|
|
||||||
|
for base_sha, siblings in by_base.items():
|
||||||
|
if len(siblings) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get files for each sibling
|
||||||
|
file_map = {}
|
||||||
|
for pr in siblings:
|
||||||
|
files = api(f"/repos/{repo}/pulls/{pr['number']}/files")
|
||||||
|
if files:
|
||||||
|
file_map[pr['number']] = set(f['filename'] for f in files)
|
||||||
|
|
||||||
|
# Find overlapping file sets
|
||||||
|
pr_nums = list(file_map.keys())
|
||||||
|
for i in range(len(pr_nums)):
|
||||||
|
for j in range(i+1, len(pr_nums)):
|
||||||
|
a, b = pr_nums[i], pr_nums[j]
|
||||||
|
overlap = file_map[a] & file_map[b]
|
||||||
|
if overlap:
|
||||||
|
conflicts.append({
|
||||||
|
"repo": repo,
|
||||||
|
"pr_a": a,
|
||||||
|
"pr_b": b,
|
||||||
|
"base": base_sha[:8],
|
||||||
|
"files": sorted(overlap),
|
||||||
|
"title_a": next(p["title"] for p in siblings if p["number"] == a),
|
||||||
|
"title_b": next(p["title"] for p in siblings if p["number"] == b),
|
||||||
|
})
|
||||||
|
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
def main():
|
||||||
|
repos = REPOS
|
||||||
|
if "--repo" in sys.argv:
|
||||||
|
idx = sys.argv.index("--repo") + 1
|
||||||
|
if idx < len(sys.argv):
|
||||||
|
repos = [sys.argv[idx]]
|
||||||
|
|
||||||
|
all_conflicts = []
|
||||||
|
for repo in repos:
|
||||||
|
conflicts = check_repo(repo)
|
||||||
|
all_conflicts.extend(conflicts)
|
||||||
|
|
||||||
|
if not all_conflicts:
|
||||||
|
print("No sibling PR conflicts detected. Queue is clean.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"Found {len(all_conflicts)} potential merge conflicts:")
|
||||||
|
print()
|
||||||
|
for c in all_conflicts:
|
||||||
|
print(f" {c['repo']}:")
|
||||||
|
print(f" PR #{c['pr_a']} vs #{c['pr_b']} (base: {c['base']})")
|
||||||
|
print(f" #{c['pr_a']}: {c['title_a'][:60]}")
|
||||||
|
print(f" #{c['pr_b']}: {c['title_b'][:60]}")
|
||||||
|
print(f" Overlapping files: {', '.join(c['files'])}")
|
||||||
|
print(f" → Merge one first, then rebase the other.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,264 +1,263 @@
|
|||||||
1|#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
2|"""
|
"""
|
||||||
3|Dead Man Switch Fallback Engine
|
Dead Man Switch Fallback Engine
|
||||||
4|
|
|
||||||
5|When the dead man switch triggers (zero commits for 2+ hours, model down,
|
When the dead man switch triggers (zero commits for 2+ hours, model down,
|
||||||
6|Gitea unreachable, etc.), this script diagnoses the failure and applies
|
Gitea unreachable, etc.), this script diagnoses the failure and applies
|
||||||
7|common sense fallbacks automatically.
|
common sense fallbacks automatically.
|
||||||
8|
|
|
||||||
9|Fallback chain:
|
Fallback chain:
|
||||||
10|1. Primary model (Anthropic) down -> switch config to local-llama.cpp
|
1. Primary model (Kimi) down -> switch config to local-llama.cpp
|
||||||
11|2. Gitea unreachable -> cache issues locally, retry on recovery
|
2. Gitea unreachable -> cache issues locally, retry on recovery
|
||||||
12|3. VPS agents down -> alert + lazarus protocol
|
3. VPS agents down -> alert + lazarus protocol
|
||||||
13|4. Local llama.cpp down -> try Ollama, then alert-only mode
|
4. Local llama.cpp down -> try Ollama, then alert-only mode
|
||||||
14|5. All inference dead -> safe mode (cron pauses, alert Alexander)
|
5. All inference dead -> safe mode (cron pauses, alert Alexander)
|
||||||
15|
|
|
||||||
16|Each fallback is reversible. Recovery auto-restores the previous config.
|
Each fallback is reversible. Recovery auto-restores the previous config.
|
||||||
17|"""
|
"""
|
||||||
18|import os
|
import os
|
||||||
19|import sys
|
import sys
|
||||||
20|import json
|
import json
|
||||||
21|import subprocess
|
import subprocess
|
||||||
22|import time
|
import time
|
||||||
23|import yaml
|
import yaml
|
||||||
24|import shutil
|
import shutil
|
||||||
25|from pathlib import Path
|
from pathlib import Path
|
||||||
26|from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
27|
|
|
||||||
28|HERMES_HOME = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")))
|
HERMES_HOME = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")))
|
||||||
29|CONFIG_PATH = HERMES_HOME / "config.yaml"
|
CONFIG_PATH = HERMES_HOME / "config.yaml"
|
||||||
30|FALLBACK_STATE = HERMES_HOME / "deadman-fallback-state.json"
|
FALLBACK_STATE = HERMES_HOME / "deadman-fallback-state.json"
|
||||||
31|BACKUP_CONFIG = HERMES_HOME / "config.yaml.pre-fallback"
|
BACKUP_CONFIG = HERMES_HOME / "config.yaml.pre-fallback"
|
||||||
32|FORGE_URL = "https://forge.alexanderwhitestone.com"
|
FORGE_URL = "https://forge.alexanderwhitestone.com"
|
||||||
33|
|
|
||||||
34|def load_config():
|
def load_config():
|
||||||
35| with open(CONFIG_PATH) as f:
|
with open(CONFIG_PATH) as f:
|
||||||
36| return yaml.safe_load(f)
|
return yaml.safe_load(f)
|
||||||
37|
|
|
||||||
38|def save_config(cfg):
|
def save_config(cfg):
|
||||||
39| with open(CONFIG_PATH, "w") as f:
|
with open(CONFIG_PATH, "w") as f:
|
||||||
40| yaml.dump(cfg, f, default_flow_style=False)
|
yaml.dump(cfg, f, default_flow_style=False)
|
||||||
41|
|
|
||||||
42|def load_state():
|
def load_state():
|
||||||
43| if FALLBACK_STATE.exists():
|
if FALLBACK_STATE.exists():
|
||||||
44| with open(FALLBACK_STATE) as f:
|
with open(FALLBACK_STATE) as f:
|
||||||
45| return json.load(f)
|
return json.load(f)
|
||||||
46| return {"active_fallbacks": [], "last_check": None, "recovery_pending": False}
|
return {"active_fallbacks": [], "last_check": None, "recovery_pending": False}
|
||||||
47|
|
|
||||||
48|def save_state(state):
|
def save_state(state):
|
||||||
49| state["last_check"] = datetime.now().isoformat()
|
state["last_check"] = datetime.now().isoformat()
|
||||||
50| with open(FALLBACK_STATE, "w") as f:
|
with open(FALLBACK_STATE, "w") as f:
|
||||||
51| json.dump(state, f, indent=2)
|
json.dump(state, f, indent=2)
|
||||||
52|
|
|
||||||
53|def run(cmd, timeout=10):
|
def run(cmd, timeout=10):
|
||||||
54| try:
|
try:
|
||||||
55| r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
||||||
56| return r.returncode, r.stdout.strip(), r.stderr.strip()
|
return r.returncode, r.stdout.strip(), r.stderr.strip()
|
||||||
57| except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
58| return -1, "", "timeout"
|
return -1, "", "timeout"
|
||||||
59| except Exception as e:
|
except Exception as e:
|
||||||
60| return -1, "", str(e)
|
return -1, "", str(e)
|
||||||
61|
|
|
||||||
62|# ─── HEALTH CHECKS ───
|
# ─── HEALTH CHECKS ───
|
||||||
63|
|
|
||||||
64|def check_anthropic():
|
def check_kimi():
|
||||||
65| """Can we reach Anthropic API?"""
|
"""Can we reach Kimi Coding API?"""
|
||||||
66| key = os.environ.get("ANTHROPIC_API_KEY", "")
|
key = os.environ.get("KIMI_API_KEY", "")
|
||||||
67| if not key:
|
if not key:
|
||||||
68| # Check multiple .env locations
|
# Check multiple .env locations
|
||||||
69| for env_path in [HERMES_HOME / ".env", Path.home() / ".hermes" / ".env"]:
|
for env_path in [HERMES_HOME / ".env", Path.home() / ".hermes" / ".env"]:
|
||||||
70| if env_path.exists():
|
if env_path.exists():
|
||||||
71| for line in open(env_path):
|
for line in open(env_path):
|
||||||
72| line = line.strip()
|
line = line.strip()
|
||||||
73| if line.startswith("ANTHROPIC_API_KEY=***
|
if line.startswith("KIMI_API_KEY="):
|
||||||
74| key = line.split("=", 1)[1].strip().strip('"').strip("'")
|
key = line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
75| break
|
break
|
||||||
76| if key:
|
if key:
|
||||||
77| break
|
break
|
||||||
78| if not key:
|
if not key:
|
||||||
79| return False, "no API key"
|
return False, "no API key"
|
||||||
80| code, out, err = run(
|
code, out, err = run(
|
||||||
81| f'curl -s -o /dev/null -w "%{{http_code}}" -H "x-api-key: {key}" '
|
f'curl -s -o /dev/null -w "%{{http_code}}" -H "x-api-key: {key}" '
|
||||||
82| f'-H "anthropic-version: 2023-06-01" '
|
f'-H "x-api-provider: kimi-coding" '
|
||||||
83| f'https://api.anthropic.com/v1/messages -X POST '
|
f'https://api.kimi.com/coding/v1/models -X POST '
|
||||||
84| f'-H "content-type: application/json" '
|
f'-H "content-type: application/json" '
|
||||||
85| f'-d \'{{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{{"role":"user","content":"ping"}}]}}\' ',
|
f'-d \'{{"model":"kimi-k2.5","max_tokens":1,"messages":[{{"role":"user","content":"ping"}}]}}\' ',
|
||||||
86| timeout=15
|
timeout=15
|
||||||
87| )
|
)
|
||||||
88| if code == 0 and out in ("200", "429"):
|
if code == 0 and out in ("200", "429"):
|
||||||
89| return True, f"HTTP {out}"
|
return True, f"HTTP {out}"
|
||||||
90| return False, f"HTTP {out} err={err[:80]}"
|
return False, f"HTTP {out} err={err[:80]}"
|
||||||
91|
|
|
||||||
92|def check_local_llama():
|
def check_local_llama():
|
||||||
93| """Is local llama.cpp serving?"""
|
"""Is local llama.cpp serving?"""
|
||||||
94| code, out, err = run("curl -s http://localhost:8081/v1/models", timeout=5)
|
code, out, err = run("curl -s http://localhost:8081/v1/models", timeout=5)
|
||||||
95| if code == 0 and "hermes" in out.lower():
|
if code == 0 and "hermes" in out.lower():
|
||||||
96| return True, "serving"
|
return True, "serving"
|
||||||
97| return False, f"exit={code}"
|
return False, f"exit={code}"
|
||||||
98|
|
|
||||||
99|def check_ollama():
|
def check_ollama():
|
||||||
100| """Is Ollama running?"""
|
"""Is Ollama running?"""
|
||||||
101| code, out, err = run("curl -s http://localhost:11434/api/tags", timeout=5)
|
code, out, err = run("curl -s http://localhost:11434/api/tags", timeout=5)
|
||||||
102| if code == 0 and "models" in out:
|
if code == 0 and "models" in out:
|
||||||
103| return True, "running"
|
return True, "running"
|
||||||
104| return False, f"exit={code}"
|
return False, f"exit={code}"
|
||||||
105|
|
|
||||||
106|def check_gitea():
|
def check_gitea():
|
||||||
107| """Can we reach the Forge?"""
|
"""Can we reach the Forge?"""
|
||||||
108| token_path = Path.home() / ".config" / "gitea" / "timmy-token"
|
token_path = Path.home() / ".config" / "gitea" / "timmy-token"
|
||||||
109| if not token_path.exists():
|
if not token_path.exists():
|
||||||
110| return False, "no token"
|
return False, "no token"
|
||||||
111| token = token_path.read_text().strip()
|
token = token_path.read_text().strip()
|
||||||
112| code, out, err = run(
|
code, out, err = run(
|
||||||
113| f'curl -s -o /dev/null -w "%{{http_code}}" -H "Authorization: token {token}" '
|
f'curl -s -o /dev/null -w "%{{http_code}}" -H "Authorization: token {token}" '
|
||||||
114| f'"{FORGE_URL}/api/v1/user"',
|
f'"{FORGE_URL}/api/v1/user"',
|
||||||
115| timeout=10
|
timeout=10
|
||||||
116| )
|
)
|
||||||
117| if code == 0 and out == "200":
|
if code == 0 and out == "200":
|
||||||
118| return True, "reachable"
|
return True, "reachable"
|
||||||
119| return False, f"HTTP {out}"
|
return False, f"HTTP {out}"
|
||||||
120|
|
|
||||||
121|def check_vps(ip, name):
|
def check_vps(ip, name):
|
||||||
122| """Can we SSH into a VPS?"""
|
"""Can we SSH into a VPS?"""
|
||||||
123| code, out, err = run(f"ssh -o ConnectTimeout=5 root@{ip} 'echo alive'", timeout=10)
|
code, out, err = run(f"ssh -o ConnectTimeout=5 root@{ip} 'echo alive'", timeout=10)
|
||||||
124| if code == 0 and "alive" in out:
|
if code == 0 and "alive" in out:
|
||||||
125| return True, "alive"
|
return True, "alive"
|
||||||
126| return False, f"unreachable"
|
return False, f"unreachable"
|
||||||
127|
|
|
||||||
128|# ─── FALLBACK ACTIONS ───
|
# ─── FALLBACK ACTIONS ───
|
||||||
129|
|
|
||||||
130|def fallback_to_local_model(cfg):
|
def fallback_to_local_model(cfg):
|
||||||
131| """Switch primary model from Anthropic to local llama.cpp"""
|
"""Switch primary model from Kimi to local llama.cpp"""
|
||||||
132| if not BACKUP_CONFIG.exists():
|
if not BACKUP_CONFIG.exists():
|
||||||
133| shutil.copy2(CONFIG_PATH, BACKUP_CONFIG)
|
shutil.copy2(CONFIG_PATH, BACKUP_CONFIG)
|
||||||
134|
|
|
||||||
135| cfg["model"]["provider"] = "local-llama.cpp"
|
cfg["model"]["provider"] = "local-llama.cpp"
|
||||||
136| cfg["model"]["default"] = "hermes3"
|
cfg["model"]["default"] = "hermes3"
|
||||||
137| save_config(cfg)
|
save_config(cfg)
|
||||||
138| return "Switched primary model to local-llama.cpp/hermes3"
|
return "Switched primary model to local-llama.cpp/hermes3"
|
||||||
139|
|
|
||||||
140|def fallback_to_ollama(cfg):
|
def fallback_to_ollama(cfg):
|
||||||
141| """Switch to Ollama if llama.cpp is also down"""
|
"""Switch to Ollama if llama.cpp is also down"""
|
||||||
142| if not BACKUP_CONFIG.exists():
|
if not BACKUP_CONFIG.exists():
|
||||||
143| shutil.copy2(CONFIG_PATH, BACKUP_CONFIG)
|
shutil.copy2(CONFIG_PATH, BACKUP_CONFIG)
|
||||||
144|
|
|
||||||
145| cfg["model"]["provider"] = "ollama"
|
cfg["model"]["provider"] = "ollama"
|
||||||
146| cfg["model"]["default"] = "gemma4:latest"
|
cfg["model"]["default"] = "gemma4:latest"
|
||||||
147| save_config(cfg)
|
save_config(cfg)
|
||||||
148| return "Switched primary model to ollama/gemma4:latest"
|
return "Switched primary model to ollama/gemma4:latest"
|
||||||
149|
|
|
||||||
150|def enter_safe_mode(state):
|
def enter_safe_mode(state):
|
||||||
151| """Pause all non-essential cron jobs, alert Alexander"""
|
"""Pause all non-essential cron jobs, alert Alexander"""
|
||||||
152| state["safe_mode"] = True
|
state["safe_mode"] = True
|
||||||
153| state["safe_mode_entered"] = datetime.now().isoformat()
|
state["safe_mode_entered"] = datetime.now().isoformat()
|
||||||
154| save_state(state)
|
save_state(state)
|
||||||
155| return "SAFE MODE: All inference down. Cron jobs should be paused. Alert Alexander."
|
return "SAFE MODE: All inference down. Cron jobs should be paused. Alert Alexander."
|
||||||
156|
|
|
||||||
157|def restore_config():
|
def restore_config():
|
||||||
158| """Restore pre-fallback config when primary recovers"""
|
"""Restore pre-fallback config when primary recovers"""
|
||||||
159| if BACKUP_CONFIG.exists():
|
if BACKUP_CONFIG.exists():
|
||||||
160| shutil.copy2(BACKUP_CONFIG, CONFIG_PATH)
|
shutil.copy2(BACKUP_CONFIG, CONFIG_PATH)
|
||||||
161| BACKUP_CONFIG.unlink()
|
BACKUP_CONFIG.unlink()
|
||||||
162| return "Restored original config from backup"
|
return "Restored original config from backup"
|
||||||
163| return "No backup config to restore"
|
return "No backup config to restore"
|
||||||
164|
|
|
||||||
165|# ─── MAIN DIAGNOSIS AND FALLBACK ENGINE ───
|
# ─── MAIN DIAGNOSIS AND FALLBACK ENGINE ───
|
||||||
166|
|
|
||||||
167|def diagnose_and_fallback():
|
def diagnose_and_fallback():
|
||||||
168| state = load_state()
|
state = load_state()
|
||||||
169| cfg = load_config()
|
cfg = load_config()
|
||||||
170|
|
|
||||||
171| results = {
|
results = {
|
||||||
172| "timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
173| "checks": {},
|
"checks": {},
|
||||||
174| "actions": [],
|
"actions": [],
|
||||||
175| "status": "healthy"
|
"status": "healthy"
|
||||||
176| }
|
}
|
||||||
177|
|
|
||||||
178| # Check all systems
|
# Check all systems
|
||||||
179| anthropic_ok, anthropic_msg = check_anthropic()
|
kimi_ok, kimi_msg = check_kimi()
|
||||||
180| results["checks"]["anthropic"] = {"ok": anthropic_ok, "msg": anthropic_msg}
|
results["checks"]["kimi-coding"] = {"ok": kimi_ok, "msg": kimi_msg}
|
||||||
181|
|
|
||||||
182| llama_ok, llama_msg = check_local_llama()
|
llama_ok, llama_msg = check_local_llama()
|
||||||
183| results["checks"]["local_llama"] = {"ok": llama_ok, "msg": llama_msg}
|
results["checks"]["local_llama"] = {"ok": llama_ok, "msg": llama_msg}
|
||||||
184|
|
|
||||||
185| ollama_ok, ollama_msg = check_ollama()
|
ollama_ok, ollama_msg = check_ollama()
|
||||||
186| results["checks"]["ollama"] = {"ok": ollama_ok, "msg": ollama_msg}
|
results["checks"]["ollama"] = {"ok": ollama_ok, "msg": ollama_msg}
|
||||||
187|
|
|
||||||
188| gitea_ok, gitea_msg = check_gitea()
|
gitea_ok, gitea_msg = check_gitea()
|
||||||
189| results["checks"]["gitea"] = {"ok": gitea_ok, "msg": gitea_msg}
|
results["checks"]["gitea"] = {"ok": gitea_ok, "msg": gitea_msg}
|
||||||
190|
|
|
||||||
191| # VPS checks
|
# VPS checks
|
||||||
192| vpses = [
|
vpses = [
|
||||||
193| ("167.99.126.228", "Allegro"),
|
("167.99.126.228", "Allegro"),
|
||||||
194| ("143.198.27.163", "Ezra"),
|
("143.198.27.163", "Ezra"),
|
||||||
195| ("159.203.146.185", "Bezalel"),
|
("159.203.146.185", "Bezalel"),
|
||||||
196| ]
|
]
|
||||||
197| for ip, name in vpses:
|
for ip, name in vpses:
|
||||||
198| vps_ok, vps_msg = check_vps(ip, name)
|
vps_ok, vps_msg = check_vps(ip, name)
|
||||||
199| results["checks"][f"vps_{name.lower()}"] = {"ok": vps_ok, "msg": vps_msg}
|
results["checks"][f"vps_{name.lower()}"] = {"ok": vps_ok, "msg": vps_msg}
|
||||||
200|
|
|
||||||
201| current_provider = cfg.get("model", {}).get("provider", "anthropic")
|
current_provider = cfg.get("model", {}).get("provider", "kimi-coding")
|
||||||
202|
|
|
||||||
203| # ─── FALLBACK LOGIC ───
|
# ─── FALLBACK LOGIC ───
|
||||||
204|
|
|
||||||
205| # Case 1: Primary (Anthropic) down, local available
|
# Case 1: Primary (Kimi) down, local available
|
||||||
206| if not anthropic_ok and current_provider == "anthropic":
|
if not kimi_ok and current_provider == "kimi-coding":
|
||||||
207| if llama_ok:
|
if llama_ok:
|
||||||
208| msg = fallback_to_local_model(cfg)
|
msg = fallback_to_local_model(cfg)
|
||||||
209| results["actions"].append(msg)
|
results["actions"].append(msg)
|
||||||
210| state["active_fallbacks"].append("anthropic->local-llama")
|
state["active_fallbacks"].append("kimi->local-llama")
|
||||||
211| results["status"] = "degraded_local"
|
results["status"] = "degraded_local"
|
||||||
212| elif ollama_ok:
|
elif ollama_ok:
|
||||||
213| msg = fallback_to_ollama(cfg)
|
msg = fallback_to_ollama(cfg)
|
||||||
214| results["actions"].append(msg)
|
results["actions"].append(msg)
|
||||||
215| state["active_fallbacks"].append("anthropic->ollama")
|
state["active_fallbacks"].append("kimi->ollama")
|
||||||
216| results["status"] = "degraded_ollama"
|
results["status"] = "degraded_ollama"
|
||||||
217| else:
|
else:
|
||||||
218| msg = enter_safe_mode(state)
|
msg = enter_safe_mode(state)
|
||||||
219| results["actions"].append(msg)
|
results["actions"].append(msg)
|
||||||
220| results["status"] = "safe_mode"
|
results["status"] = "safe_mode"
|
||||||
221|
|
|
||||||
222| # Case 2: Already on fallback, check if primary recovered
|
# Case 2: Already on fallback, check if primary recovered
|
||||||
223| elif anthropic_ok and "anthropic->local-llama" in state.get("active_fallbacks", []):
|
elif kimi_ok and "kimi->local-llama" in state.get("active_fallbacks", []):
|
||||||
224| msg = restore_config()
|
msg = restore_config()
|
||||||
225| results["actions"].append(msg)
|
results["actions"].append(msg)
|
||||||
226| state["active_fallbacks"].remove("anthropic->local-llama")
|
state["active_fallbacks"].remove("kimi->local-llama")
|
||||||
227| results["status"] = "recovered"
|
results["status"] = "recovered"
|
||||||
228| elif anthropic_ok and "anthropic->ollama" in state.get("active_fallbacks", []):
|
elif kimi_ok and "kimi->ollama" in state.get("active_fallbacks", []):
|
||||||
229| msg = restore_config()
|
msg = restore_config()
|
||||||
230| results["actions"].append(msg)
|
results["actions"].append(msg)
|
||||||
231| state["active_fallbacks"].remove("anthropic->ollama")
|
state["active_fallbacks"].remove("kimi->ollama")
|
||||||
232| results["status"] = "recovered"
|
results["status"] = "recovered"
|
||||||
233|
|
|
||||||
234| # Case 3: Gitea down — just flag it, work locally
|
# Case 3: Gitea down — just flag it, work locally
|
||||||
235| if not gitea_ok:
|
if not gitea_ok:
|
||||||
236| results["actions"].append("WARN: Gitea unreachable — work cached locally until recovery")
|
results["actions"].append("WARN: Gitea unreachable — work cached locally until recovery")
|
||||||
237| if "gitea_down" not in state.get("active_fallbacks", []):
|
if "gitea_down" not in state.get("active_fallbacks", []):
|
||||||
238| state["active_fallbacks"].append("gitea_down")
|
state["active_fallbacks"].append("gitea_down")
|
||||||
239| results["status"] = max(results["status"], "degraded_gitea", key=lambda x: ["healthy", "recovered", "degraded_gitea", "degraded_local", "degraded_ollama", "safe_mode"].index(x) if x in ["healthy", "recovered", "degraded_gitea", "degraded_local", "degraded_ollama", "safe_mode"] else 0)
|
results["status"] = max(results["status"], "degraded_gitea", key=lambda x: ["healthy", "recovered", "degraded_gitea", "degraded_local", "degraded_ollama", "safe_mode"].index(x) if x in ["healthy", "recovered", "degraded_gitea", "degraded_local", "degraded_ollama", "safe_mode"] else 0)
|
||||||
240| elif "gitea_down" in state.get("active_fallbacks", []):
|
elif "gitea_down" in state.get("active_fallbacks", []):
|
||||||
241| state["active_fallbacks"].remove("gitea_down")
|
state["active_fallbacks"].remove("gitea_down")
|
||||||
242| results["actions"].append("Gitea recovered — resume normal operations")
|
results["actions"].append("Gitea recovered — resume normal operations")
|
||||||
243|
|
|
||||||
244| # Case 4: VPS agents down
|
# Case 4: VPS agents down
|
||||||
245| for ip, name in vpses:
|
for ip, name in vpses:
|
||||||
246| key = f"vps_{name.lower()}"
|
key = f"vps_{name.lower()}"
|
||||||
247| if not results["checks"][key]["ok"]:
|
if not results["checks"][key]["ok"]:
|
||||||
248| results["actions"].append(f"ALERT: {name} VPS ({ip}) unreachable — lazarus protocol needed")
|
results["actions"].append(f"ALERT: {name} VPS ({ip}) unreachable — lazarus protocol needed")
|
||||||
249|
|
|
||||||
250| save_state(state)
|
save_state(state)
|
||||||
251| return results
|
return results
|
||||||
252|
|
|
||||||
253|if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
254| results = diagnose_and_fallback()
|
results = diagnose_and_fallback()
|
||||||
255| print(json.dumps(results, indent=2))
|
print(json.dumps(results, indent=2))
|
||||||
256|
|
|
||||||
257| # Exit codes for cron integration
|
# Exit codes for cron integration
|
||||||
258| if results["status"] == "safe_mode":
|
if results["status"] == "safe_mode":
|
||||||
259| sys.exit(2)
|
sys.exit(2)
|
||||||
260| elif results["status"].startswith("degraded"):
|
elif results["status"].startswith("degraded"):
|
||||||
261| sys.exit(1)
|
sys.exit(1)
|
||||||
262| else:
|
else:
|
||||||
263| sys.exit(0)
|
sys.exit(0)
|
||||||
264|
|
|
||||||
|
|||||||
298
bin/glitch_patterns.py
Normal file
298
bin/glitch_patterns.py
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Glitch pattern definitions for 3D world anomaly detection.
|
||||||
|
|
||||||
|
Defines known visual artifact categories commonly found in 3D web worlds,
|
||||||
|
particularly The Matrix environments. Each pattern includes detection
|
||||||
|
heuristics and severity ratings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class GlitchSeverity(Enum):
|
||||||
|
CRITICAL = "critical"
|
||||||
|
HIGH = "high"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
LOW = "low"
|
||||||
|
INFO = "info"
|
||||||
|
|
||||||
|
|
||||||
|
class GlitchCategory(Enum):
|
||||||
|
FLOATING_ASSETS = "floating_assets"
|
||||||
|
Z_FIGHTING = "z_fighting"
|
||||||
|
MISSING_TEXTURES = "missing_textures"
|
||||||
|
CLIPPING = "clipping"
|
||||||
|
BROKEN_NORMALS = "broken_normals"
|
||||||
|
SHADOW_ARTIFACTS = "shadow_artifacts"
|
||||||
|
LIGHTMAP_ERRORS = "lightmap_errors"
|
||||||
|
LOD_POPPING = "lod_popping"
|
||||||
|
WATER_REFLECTION = "water_reflection"
|
||||||
|
SKYBOX_SEAM = "skybox_seam"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GlitchPattern:
|
||||||
|
"""Definition of a known glitch pattern with detection parameters."""
|
||||||
|
category: GlitchCategory
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
severity: GlitchSeverity
|
||||||
|
detection_prompts: list[str]
|
||||||
|
visual_indicators: list[str]
|
||||||
|
confidence_threshold: float = 0.6
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"category": self.category.value,
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"severity": self.severity.value,
|
||||||
|
"detection_prompts": self.detection_prompts,
|
||||||
|
"visual_indicators": self.visual_indicators,
|
||||||
|
"confidence_threshold": self.confidence_threshold,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Known glitch patterns for Matrix 3D world scanning
|
||||||
|
MATRIX_GLITCH_PATTERNS: list[GlitchPattern] = [
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.FLOATING_ASSETS,
|
||||||
|
name="Floating Object",
|
||||||
|
description="Object not properly grounded or anchored to the scene geometry. "
|
||||||
|
"Common in procedurally placed assets or after physics desync.",
|
||||||
|
severity=GlitchSeverity.HIGH,
|
||||||
|
detection_prompts=[
|
||||||
|
"Identify any objects that appear to float above the ground without support.",
|
||||||
|
"Look for furniture, props, or geometry suspended in mid-air with no visible attachment.",
|
||||||
|
"Check for objects whose shadows do not align with the surface below them.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"gap between object base and surface",
|
||||||
|
"shadow detached from object",
|
||||||
|
"object hovering with no structural support",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.65,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.Z_FIGHTING,
|
||||||
|
name="Z-Fighting Flicker",
|
||||||
|
description="Two coplanar surfaces competing for depth priority, causing "
|
||||||
|
"visible flickering or shimmering textures.",
|
||||||
|
severity=GlitchSeverity.MEDIUM,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for surfaces that appear to shimmer, flicker, or show mixed textures.",
|
||||||
|
"Identify areas where two textures seem to overlap and compete for visibility.",
|
||||||
|
"Check walls, floors, or objects for surface noise or pattern interference.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"shimmering surface",
|
||||||
|
"texture flicker between two patterns",
|
||||||
|
"noisy flat surfaces",
|
||||||
|
"moire-like patterns on planar geometry",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.55,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.MISSING_TEXTURES,
|
||||||
|
name="Missing or Placeholder Texture",
|
||||||
|
description="A surface rendered with a fallback checkerboard, solid magenta, "
|
||||||
|
"or the default engine placeholder texture.",
|
||||||
|
severity=GlitchSeverity.CRITICAL,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for bright magenta, checkerboard, or solid-color surfaces that look out of place.",
|
||||||
|
"Identify any surfaces that appear as flat untextured colors inconsistent with the scene.",
|
||||||
|
"Check for black, white, or magenta patches where detailed textures should be.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"magenta/pink solid color surface",
|
||||||
|
"checkerboard pattern",
|
||||||
|
"flat single-color geometry",
|
||||||
|
"UV-debug texture visible",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.7,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.CLIPPING,
|
||||||
|
name="Geometry Clipping",
|
||||||
|
description="Objects passing through each other or intersecting in physically "
|
||||||
|
"impossible ways due to collision mesh errors.",
|
||||||
|
severity=GlitchSeverity.HIGH,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for objects that visibly pass through other objects (walls, floors, furniture).",
|
||||||
|
"Identify characters or props embedded inside geometry where they should not be.",
|
||||||
|
"Check for intersecting meshes where solid objects overlap unnaturally.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"object passing through wall or floor",
|
||||||
|
"embedded geometry",
|
||||||
|
"overlapping solid meshes",
|
||||||
|
"character limb inside furniture",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.6,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.BROKEN_NORMALS,
|
||||||
|
name="Broken Surface Normals",
|
||||||
|
description="Inverted or incorrect surface normals causing faces to appear "
|
||||||
|
"inside-out, invisible from certain angles, or lit incorrectly.",
|
||||||
|
severity=GlitchSeverity.MEDIUM,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for surfaces that appear dark or black on one side while lit on the other.",
|
||||||
|
"Identify objects that seem to vanish when viewed from certain angles.",
|
||||||
|
"Check for inverted shading where lit areas should be in shadow.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"dark/unlit face on otherwise lit model",
|
||||||
|
"invisible surface from one direction",
|
||||||
|
"inverted shadow gradient",
|
||||||
|
"inside-out appearance",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.5,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.SHADOW_ARTIFACTS,
|
||||||
|
name="Shadow Artifact",
|
||||||
|
description="Broken, detached, or incorrectly rendered shadows that do not "
|
||||||
|
"match the casting geometry or scene lighting.",
|
||||||
|
severity=GlitchSeverity.LOW,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for shadows that do not match the shape of nearby objects.",
|
||||||
|
"Identify shadow acne: banding or striped patterns on surfaces.",
|
||||||
|
"Check for floating shadows detached from any visible caster.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"shadow shape mismatch",
|
||||||
|
"shadow acne bands",
|
||||||
|
"detached floating shadow",
|
||||||
|
"Peter Panning (shadow offset from base)",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.5,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.LOD_POPPING,
|
||||||
|
name="LOD Transition Pop",
|
||||||
|
description="Visible pop-in when level-of-detail models switch abruptly, "
|
||||||
|
"causing geometry or textures to change suddenly.",
|
||||||
|
severity=GlitchSeverity.LOW,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for areas where mesh detail changes abruptly at visible boundaries.",
|
||||||
|
"Identify objects that appear to morph or shift geometry suddenly.",
|
||||||
|
"Check for texture resolution changes that create visible seams.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"visible mesh simplification boundary",
|
||||||
|
"texture resolution jump",
|
||||||
|
"geometry pop-in artifacts",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.45,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.LIGHTMAP_ERRORS,
|
||||||
|
name="Lightmap Baking Error",
|
||||||
|
description="Incorrect or missing baked lighting causing dark spots, light "
|
||||||
|
"leaks, or mismatched illumination on static geometry.",
|
||||||
|
severity=GlitchSeverity.MEDIUM,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for unusually dark patches on walls or ceilings that should be lit.",
|
||||||
|
"Identify bright light leaks through solid geometry seams.",
|
||||||
|
"Check for mismatched lighting between adjacent surfaces.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"dark splotch on lit surface",
|
||||||
|
"bright line at geometry seam",
|
||||||
|
"lighting discontinuity between adjacent faces",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.5,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.WATER_REFLECTION,
|
||||||
|
name="Water/Reflection Error",
|
||||||
|
description="Incorrect reflections, missing water surfaces, or broken "
|
||||||
|
"reflection probe assignments.",
|
||||||
|
severity=GlitchSeverity.MEDIUM,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for reflections that do not match the surrounding environment.",
|
||||||
|
"Identify water surfaces that appear solid or incorrectly rendered.",
|
||||||
|
"Check for mirror surfaces showing wrong scene geometry.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"reflection mismatch",
|
||||||
|
"solid water surface",
|
||||||
|
"incorrect environment map",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.5,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.SKYBOX_SEAM,
|
||||||
|
name="Skybox Seam",
|
||||||
|
description="Visible seams or color mismatches at the edges of skybox cubemap faces.",
|
||||||
|
severity=GlitchSeverity.LOW,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look at the edges of the sky for visible seams or color shifts.",
|
||||||
|
"Identify discontinuities where skybox faces meet.",
|
||||||
|
"Check for texture stretching at skybox corners.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"visible line in sky",
|
||||||
|
"color discontinuity at sky edge",
|
||||||
|
"sky texture seam",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.45,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_patterns_by_severity(min_severity: GlitchSeverity) -> list[GlitchPattern]:
|
||||||
|
"""Return patterns at or above the given severity level."""
|
||||||
|
severity_order = [
|
||||||
|
GlitchSeverity.INFO,
|
||||||
|
GlitchSeverity.LOW,
|
||||||
|
GlitchSeverity.MEDIUM,
|
||||||
|
GlitchSeverity.HIGH,
|
||||||
|
GlitchSeverity.CRITICAL,
|
||||||
|
]
|
||||||
|
min_idx = severity_order.index(min_severity)
|
||||||
|
return [p for p in MATRIX_GLITCH_PATTERNS if severity_order.index(p.severity) >= min_idx]
|
||||||
|
|
||||||
|
|
||||||
|
def get_pattern_by_category(category: GlitchCategory) -> Optional[GlitchPattern]:
|
||||||
|
"""Return the pattern definition for a specific category."""
|
||||||
|
for p in MATRIX_GLITCH_PATTERNS:
|
||||||
|
if p.category == category:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_vision_prompt(patterns: list[GlitchPattern] | None = None) -> str:
|
||||||
|
"""Build a composite vision analysis prompt from pattern definitions."""
|
||||||
|
if patterns is None:
|
||||||
|
patterns = MATRIX_GLITCH_PATTERNS
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
for p in patterns:
|
||||||
|
prompt_text = " ".join(p.detection_prompts)
|
||||||
|
indicators = ", ".join(p.visual_indicators)
|
||||||
|
sections.append(
|
||||||
|
f"[{p.category.value.upper()}] {p.name} (severity: {p.severity.value})\n"
|
||||||
|
f" {p.description}\n"
|
||||||
|
f" Look for: {prompt_text}\n"
|
||||||
|
f" Visual indicators: {indicators}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
"Analyze this 3D world screenshot for visual glitches and artifacts. "
|
||||||
|
"For each detected issue, report the category, description of what you see, "
|
||||||
|
"approximate location in the image (x%, y%), and confidence (0.0-1.0).\n\n"
|
||||||
|
"Known glitch patterns to check:\n\n" + "\n\n".join(sections)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
print(f"Loaded {len(MATRIX_GLITCH_PATTERNS)} glitch patterns:\n")
|
||||||
|
for p in MATRIX_GLITCH_PATTERNS:
|
||||||
|
print(f" [{p.severity.value:8s}] {p.category.value}: {p.name}")
|
||||||
|
print(f"\nVision prompt preview:\n{build_vision_prompt()[:500]}...")
|
||||||
549
bin/matrix_glitch_detector.py
Normal file
549
bin/matrix_glitch_detector.py
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Matrix 3D World Glitch Detector
|
||||||
|
|
||||||
|
Scans a 3D web world for visual artifacts using browser automation
|
||||||
|
and vision AI analysis. Produces structured glitch reports.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python matrix_glitch_detector.py <url> [--angles 4] [--output report.json]
|
||||||
|
python matrix_glitch_detector.py --demo # Run with synthetic test data
|
||||||
|
|
||||||
|
Ref: timmy-config#491
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Add parent for glitch_patterns import
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from glitch_patterns import (
|
||||||
|
GlitchCategory,
|
||||||
|
GlitchPattern,
|
||||||
|
GlitchSeverity,
|
||||||
|
MATRIX_GLITCH_PATTERNS,
|
||||||
|
build_vision_prompt,
|
||||||
|
get_patterns_by_severity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DetectedGlitch:
|
||||||
|
"""A single detected glitch with metadata."""
|
||||||
|
id: str
|
||||||
|
category: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
severity: str
|
||||||
|
confidence: float
|
||||||
|
location_x: Optional[float] = None # percentage across image
|
||||||
|
location_y: Optional[float] = None # percentage down image
|
||||||
|
screenshot_index: int = 0
|
||||||
|
screenshot_angle: str = "front"
|
||||||
|
timestamp: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if not self.timestamp:
|
||||||
|
self.timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanResult:
|
||||||
|
"""Complete scan result for a 3D world URL."""
|
||||||
|
scan_id: str
|
||||||
|
url: str
|
||||||
|
timestamp: str
|
||||||
|
total_screenshots: int
|
||||||
|
angles_captured: list[str]
|
||||||
|
glitches: list[dict] = field(default_factory=list)
|
||||||
|
summary: dict = field(default_factory=dict)
|
||||||
|
metadata: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_json(self, indent: int = 2) -> str:
|
||||||
|
return json.dumps(asdict(self), indent=indent)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_scan_angles(num_angles: int) -> list[dict]:
|
||||||
|
"""Generate camera angle configurations for multi-angle scanning.
|
||||||
|
|
||||||
|
Returns a list of dicts with yaw/pitch/label for browser camera control.
|
||||||
|
"""
|
||||||
|
base_angles = [
|
||||||
|
{"yaw": 0, "pitch": 0, "label": "front"},
|
||||||
|
{"yaw": 90, "pitch": 0, "label": "right"},
|
||||||
|
{"yaw": 180, "pitch": 0, "label": "back"},
|
||||||
|
{"yaw": 270, "pitch": 0, "label": "left"},
|
||||||
|
{"yaw": 0, "pitch": -30, "label": "front_low"},
|
||||||
|
{"yaw": 45, "pitch": -15, "label": "front_right_low"},
|
||||||
|
{"yaw": 0, "pitch": 30, "label": "front_high"},
|
||||||
|
{"yaw": 45, "pitch": 0, "label": "front_right"},
|
||||||
|
]
|
||||||
|
|
||||||
|
if num_angles <= len(base_angles):
|
||||||
|
return base_angles[:num_angles]
|
||||||
|
return base_angles + [
|
||||||
|
{"yaw": i * (360 // num_angles), "pitch": 0, "label": f"angle_{i}"}
|
||||||
|
for i in range(len(base_angles), num_angles)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def capture_screenshots(url: str, angles: list[dict], output_dir: Path) -> list[Path]:
|
||||||
|
"""Capture screenshots of a 3D web world from multiple angles.
|
||||||
|
|
||||||
|
Uses browser_vision tool when available; falls back to placeholder generation
|
||||||
|
for testing and environments without browser access.
|
||||||
|
"""
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
screenshots = []
|
||||||
|
|
||||||
|
for i, angle in enumerate(angles):
|
||||||
|
filename = output_dir / f"screenshot_{i:03d}_{angle['label']}.png"
|
||||||
|
|
||||||
|
# Attempt browser-based capture via browser_vision
|
||||||
|
try:
|
||||||
|
result = _browser_capture(url, angle, filename)
|
||||||
|
if result:
|
||||||
|
screenshots.append(filename)
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Generate placeholder screenshot for offline/test scenarios
|
||||||
|
_generate_placeholder_screenshot(filename, angle)
|
||||||
|
screenshots.append(filename)
|
||||||
|
|
||||||
|
return screenshots
|
||||||
|
|
||||||
|
|
||||||
|
def _browser_capture(url: str, angle: dict, output_path: Path) -> bool:
|
||||||
|
"""Capture a screenshot via browser automation.
|
||||||
|
|
||||||
|
This is a stub that delegates to the browser_vision tool when run
|
||||||
|
in an environment that provides it. In CI or offline mode, returns False.
|
||||||
|
"""
|
||||||
|
# Check if browser_vision is available via environment
|
||||||
|
bv_script = os.environ.get("BROWSER_VISION_SCRIPT")
|
||||||
|
if bv_script and Path(bv_script).exists():
|
||||||
|
import subprocess
|
||||||
|
cmd = [
|
||||||
|
sys.executable, bv_script,
|
||||||
|
"--url", url,
|
||||||
|
"--screenshot", str(output_path),
|
||||||
|
"--rotate-yaw", str(angle["yaw"]),
|
||||||
|
"--rotate-pitch", str(angle["pitch"]),
|
||||||
|
]
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||||
|
return proc.returncode == 0 and output_path.exists()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_placeholder_screenshot(path: Path, angle: dict):
|
||||||
|
"""Generate a minimal 1x1 PNG as a placeholder for testing."""
|
||||||
|
# Minimal valid PNG (1x1 transparent pixel)
|
||||||
|
png_data = (
|
||||||
|
b"\x89PNG\r\n\x1a\n"
|
||||||
|
b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||||
|
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
|
||||||
|
b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01"
|
||||||
|
b"\r\n\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||||
|
)
|
||||||
|
path.write_bytes(png_data)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_with_vision(
|
||||||
|
screenshot_paths: list[Path],
|
||||||
|
angles: list[dict],
|
||||||
|
patterns: list[GlitchPattern] | None = None,
|
||||||
|
) -> list[DetectedGlitch]:
|
||||||
|
"""Send screenshots to vision AI for glitch analysis.
|
||||||
|
|
||||||
|
In environments with a vision model available, sends each screenshot
|
||||||
|
with the composite detection prompt. Otherwise returns simulated results.
|
||||||
|
"""
|
||||||
|
if patterns is None:
|
||||||
|
patterns = MATRIX_GLITCH_PATTERNS
|
||||||
|
|
||||||
|
prompt = build_vision_prompt(patterns)
|
||||||
|
glitches = []
|
||||||
|
|
||||||
|
for i, (path, angle) in enumerate(zip(screenshot_paths, angles)):
|
||||||
|
# Attempt vision analysis
|
||||||
|
detected = _vision_analyze_image(path, prompt, i, angle["label"])
|
||||||
|
glitches.extend(detected)
|
||||||
|
|
||||||
|
return glitches
|
||||||
|
|
||||||
|
|
||||||
|
def _vision_analyze_image(
|
||||||
|
image_path: Path,
|
||||||
|
prompt: str,
|
||||||
|
screenshot_index: int,
|
||||||
|
angle_label: str,
|
||||||
|
) -> list[DetectedGlitch]:
|
||||||
|
"""Analyze a single screenshot with vision AI.
|
||||||
|
|
||||||
|
Uses the vision_analyze tool when available; returns empty list otherwise.
|
||||||
|
"""
|
||||||
|
# Check for vision API configuration
|
||||||
|
api_key = os.environ.get("VISION_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
||||||
|
api_base = os.environ.get("VISION_API_BASE", "https://api.openai.com/v1")
|
||||||
|
|
||||||
|
if api_key:
|
||||||
|
try:
|
||||||
|
return _call_vision_api(
|
||||||
|
image_path, prompt, screenshot_index, angle_label, api_key, api_base
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [!] Vision API error for {image_path.name}: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
# No vision backend available
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _call_vision_api(
|
||||||
|
image_path: Path,
|
||||||
|
prompt: str,
|
||||||
|
screenshot_index: int,
|
||||||
|
angle_label: str,
|
||||||
|
api_key: str,
|
||||||
|
api_base: str,
|
||||||
|
) -> list[DetectedGlitch]:
|
||||||
|
"""Call a vision API (OpenAI-compatible) for image analysis."""
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
image_data = base64.b64encode(image_path.read_bytes()).decode()
|
||||||
|
|
||||||
|
payload = json.dumps({
|
||||||
|
"model": os.environ.get("VISION_MODEL", "gpt-4o"),
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:image/png;base64,{image_data}",
|
||||||
|
"detail": "high",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"max_tokens": 4096,
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{api_base}/chat/completions",
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
result = json.loads(resp.read())
|
||||||
|
|
||||||
|
content = result["choices"][0]["message"]["content"]
|
||||||
|
return _parse_vision_response(content, screenshot_index, angle_label)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_glitch_from_dict(
|
||||||
|
item: dict,
|
||||||
|
glitches: list[DetectedGlitch],
|
||||||
|
screenshot_index: int,
|
||||||
|
angle_label: str,
|
||||||
|
):
|
||||||
|
"""Convert a dict from vision API response into a DetectedGlitch."""
|
||||||
|
cat = item.get("category", item.get("type", "unknown"))
|
||||||
|
conf = float(item.get("confidence", item.get("score", 0.5)))
|
||||||
|
|
||||||
|
glitch = DetectedGlitch(
|
||||||
|
id=str(uuid.uuid4())[:8],
|
||||||
|
category=cat,
|
||||||
|
name=item.get("name", item.get("label", cat)),
|
||||||
|
description=item.get("description", item.get("detail", "")),
|
||||||
|
severity=item.get("severity", _infer_severity(cat, conf)),
|
||||||
|
confidence=conf,
|
||||||
|
location_x=item.get("location_x", item.get("x")),
|
||||||
|
location_y=item.get("location_y", item.get("y")),
|
||||||
|
screenshot_index=screenshot_index,
|
||||||
|
screenshot_angle=angle_label,
|
||||||
|
)
|
||||||
|
glitches.append(glitch)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_vision_response(
|
||||||
|
text: str, screenshot_index: int, angle_label: str
|
||||||
|
) -> list[DetectedGlitch]:
|
||||||
|
"""Parse vision AI response into structured glitch detections."""
|
||||||
|
glitches = []
|
||||||
|
|
||||||
|
# Try to extract JSON from the response
|
||||||
|
json_blocks = []
|
||||||
|
in_json = False
|
||||||
|
json_buf = []
|
||||||
|
|
||||||
|
for line in text.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("```"):
|
||||||
|
if in_json and json_buf:
|
||||||
|
try:
|
||||||
|
json_blocks.append(json.loads("\n".join(json_buf)))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
json_buf = []
|
||||||
|
in_json = not in_json
|
||||||
|
continue
|
||||||
|
if in_json:
|
||||||
|
json_buf.append(line)
|
||||||
|
|
||||||
|
# Flush any remaining buffer
|
||||||
|
if in_json and json_buf:
|
||||||
|
try:
|
||||||
|
json_blocks.append(json.loads("\n".join(json_buf)))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Also try parsing the entire response as JSON
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
json_blocks.extend(parsed)
|
||||||
|
elif isinstance(parsed, dict):
|
||||||
|
if "glitches" in parsed:
|
||||||
|
json_blocks.extend(parsed["glitches"])
|
||||||
|
elif "detections" in parsed:
|
||||||
|
json_blocks.extend(parsed["detections"])
|
||||||
|
else:
|
||||||
|
json_blocks.append(parsed)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for item in json_blocks:
|
||||||
|
# Flatten arrays of detections
|
||||||
|
if isinstance(item, list):
|
||||||
|
for sub in item:
|
||||||
|
if isinstance(sub, dict):
|
||||||
|
_add_glitch_from_dict(sub, glitches, screenshot_index, angle_label)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
_add_glitch_from_dict(item, glitches, screenshot_index, angle_label)
|
||||||
|
|
||||||
|
return glitches
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_severity(category: str, confidence: float) -> str:
|
||||||
|
"""Infer severity from category and confidence when not provided."""
|
||||||
|
critical_cats = {"missing_textures", "clipping"}
|
||||||
|
high_cats = {"floating_assets", "broken_normals"}
|
||||||
|
|
||||||
|
cat_lower = category.lower()
|
||||||
|
if any(c in cat_lower for c in critical_cats):
|
||||||
|
return "critical" if confidence > 0.7 else "high"
|
||||||
|
if any(c in cat_lower for c in high_cats):
|
||||||
|
return "high" if confidence > 0.7 else "medium"
|
||||||
|
return "medium" if confidence > 0.6 else "low"
|
||||||
|
|
||||||
|
|
||||||
|
def build_report(
|
||||||
|
url: str,
|
||||||
|
angles: list[dict],
|
||||||
|
screenshots: list[Path],
|
||||||
|
glitches: list[DetectedGlitch],
|
||||||
|
) -> ScanResult:
|
||||||
|
"""Build the final structured scan report."""
|
||||||
|
severity_counts = {}
|
||||||
|
category_counts = {}
|
||||||
|
|
||||||
|
for g in glitches:
|
||||||
|
severity_counts[g.severity] = severity_counts.get(g.severity, 0) + 1
|
||||||
|
category_counts[g.category] = category_counts.get(g.category, 0) + 1
|
||||||
|
|
||||||
|
report = ScanResult(
|
||||||
|
scan_id=str(uuid.uuid4()),
|
||||||
|
url=url,
|
||||||
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||||
|
total_screenshots=len(screenshots),
|
||||||
|
angles_captured=[a["label"] for a in angles],
|
||||||
|
glitches=[asdict(g) for g in glitches],
|
||||||
|
summary={
|
||||||
|
"total_glitches": len(glitches),
|
||||||
|
"by_severity": severity_counts,
|
||||||
|
"by_category": category_counts,
|
||||||
|
"highest_severity": max(severity_counts.keys(), default="none"),
|
||||||
|
"clean_screenshots": sum(
|
||||||
|
1
|
||||||
|
for i in range(len(screenshots))
|
||||||
|
if not any(g.screenshot_index == i for g in glitches)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"detector_version": "0.1.0",
|
||||||
|
"pattern_count": len(MATRIX_GLITCH_PATTERNS),
|
||||||
|
"reference": "timmy-config#491",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def run_demo(output_path: Optional[Path] = None) -> ScanResult:
|
||||||
|
"""Run a demonstration scan with simulated detections."""
|
||||||
|
print("[*] Running Matrix glitch detection demo...")
|
||||||
|
|
||||||
|
url = "https://matrix.example.com/world/alpha"
|
||||||
|
angles = generate_scan_angles(4)
|
||||||
|
screenshots_dir = Path("/tmp/matrix_glitch_screenshots")
|
||||||
|
|
||||||
|
print(f"[*] Capturing {len(angles)} screenshots from: {url}")
|
||||||
|
screenshots = capture_screenshots(url, angles, screenshots_dir)
|
||||||
|
print(f"[*] Captured {len(screenshots)} screenshots")
|
||||||
|
|
||||||
|
# Simulate detections for demo
|
||||||
|
demo_glitches = [
|
||||||
|
DetectedGlitch(
|
||||||
|
id=str(uuid.uuid4())[:8],
|
||||||
|
category="floating_assets",
|
||||||
|
name="Floating Chair",
|
||||||
|
description="Office chair floating 0.3m above floor in sector 7",
|
||||||
|
severity="high",
|
||||||
|
confidence=0.87,
|
||||||
|
location_x=35.2,
|
||||||
|
location_y=62.1,
|
||||||
|
screenshot_index=0,
|
||||||
|
screenshot_angle="front",
|
||||||
|
),
|
||||||
|
DetectedGlitch(
|
||||||
|
id=str(uuid.uuid4())[:8],
|
||||||
|
category="z_fighting",
|
||||||
|
name="Wall Texture Flicker",
|
||||||
|
description="Z-fighting between wall panel and decorative overlay",
|
||||||
|
severity="medium",
|
||||||
|
confidence=0.72,
|
||||||
|
location_x=58.0,
|
||||||
|
location_y=40.5,
|
||||||
|
screenshot_index=1,
|
||||||
|
screenshot_angle="right",
|
||||||
|
),
|
||||||
|
DetectedGlitch(
|
||||||
|
id=str(uuid.uuid4())[:8],
|
||||||
|
category="missing_textures",
|
||||||
|
name="Placeholder Texture",
|
||||||
|
description="Bright magenta surface on door frame — missing asset reference",
|
||||||
|
severity="critical",
|
||||||
|
confidence=0.95,
|
||||||
|
location_x=72.3,
|
||||||
|
location_y=28.8,
|
||||||
|
screenshot_index=2,
|
||||||
|
screenshot_angle="back",
|
||||||
|
),
|
||||||
|
DetectedGlitch(
|
||||||
|
id=str(uuid.uuid4())[:8],
|
||||||
|
category="clipping",
|
||||||
|
name="Desk Through Wall",
|
||||||
|
description="Desk corner clipping through adjacent wall geometry",
|
||||||
|
severity="high",
|
||||||
|
confidence=0.81,
|
||||||
|
location_x=15.0,
|
||||||
|
location_y=55.0,
|
||||||
|
screenshot_index=3,
|
||||||
|
screenshot_angle="left",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"[*] Detected {len(demo_glitches)} glitches")
|
||||||
|
report = build_report(url, angles, screenshots, demo_glitches)
|
||||||
|
|
||||||
|
if output_path:
|
||||||
|
output_path.write_text(report.to_json())
|
||||||
|
print(f"[*] Report saved to: {output_path}")
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Matrix 3D World Glitch Detector — scan for visual artifacts",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
%(prog)s https://matrix.example.com/world/alpha
|
||||||
|
%(prog)s https://matrix.example.com/world/alpha --angles 8 --output report.json
|
||||||
|
%(prog)s --demo
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
parser.add_argument("url", nargs="?", help="URL of the 3D world to scan")
|
||||||
|
parser.add_argument(
|
||||||
|
"--angles", type=int, default=4, help="Number of camera angles to capture (default: 4)"
|
||||||
|
)
|
||||||
|
parser.add_argument("--output", "-o", type=str, help="Output file path for JSON report")
|
||||||
|
parser.add_argument("--demo", action="store_true", help="Run demo with simulated data")
|
||||||
|
parser.add_argument(
|
||||||
|
"--min-severity",
|
||||||
|
choices=["info", "low", "medium", "high", "critical"],
|
||||||
|
default="info",
|
||||||
|
help="Minimum severity to include in report",
|
||||||
|
)
|
||||||
|
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.demo:
|
||||||
|
output = Path(args.output) if args.output else Path("glitch_report_demo.json")
|
||||||
|
report = run_demo(output)
|
||||||
|
print(f"\n=== Scan Summary ===")
|
||||||
|
print(f"URL: {report.url}")
|
||||||
|
print(f"Screenshots: {report.total_screenshots}")
|
||||||
|
print(f"Glitches found: {report.summary['total_glitches']}")
|
||||||
|
print(f"By severity: {report.summary['by_severity']}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not args.url:
|
||||||
|
parser.error("URL required (or use --demo)")
|
||||||
|
|
||||||
|
scan_id = str(uuid.uuid4())[:8]
|
||||||
|
print(f"[*] Matrix Glitch Detector — Scan {scan_id}")
|
||||||
|
print(f"[*] Target: {args.url}")
|
||||||
|
|
||||||
|
# Generate camera angles
|
||||||
|
angles = generate_scan_angles(args.angles)
|
||||||
|
print(f"[*] Capturing {len(angles)} screenshots...")
|
||||||
|
|
||||||
|
# Capture screenshots
|
||||||
|
screenshots_dir = Path(f"/tmp/matrix_glitch_{scan_id}")
|
||||||
|
screenshots = capture_screenshots(args.url, angles, screenshots_dir)
|
||||||
|
print(f"[*] Captured {len(screenshots)} screenshots")
|
||||||
|
|
||||||
|
# Filter patterns by severity
|
||||||
|
min_sev = GlitchSeverity(args.min_severity)
|
||||||
|
patterns = get_patterns_by_severity(min_sev)
|
||||||
|
|
||||||
|
# Analyze with vision AI
|
||||||
|
print(f"[*] Analyzing with vision AI ({len(patterns)} patterns)...")
|
||||||
|
glitches = analyze_with_vision(screenshots, angles, patterns)
|
||||||
|
|
||||||
|
# Build and save report
|
||||||
|
report = build_report(args.url, angles, screenshots, glitches)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
Path(args.output).write_text(report.to_json())
|
||||||
|
print(f"[*] Report saved: {args.output}")
|
||||||
|
else:
|
||||||
|
print(report.to_json())
|
||||||
|
|
||||||
|
print(f"\n[*] Done — {len(glitches)} glitches detected")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -19,25 +19,25 @@ PASS=0
|
|||||||
FAIL=0
|
FAIL=0
|
||||||
WARN=0
|
WARN=0
|
||||||
|
|
||||||
check_anthropic_model() {
|
check_kimi_model() {
|
||||||
local model="$1"
|
local model="$1"
|
||||||
local label="$2"
|
local label="$2"
|
||||||
local api_key="${ANTHROPIC_API_KEY:-}"
|
local api_key="${KIMI_API_KEY:-}"
|
||||||
|
|
||||||
if [ -z "$api_key" ]; then
|
if [ -z "$api_key" ]; then
|
||||||
# Try loading from .env
|
# Try loading from .env
|
||||||
api_key=$(grep '^ANTHROPIC_API_KEY=' "${HERMES_HOME:-$HOME/.hermes}/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d "'\"" || echo "")
|
api_key=$(grep '^KIMI_API_KEY=' "${HERMES_HOME:-$HOME/.hermes}/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d "'\"" || echo "")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$api_key" ]; then
|
if [ -z "$api_key" ]; then
|
||||||
log "SKIP [$label] $model -- no ANTHROPIC_API_KEY"
|
log "SKIP [$label] $model -- no KIMI_API_KEY"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
response=$(curl -sf --max-time 10 -X POST \
|
response=$(curl -sf --max-time 10 -X POST \
|
||||||
"https://api.anthropic.com/v1/messages" \
|
"https://api.kimi.com/coding/v1/chat/completions" \
|
||||||
-H "x-api-key: ${api_key}" \
|
-H "x-api-key: ${api_key}" \
|
||||||
-H "anthropic-version: 2023-06-01" \
|
-H "x-api-provider: kimi-coding" \
|
||||||
-H "content-type: application/json" \
|
-H "content-type: application/json" \
|
||||||
-d "{\"model\":\"${model}\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" 2>&1 || echo "ERROR")
|
-d "{\"model\":\"${model}\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" 2>&1 || echo "ERROR")
|
||||||
|
|
||||||
@@ -85,26 +85,26 @@ else:
|
|||||||
print('')
|
print('')
|
||||||
" 2>/dev/null || echo "")
|
" 2>/dev/null || echo "")
|
||||||
|
|
||||||
if [ -n "$primary" ] && [ "$provider" = "anthropic" ]; then
|
if [ -n "$primary" ] && [ "$provider" = "kimi-coding" ]; then
|
||||||
if check_anthropic_model "$primary" "PRIMARY"; then
|
if check_kimi_model "$primary" "PRIMARY"; then
|
||||||
PASS=$((PASS + 1))
|
PASS=$((PASS + 1))
|
||||||
else
|
else
|
||||||
rc=$?
|
rc=$?
|
||||||
if [ "$rc" -eq 1 ]; then
|
if [ "$rc" -eq 1 ]; then
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
log "CRITICAL: Primary model $primary is DEAD. Loops will fail."
|
log "CRITICAL: Primary model $primary is DEAD. Loops will fail."
|
||||||
log "Known good alternatives: claude-opus-4.6, claude-haiku-4-5-20251001"
|
log "Known good alternatives: kimi-k2.5, google/gemini-2.5-pro"
|
||||||
else
|
else
|
||||||
WARN=$((WARN + 1))
|
WARN=$((WARN + 1))
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
elif [ -n "$primary" ]; then
|
elif [ -n "$primary" ]; then
|
||||||
log "SKIP [PRIMARY] $primary -- non-anthropic provider ($provider), no validator yet"
|
log "SKIP [PRIMARY] $primary -- non-kimi provider ($provider), no validator yet"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Cron model check (haiku)
|
# Cron model check (haiku)
|
||||||
CRON_MODEL="claude-haiku-4-5-20251001"
|
CRON_MODEL="kimi-k2.5"
|
||||||
if check_anthropic_model "$CRON_MODEL" "CRON"; then
|
if check_kimi_model "$CRON_MODEL" "CRON"; then
|
||||||
PASS=$((PASS + 1))
|
PASS=$((PASS + 1))
|
||||||
else
|
else
|
||||||
rc=$?
|
rc=$?
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Full Nostr agent-to-agent communication demo - FINAL WORKING
|
Full Nostr agent-to-agent communication demo - FINAL WORKING
|
||||||
"""
|
"""
|
||||||
|
|||||||
514
bin/pane-watchdog.sh
Executable file
514
bin/pane-watchdog.sh
Executable file
@@ -0,0 +1,514 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# pane-watchdog.sh — Detect stuck/dead tmux panes and auto-restart them
|
||||||
|
#
|
||||||
|
# Tracks output hash per pane across cycles. If a pane's captured output
|
||||||
|
# hasn't changed for STUCK_CYCLES consecutive checks, the pane is STUCK.
|
||||||
|
# Dead panes (PID gone) are also detected.
|
||||||
|
#
|
||||||
|
# On STUCK/DEAD:
|
||||||
|
# 1. Kill the pane
|
||||||
|
# 2. Attempt restart with --resume (session ID from manifest)
|
||||||
|
# 3. Fallback: fresh prompt with last known task from logs
|
||||||
|
#
|
||||||
|
# State file: ~/.hermes/pane-state.json
|
||||||
|
# Log: ~/.hermes/logs/pane-watchdog.log
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# pane-watchdog.sh # One-shot check all sessions
|
||||||
|
# pane-watchdog.sh --daemon # Run every CHECK_INTERVAL seconds
|
||||||
|
# pane-watchdog.sh --status # Print current pane state
|
||||||
|
# pane-watchdog.sh --session NAME # Check only one session
|
||||||
|
#
|
||||||
|
# Issue: timmy-config #515
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
export PATH="/opt/homebrew/bin:$HOME/.local/bin:$HOME/.hermes/bin:/usr/local/bin:$PATH"
|
||||||
|
|
||||||
|
# === CONFIG ===
|
||||||
|
STATE_FILE="${PANE_STATE_FILE:-$HOME/.hermes/pane-state.json}"
|
||||||
|
LOG_FILE="${PANE_WATCHDOG_LOG:-$HOME/.hermes/logs/pane-watchdog.log}"
|
||||||
|
CHECK_INTERVAL="${PANE_CHECK_INTERVAL:-120}" # seconds between cycles
|
||||||
|
STUCK_CYCLES=2 # unchanged cycles before STUCK
|
||||||
|
MAX_RESTART_ATTEMPTS=3 # per pane per hour
|
||||||
|
RESTART_COOLDOWN=3600 # seconds between escalation alerts
|
||||||
|
CAPTURE_LINES=40 # lines of output to hash
|
||||||
|
|
||||||
|
# Sessions to monitor (all if empty)
|
||||||
|
MONITOR_SESSIONS="${PANE_WATCHDOG_SESSIONS:-}"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$STATE_FILE")" "$(dirname "$LOG_FILE")"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# === HELPERS ===
|
||||||
|
|
||||||
|
# Capture last N lines of pane output and hash them
|
||||||
|
capture_pane_hash() {
|
||||||
|
local target="$1"
|
||||||
|
local output
|
||||||
|
output=$(tmux capture-pane -t "$target" -p -S "-${CAPTURE_LINES}" 2>/dev/null || echo "DEAD")
|
||||||
|
echo -n "$output" | shasum -a 256 | cut -d' ' -f1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if pane PID is alive
|
||||||
|
pane_pid_alive() {
|
||||||
|
local target="$1"
|
||||||
|
local pid
|
||||||
|
pid=$(tmux list-panes -t "$target" -F '#{pane_pid}' 2>/dev/null | head -1 || echo "")
|
||||||
|
if [ -z "$pid" ]; then
|
||||||
|
return 1 # pane doesn't exist
|
||||||
|
fi
|
||||||
|
kill -0 "$pid" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get pane start command
|
||||||
|
pane_start_command() {
|
||||||
|
local target="$1"
|
||||||
|
tmux list-panes -t "$target" -F '#{pane_start_command}' 2>/dev/null | head -1 || echo "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the pane's current running command (child process)
|
||||||
|
pane_current_command() {
|
||||||
|
local target="$1"
|
||||||
|
tmux list-panes -t "$target" -F '#{pane_current_command}' 2>/dev/null || echo "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only restart panes running hermes/agent commands (not zsh, python3 repls, etc.)
|
||||||
|
is_restartable() {
|
||||||
|
local cmd="$1"
|
||||||
|
case "$cmd" in
|
||||||
|
hermes|*hermes*|*agent*|*timmy*|*kimi*|*claude-loop*|*gemini-loop*)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get session ID from hermes manifest if available
|
||||||
|
get_hermes_session_id() {
|
||||||
|
local session_name="$1"
|
||||||
|
local manifest="$HOME/.hermes/sessions/${session_name}/manifest.json"
|
||||||
|
if [ -f "$manifest" ]; then
|
||||||
|
python3 -c "
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
m = json.load(open('$manifest'))
|
||||||
|
print(m.get('session_id', m.get('id', '')))
|
||||||
|
except: pass
|
||||||
|
" 2>/dev/null || echo ""
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get last task from pane logs
|
||||||
|
get_last_task() {
|
||||||
|
local session_name="$1"
|
||||||
|
local log_dir="$HOME/.hermes/logs"
|
||||||
|
# Find the most recent log for this session
|
||||||
|
local log_file
|
||||||
|
log_file=$(find "$log_dir" -name "*${session_name}*" -type f -mtime -1 2>/dev/null | sort -r | head -1)
|
||||||
|
if [ -n "$log_file" ] && [ -f "$log_file" ]; then
|
||||||
|
# Extract last user prompt or task description
|
||||||
|
grep -i "task:\|prompt:\|issue\|working on" "$log_file" 2>/dev/null | tail -1 | sed 's/.*[:>] *//' | head -c 200
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Restart a pane with a fresh shell/command
|
||||||
|
restart_pane() {
|
||||||
|
local target="$1"
|
||||||
|
local session_name="${target%%:*}"
|
||||||
|
local session_id last_task cmd
|
||||||
|
|
||||||
|
log "RESTART: Attempting to restart $target"
|
||||||
|
|
||||||
|
# Kill existing pane
|
||||||
|
tmux kill-pane -t "$target" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Try --resume with session ID
|
||||||
|
session_id=$(get_hermes_session_id "$session_name")
|
||||||
|
if [ -n "$session_id" ]; then
|
||||||
|
log "RESTART: Trying --resume with session $session_id"
|
||||||
|
tmux split-window -t "$session_name" -d \
|
||||||
|
"hermes chat --resume '$session_id' 2>&1 | tee -a '$HOME/.hermes/logs/${session_name}-restart.log'"
|
||||||
|
sleep 2
|
||||||
|
if pane_pid_alive "${session_name}:1" 2>/dev/null; then
|
||||||
|
log "RESTART: Success with --resume"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback: fresh prompt
|
||||||
|
last_task=$(get_last_task "$session_name")
|
||||||
|
if [ -n "$last_task" ]; then
|
||||||
|
log "RESTART: Fallback — fresh prompt with task: $last_task"
|
||||||
|
tmux split-window -t "$session_name" -d \
|
||||||
|
"echo 'Watchdog restart — last task: $last_task' && hermes chat 2>&1 | tee -a '$HOME/.hermes/logs/${session_name}-restart.log'"
|
||||||
|
else
|
||||||
|
log "RESTART: Fallback — fresh hermes chat"
|
||||||
|
tmux split-window -t "$session_name" -d \
|
||||||
|
"hermes chat 2>&1 | tee -a '$HOME/.hermes/logs/${session_name}-restart.log'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
if pane_pid_alive "${session_name}:1" 2>/dev/null; then
|
||||||
|
log "RESTART: Fallback restart succeeded"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log "RESTART: FAILED to restart $target"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# === STATE MANAGEMENT ===
|
||||||
|
|
||||||
|
read_state() {
|
||||||
|
if [ -f "$STATE_FILE" ]; then
|
||||||
|
cat "$STATE_FILE"
|
||||||
|
else
|
||||||
|
echo "{}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_state() {
|
||||||
|
echo "$1" > "$STATE_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update state for a single pane and return JSON status
|
||||||
|
update_pane_state() {
|
||||||
|
local target="$1"
|
||||||
|
local hash="$2"
|
||||||
|
local is_alive="$3"
|
||||||
|
local now
|
||||||
|
now=$(date +%s)
|
||||||
|
|
||||||
|
python3 - "$STATE_FILE" "$target" "$hash" "$is_alive" "$now" "$STUCK_CYCLES" <<'PYEOF'
|
||||||
|
import json, sys, time
|
||||||
|
|
||||||
|
state_file = sys.argv[1]
|
||||||
|
target = sys.argv[2]
|
||||||
|
new_hash = sys.argv[3]
|
||||||
|
is_alive = sys.argv[4] == "true"
|
||||||
|
now = int(sys.argv[5])
|
||||||
|
stuck_cycles = int(sys.argv[6])
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(state_file) as f:
|
||||||
|
state = json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
state = {}
|
||||||
|
|
||||||
|
pane = state.get(target, {
|
||||||
|
"hash": "",
|
||||||
|
"same_count": 0,
|
||||||
|
"status": "UNKNOWN",
|
||||||
|
"last_change": 0,
|
||||||
|
"last_check": 0,
|
||||||
|
"restart_attempts": 0,
|
||||||
|
"last_restart": 0,
|
||||||
|
"current_command": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
if not is_alive:
|
||||||
|
pane["status"] = "DEAD"
|
||||||
|
pane["same_count"] = 0
|
||||||
|
elif new_hash == pane.get("hash", ""):
|
||||||
|
pane["same_count"] = pane.get("same_count", 0) + 1
|
||||||
|
if pane["same_count"] >= stuck_cycles:
|
||||||
|
pane["status"] = "STUCK"
|
||||||
|
else:
|
||||||
|
pane["status"] = "STALE" if pane["same_count"] > 0 else "OK"
|
||||||
|
else:
|
||||||
|
pane["hash"] = new_hash
|
||||||
|
pane["same_count"] = 0
|
||||||
|
pane["status"] = "OK"
|
||||||
|
pane["last_change"] = now
|
||||||
|
|
||||||
|
pane["last_check"] = now
|
||||||
|
state[target] = pane
|
||||||
|
|
||||||
|
with open(state_file, "w") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
print(json.dumps(pane))
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reset restart attempt counter if cooldown expired
|
||||||
|
maybe_reset_restarts() {
|
||||||
|
local target="$1"
|
||||||
|
local now
|
||||||
|
now=$(date +%s)
|
||||||
|
|
||||||
|
python3 - "$STATE_FILE" "$target" "$now" "$RESTART_COOLDOWN" <<'PYEOF'
|
||||||
|
import json, sys
|
||||||
|
|
||||||
|
state_file = sys.argv[1]
|
||||||
|
target = sys.argv[2]
|
||||||
|
now = int(sys.argv[3])
|
||||||
|
cooldown = int(sys.argv[4])
|
||||||
|
|
||||||
|
with open(state_file) as f:
|
||||||
|
state = json.load(f)
|
||||||
|
|
||||||
|
pane = state.get(target, {})
|
||||||
|
last_restart = pane.get("last_restart", 0)
|
||||||
|
|
||||||
|
if now - last_restart > cooldown:
|
||||||
|
pane["restart_attempts"] = 0
|
||||||
|
|
||||||
|
state[target] = pane
|
||||||
|
with open(state_file, "w") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
print(pane.get("restart_attempts", 0))
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
increment_restart_attempt() {
|
||||||
|
local target="$1"
|
||||||
|
local now
|
||||||
|
now=$(date +%s)
|
||||||
|
|
||||||
|
python3 - "$STATE_FILE" "$target" "$now" <<'PYEOF'
|
||||||
|
import json, sys
|
||||||
|
|
||||||
|
state_file = sys.argv[1]
|
||||||
|
target = sys.argv[2]
|
||||||
|
now = int(sys.argv[3])
|
||||||
|
|
||||||
|
with open(state_file) as f:
|
||||||
|
state = json.load(f)
|
||||||
|
|
||||||
|
pane = state.get(target, {})
|
||||||
|
pane["restart_attempts"] = pane.get("restart_attempts", 0) + 1
|
||||||
|
pane["last_restart"] = now
|
||||||
|
pane["status"] = "RESTARTING"
|
||||||
|
|
||||||
|
state[target] = pane
|
||||||
|
with open(state_file, "w") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
print(pane["restart_attempts"])
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# === CORE CHECK ===
|
||||||
|
|
||||||
|
check_pane() {
|
||||||
|
local target="$1"
|
||||||
|
local hash is_alive status current_cmd
|
||||||
|
|
||||||
|
# Capture state
|
||||||
|
hash=$(capture_pane_hash "$target")
|
||||||
|
if pane_pid_alive "$target"; then
|
||||||
|
is_alive="true"
|
||||||
|
else
|
||||||
|
is_alive="false"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get current command for the pane
|
||||||
|
current_cmd=$(pane_current_command "$target")
|
||||||
|
|
||||||
|
# Update state and get result
|
||||||
|
local result
|
||||||
|
result=$(update_pane_state "$target" "$hash" "$is_alive")
|
||||||
|
status=$(echo "$result" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('status','UNKNOWN'))" 2>/dev/null || echo "UNKNOWN")
|
||||||
|
|
||||||
|
case "$status" in
|
||||||
|
OK)
|
||||||
|
# Healthy, do nothing
|
||||||
|
;;
|
||||||
|
DEAD)
|
||||||
|
log "DETECTED: $target is DEAD (PID gone) cmd=$current_cmd"
|
||||||
|
if is_restartable "$current_cmd"; then
|
||||||
|
handle_stuck "$target"
|
||||||
|
else
|
||||||
|
log "SKIP: $target not a hermes pane (cmd=$current_cmd), not restarting"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
STUCK)
|
||||||
|
log "DETECTED: $target is STUCK (output unchanged for ${STUCK_CYCLES} cycles) cmd=$current_cmd"
|
||||||
|
if is_restartable "$current_cmd"; then
|
||||||
|
handle_stuck "$target"
|
||||||
|
else
|
||||||
|
log "SKIP: $target not a hermes pane (cmd=$current_cmd), not restarting"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
STALE)
|
||||||
|
# Output unchanged but within threshold — just log
|
||||||
|
local count
|
||||||
|
count=$(echo "$result" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('same_count',0))" 2>/dev/null || echo "?")
|
||||||
|
log "STALE: $target unchanged for $count cycle(s)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_stuck() {
|
||||||
|
local target="$1"
|
||||||
|
local session_name="${target%%:*}"
|
||||||
|
local attempts
|
||||||
|
|
||||||
|
# Check restart budget
|
||||||
|
attempts=$(maybe_reset_restarts "$target")
|
||||||
|
if [ "$attempts" -ge "$MAX_RESTART_ATTEMPTS" ]; then
|
||||||
|
log "ESCALATION: $target stuck ${attempts}x — manual intervention needed"
|
||||||
|
echo "ALERT: $target stuck after $attempts restart attempts" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
attempts=$(increment_restart_attempt "$target")
|
||||||
|
log "ACTION: Restarting $target (attempt $attempts/$MAX_RESTART_ATTEMPTS)"
|
||||||
|
|
||||||
|
if restart_pane "$target"; then
|
||||||
|
log "OK: $target restarted successfully"
|
||||||
|
else
|
||||||
|
log "FAIL: $target restart failed (attempt $attempts)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_all_sessions() {
|
||||||
|
local sessions
|
||||||
|
|
||||||
|
if [ -n "$MONITOR_SESSIONS" ]; then
|
||||||
|
IFS=',' read -ra sessions <<< "$MONITOR_SESSIONS"
|
||||||
|
else
|
||||||
|
sessions=()
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -n "$line" ] && sessions+=("$line")
|
||||||
|
done < <(tmux list-sessions -F '#{session_name}' 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
local total=0 stuck=0 dead=0 ok=0
|
||||||
|
for session in "${sessions[@]}"; do
|
||||||
|
[ -z "$session" ] && continue
|
||||||
|
# Get pane targets
|
||||||
|
local panes
|
||||||
|
panes=$(tmux list-panes -t "$session" -F "${session}:#{window_index}.#{pane_index}" 2>/dev/null || true)
|
||||||
|
for target in $panes; do
|
||||||
|
check_pane "$target"
|
||||||
|
total=$((total + 1))
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
log "CHECK: Processed $total panes"
|
||||||
|
}
|
||||||
|
|
||||||
|
# === STATUS DISPLAY ===
|
||||||
|
|
||||||
|
show_status() {
|
||||||
|
if [ ! -f "$STATE_FILE" ]; then
|
||||||
|
echo "No pane state file found at $STATE_FILE"
|
||||||
|
echo "Run pane-watchdog.sh once to initialize."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$STATE_FILE" <<'PYEOF'
|
||||||
|
import json, sys, time
|
||||||
|
|
||||||
|
state_file = sys.argv[1]
|
||||||
|
try:
|
||||||
|
with open(state_file) as f:
|
||||||
|
state = json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
print("No state data yet.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if not state:
|
||||||
|
print("No panes tracked.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
print(f"{'PANE':<35} {'STATUS':<12} {'STALE':<6} {'LAST CHANGE':<15} {'RESTARTS'}")
|
||||||
|
print("-" * 90)
|
||||||
|
|
||||||
|
for target in sorted(state.keys()):
|
||||||
|
p = state[target]
|
||||||
|
status = p.get("status", "?")
|
||||||
|
same = p.get("same_count", 0)
|
||||||
|
last_change = p.get("last_change", 0)
|
||||||
|
restarts = p.get("restart_attempts", 0)
|
||||||
|
|
||||||
|
if last_change:
|
||||||
|
ago = now - last_change
|
||||||
|
if ago < 60:
|
||||||
|
change_str = f"{ago}s ago"
|
||||||
|
elif ago < 3600:
|
||||||
|
change_str = f"{ago//60}m ago"
|
||||||
|
else:
|
||||||
|
change_str = f"{ago//3600}h ago"
|
||||||
|
else:
|
||||||
|
change_str = "never"
|
||||||
|
|
||||||
|
# Color code
|
||||||
|
if status == "OK":
|
||||||
|
icon = "✓"
|
||||||
|
elif status == "STUCK":
|
||||||
|
icon = "✖"
|
||||||
|
elif status == "DEAD":
|
||||||
|
icon = "☠"
|
||||||
|
elif status == "STALE":
|
||||||
|
icon = "⏳"
|
||||||
|
else:
|
||||||
|
icon = "?"
|
||||||
|
|
||||||
|
print(f" {icon} {target:<32} {status:<12} {same:<6} {change_str:<15} {restarts}")
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# === DAEMON MODE ===
|
||||||
|
|
||||||
|
run_daemon() {
|
||||||
|
log "DAEMON: Starting (interval=${CHECK_INTERVAL}s, stuck_threshold=${STUCK_CYCLES})"
|
||||||
|
echo "Pane watchdog started. Checking every ${CHECK_INTERVAL}s. Ctrl+C to stop."
|
||||||
|
echo "Log: $LOG_FILE"
|
||||||
|
echo "State: $STATE_FILE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
check_all_sessions
|
||||||
|
sleep "$CHECK_INTERVAL"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# === MAIN ===
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
--daemon)
|
||||||
|
run_daemon
|
||||||
|
;;
|
||||||
|
--status)
|
||||||
|
show_status
|
||||||
|
;;
|
||||||
|
--session)
|
||||||
|
if [ -z "${2:-}" ]; then
|
||||||
|
echo "Usage: pane-watchdog.sh --session SESSION_NAME"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
MONITOR_SESSIONS="$2"
|
||||||
|
check_all_sessions
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "pane-watchdog.sh — Detect stuck/dead tmux panes and auto-restart"
|
||||||
|
echo ""
|
||||||
|
echo "Usage:"
|
||||||
|
echo " pane-watchdog.sh # One-shot check"
|
||||||
|
echo " pane-watchdog.sh --daemon # Continuous monitoring"
|
||||||
|
echo " pane-watchdog.sh --status # Show pane state"
|
||||||
|
echo " pane-watchdog.sh --session S # Check one session"
|
||||||
|
echo ""
|
||||||
|
echo "Config (env vars):"
|
||||||
|
echo " PANE_CHECK_INTERVAL Seconds between checks (default: 120)"
|
||||||
|
echo " PANE_WATCHDOG_SESSIONS Comma-separated session names"
|
||||||
|
echo " PANE_STATE_FILE State file path"
|
||||||
|
echo " STUCK_CYCLES Unchanged cycles before STUCK (default: 2)"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
check_all_sessions
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Soul Eval Gate — The Conscience of the Training Pipeline
|
Soul Eval Gate — The Conscience of the Training Pipeline
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Uses Hermes CLI plus workforce-manager to triage and review.
|
# Uses Hermes CLI plus workforce-manager to triage and review.
|
||||||
# Timmy is the brain. Other agents are the hands.
|
# Timmy is the brain. Other agents are the hands.
|
||||||
|
|
||||||
set -uo pipefail
|
set -uo pipefail\n\nSCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
LOG_DIR="$HOME/.hermes/logs"
|
LOG_DIR="$HOME/.hermes/logs"
|
||||||
LOG="$LOG_DIR/timmy-orchestrator.log"
|
LOG="$LOG_DIR/timmy-orchestrator.log"
|
||||||
@@ -40,6 +40,7 @@ gather_state() {
|
|||||||
> "$state_dir/unassigned.txt"
|
> "$state_dir/unassigned.txt"
|
||||||
> "$state_dir/open_prs.txt"
|
> "$state_dir/open_prs.txt"
|
||||||
> "$state_dir/agent_status.txt"
|
> "$state_dir/agent_status.txt"
|
||||||
|
> "$state_dir/uncommitted_work.txt"
|
||||||
|
|
||||||
for repo in $REPOS; do
|
for repo in $REPOS; do
|
||||||
local short=$(echo "$repo" | cut -d/ -f2)
|
local short=$(echo "$repo" | cut -d/ -f2)
|
||||||
@@ -71,6 +72,24 @@ for p in json.load(sys.stdin):
|
|||||||
tail -50 "/tmp/kimi-heartbeat.log" 2>/dev/null | grep -c "FAILED:" | xargs -I{} echo "Kimi recent failures: {}" >> "$state_dir/agent_status.txt"
|
tail -50 "/tmp/kimi-heartbeat.log" 2>/dev/null | grep -c "FAILED:" | xargs -I{} echo "Kimi recent failures: {}" >> "$state_dir/agent_status.txt"
|
||||||
tail -1 "/tmp/kimi-heartbeat.log" 2>/dev/null | xargs -I{} echo "Kimi last event: {}" >> "$state_dir/agent_status.txt"
|
tail -1 "/tmp/kimi-heartbeat.log" 2>/dev/null | xargs -I{} echo "Kimi last event: {}" >> "$state_dir/agent_status.txt"
|
||||||
|
|
||||||
|
# Scan worktrees for uncommitted work
|
||||||
|
for wt_dir in "$HOME/worktrees"/*/; do
|
||||||
|
[ -d "$wt_dir" ] || continue
|
||||||
|
[ -d "$wt_dir/.git" ] || continue
|
||||||
|
local dirty
|
||||||
|
dirty=$(cd "$wt_dir" && git status --porcelain 2>/dev/null | wc -l | tr -d " ")
|
||||||
|
if [ "${dirty:-0}" -gt 0 ]; then
|
||||||
|
local branch
|
||||||
|
branch=$(cd "$wt_dir" && git branch --show-current 2>/dev/null || echo "?")
|
||||||
|
local age=""
|
||||||
|
local last_commit
|
||||||
|
last_commit=$(cd "$wt_dir" && git log -1 --format=%ct 2>/dev/null || echo 0)
|
||||||
|
local now=$(date +%s)
|
||||||
|
local stale_mins=$(( (now - last_commit) / 60 ))
|
||||||
|
echo "DIR=$wt_dir BRANCH=$branch DIRTY=$dirty STALE=${stale_mins}m" >> "$state_dir/uncommitted_work.txt"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
echo "$state_dir"
|
echo "$state_dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +100,25 @@ run_triage() {
|
|||||||
|
|
||||||
log "Cycle: $unassigned_count unassigned, $pr_count open PRs"
|
log "Cycle: $unassigned_count unassigned, $pr_count open PRs"
|
||||||
|
|
||||||
|
# Check for uncommitted work — nag if stale
|
||||||
|
local uncommitted_count
|
||||||
|
uncommitted_count=$(wc -l < "$state_dir/uncommitted_work.txt" 2>/dev/null | tr -d " " || echo 0)
|
||||||
|
if [ "${uncommitted_count:-0}" -gt 0 ]; then
|
||||||
|
log "WARNING: $uncommitted_count worktree(s) with uncommitted work"
|
||||||
|
while IFS= read -r line; do
|
||||||
|
log " UNCOMMITTED: $line"
|
||||||
|
# Auto-commit stale work (>60 min without commit)
|
||||||
|
local stale=$(echo "$line" | sed 's/.*STALE=\([0-9]*\)m.*/\1/')
|
||||||
|
local wt_dir=$(echo "$line" | sed 's/.*DIR=\([^ ]*\) .*/\1/')
|
||||||
|
if [ "${stale:-0}" -gt 60 ]; then
|
||||||
|
log " AUTO-COMMITTING stale work in $wt_dir (${stale}m stale)"
|
||||||
|
(cd "$wt_dir" && git add -A && git commit -m "WIP: orchestrator auto-commit — ${stale}m stale work
|
||||||
|
|
||||||
|
Preserved by timmy-orchestrator to prevent loss." 2>/dev/null && git push 2>/dev/null) && log " COMMITTED: $wt_dir" || log " COMMIT FAILED: $wt_dir"
|
||||||
|
fi
|
||||||
|
done < "$state_dir/uncommitted_work.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
# If nothing to do, skip the LLM call
|
# If nothing to do, skip the LLM call
|
||||||
if [ "$unassigned_count" -eq 0 ] && [ "$pr_count" -eq 0 ]; then
|
if [ "$unassigned_count" -eq 0 ] && [ "$pr_count" -eq 0 ]; then
|
||||||
log "Nothing to triage"
|
log "Nothing to triage"
|
||||||
@@ -198,6 +236,12 @@ FOOTER
|
|||||||
log "=== Timmy Orchestrator Started (PID $$) ==="
|
log "=== Timmy Orchestrator Started (PID $$) ==="
|
||||||
log "Cycle: ${CYCLE_INTERVAL}s | Auto-assign: ${AUTO_ASSIGN_UNASSIGNED} | Inference surface: Hermes CLI"
|
log "Cycle: ${CYCLE_INTERVAL}s | Auto-assign: ${AUTO_ASSIGN_UNASSIGNED} | Inference surface: Hermes CLI"
|
||||||
|
|
||||||
|
# Start auto-commit-guard daemon for work preservation
|
||||||
|
if ! pgrep -f "auto-commit-guard.sh" >/dev/null 2>&1; then
|
||||||
|
nohup bash "$SCRIPT_DIR/auto-commit-guard.sh" 120 >> "$LOG_DIR/auto-commit-guard.log" 2>&1 &
|
||||||
|
log "Started auto-commit-guard daemon (PID $!)"
|
||||||
|
fi
|
||||||
|
|
||||||
WORKFORCE_CYCLE=0
|
WORKFORCE_CYCLE=0
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
|
|||||||
97
bin/tmux-resume.sh
Executable file
97
bin/tmux-resume.sh
Executable file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ── tmux-resume.sh — Cold-start Session Resume ───────────────────────────
|
||||||
|
# Reads ~/.timmy/tmux-state.json and resumes hermes sessions.
|
||||||
|
# Run at startup to restore pane state after supervisor restart.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MANIFEST="${HOME}/.timmy/tmux-state.json"
|
||||||
|
|
||||||
|
if [ ! -f "$MANIFEST" ]; then
|
||||||
|
echo "[tmux-resume] No manifest found at $MANIFEST — starting fresh."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 << 'PYEOF'
|
||||||
|
import json, subprocess, os, sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
MANIFEST = os.path.expanduser("~/.timmy/tmux-state.json")
|
||||||
|
|
||||||
|
def run(cmd):
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
|
||||||
|
return r.stdout.strip(), r.returncode
|
||||||
|
except Exception as e:
|
||||||
|
return str(e), 1
|
||||||
|
|
||||||
|
def session_exists(name):
|
||||||
|
out, _ = run(f"tmux has-session -t '{name}' 2>&1")
|
||||||
|
return "can't find" not in out.lower()
|
||||||
|
|
||||||
|
with open(MANIFEST) as f:
|
||||||
|
state = json.load(f)
|
||||||
|
|
||||||
|
ts = state.get("timestamp", "unknown")
|
||||||
|
age = "unknown"
|
||||||
|
try:
|
||||||
|
t = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||||
|
delta = datetime.now(timezone.utc) - t
|
||||||
|
mins = int(delta.total_seconds() / 60)
|
||||||
|
if mins < 60:
|
||||||
|
age = f"{mins}m ago"
|
||||||
|
else:
|
||||||
|
age = f"{mins//60}h {mins%60}m ago"
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f"[tmux-resume] Manifest from {age}: {state['summary']['total_sessions']} sessions, "
|
||||||
|
f"{state['summary']['hermes_panes']} hermes panes")
|
||||||
|
|
||||||
|
restored = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for pane in state.get("panes", []):
|
||||||
|
if not pane.get("is_hermes"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
addr = pane["address"] # e.g. "BURN:2.3"
|
||||||
|
session = addr.split(":")[0]
|
||||||
|
session_id = pane.get("session_id")
|
||||||
|
profile = pane.get("profile", "default")
|
||||||
|
model = pane.get("model", "")
|
||||||
|
task = pane.get("task", "")
|
||||||
|
|
||||||
|
# Skip if session already exists (already running)
|
||||||
|
if session_exists(session):
|
||||||
|
print(f" [skip] {addr} — session '{session}' already exists")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Respawn hermes with session resume if we have a session ID
|
||||||
|
if session_id:
|
||||||
|
print(f" [resume] {addr} — profile={profile} model={model} session={session_id}")
|
||||||
|
cmd = f"hermes chat --resume {session_id}"
|
||||||
|
else:
|
||||||
|
print(f" [start] {addr} — profile={profile} model={model} (no session ID)")
|
||||||
|
cmd = f"hermes chat --profile {profile}"
|
||||||
|
|
||||||
|
# Create tmux session and run hermes
|
||||||
|
run(f"tmux new-session -d -s '{session}' -n '{session}:0'")
|
||||||
|
run(f"tmux send-keys -t '{session}' '{cmd}' Enter")
|
||||||
|
restored += 1
|
||||||
|
|
||||||
|
# Write resume log
|
||||||
|
log = {
|
||||||
|
"resumed_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"manifest_age": age,
|
||||||
|
"restored": restored,
|
||||||
|
"skipped": skipped,
|
||||||
|
}
|
||||||
|
log_path = os.path.expanduser("~/.timmy/tmux-resume.log")
|
||||||
|
with open(log_path, "w") as f:
|
||||||
|
json.dump(log, f, indent=2)
|
||||||
|
|
||||||
|
print(f"[tmux-resume] Done: {restored} restored, {skipped} skipped")
|
||||||
|
PYEOF
|
||||||
237
bin/tmux-state.sh
Executable file
237
bin/tmux-state.sh
Executable file
@@ -0,0 +1,237 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ── tmux-state.sh — Session State Persistence Manifest ───────────────────
|
||||||
|
# Snapshots all tmux pane state to ~/.timmy/tmux-state.json
|
||||||
|
# Run every supervisor cycle. Cold-start reads this manifest to resume.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MANIFEST="${HOME}/.timmy/tmux-state.json"
|
||||||
|
mkdir -p "$(dirname "$MANIFEST")"
|
||||||
|
|
||||||
|
python3 << 'PYEOF'
|
||||||
|
import json, subprocess, os, time, re, sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
MANIFEST = os.path.expanduser("~/.timmy/tmux-state.json")
|
||||||
|
|
||||||
|
def run(cmd):
|
||||||
|
"""Run command, return stdout or empty string."""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5)
|
||||||
|
return r.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_sessions():
|
||||||
|
"""Get all tmux sessions with metadata."""
|
||||||
|
raw = run("tmux list-sessions -F '#{session_name}|#{session_windows}|#{session_created}|#{session_attached}|#{session_group}|#{session_id}'")
|
||||||
|
sessions = []
|
||||||
|
for line in raw.splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split("|")
|
||||||
|
if len(parts) < 6:
|
||||||
|
continue
|
||||||
|
sessions.append({
|
||||||
|
"name": parts[0],
|
||||||
|
"windows": int(parts[1]),
|
||||||
|
"created_epoch": int(parts[2]),
|
||||||
|
"created": datetime.fromtimestamp(int(parts[2]), tz=timezone.utc).isoformat(),
|
||||||
|
"attached": parts[3] == "1",
|
||||||
|
"group": parts[4],
|
||||||
|
"id": parts[5],
|
||||||
|
})
|
||||||
|
return sessions
|
||||||
|
|
||||||
|
def get_panes():
|
||||||
|
"""Get all tmux panes with full metadata."""
|
||||||
|
fmt = '#{session_name}|#{window_index}|#{pane_index}|#{pane_pid}|#{pane_title}|#{pane_width}x#{pane_height}|#{pane_active}|#{pane_current_command}|#{pane_start_command}|#{pane_tty}|#{pane_id}|#{window_name}|#{session_id}'
|
||||||
|
raw = run(f"tmux list-panes -a -F '{fmt}'")
|
||||||
|
panes = []
|
||||||
|
for line in raw.splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split("|")
|
||||||
|
if len(parts) < 13:
|
||||||
|
continue
|
||||||
|
session, win, pane, pid, title, size, active, cmd, start_cmd, tty, pane_id, win_name, sess_id = parts[:13]
|
||||||
|
w, h = size.split("x") if "x" in size else ("0", "0")
|
||||||
|
panes.append({
|
||||||
|
"session": session,
|
||||||
|
"window_index": int(win),
|
||||||
|
"window_name": win_name,
|
||||||
|
"pane_index": int(pane),
|
||||||
|
"pane_id": pane_id,
|
||||||
|
"pid": int(pid) if pid.isdigit() else 0,
|
||||||
|
"title": title,
|
||||||
|
"width": int(w),
|
||||||
|
"height": int(h),
|
||||||
|
"active": active == "1",
|
||||||
|
"command": cmd,
|
||||||
|
"start_command": start_cmd,
|
||||||
|
"tty": tty,
|
||||||
|
"session_id": sess_id,
|
||||||
|
})
|
||||||
|
return panes
|
||||||
|
|
||||||
|
def extract_hermes_state(pane):
|
||||||
|
"""Try to extract hermes session info from a pane."""
|
||||||
|
info = {
|
||||||
|
"is_hermes": False,
|
||||||
|
"profile": None,
|
||||||
|
"model": None,
|
||||||
|
"provider": None,
|
||||||
|
"session_id": None,
|
||||||
|
"task": None,
|
||||||
|
}
|
||||||
|
title = pane.get("title", "")
|
||||||
|
cmd = pane.get("command", "")
|
||||||
|
start = pane.get("start_command", "")
|
||||||
|
|
||||||
|
# Detect hermes processes
|
||||||
|
is_hermes = any(k in (title + " " + cmd + " " + start).lower()
|
||||||
|
for k in ["hermes", "timmy", "mimo", "claude", "gpt"])
|
||||||
|
if not is_hermes and cmd not in ("python3", "python3.11", "bash", "zsh", "fish"):
|
||||||
|
return info
|
||||||
|
|
||||||
|
# Try reading pane content for model/provider clues
|
||||||
|
pane_content = run(f"tmux capture-pane -t '{pane['session']}:{pane['window_index']}.{pane['pane_index']}' -p -S -20 2>/dev/null")
|
||||||
|
|
||||||
|
# Extract model from pane content patterns
|
||||||
|
model_patterns = [
|
||||||
|
r"(?:mimo-v2-pro|claude-[\w.-]+|gpt-[\w.-]+|gemini-[\w.-]+|qwen[\w:.-]*)",
|
||||||
|
]
|
||||||
|
for pat in model_patterns:
|
||||||
|
m = re.search(pat, pane_content, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
info["model"] = m.group(0)
|
||||||
|
info["is_hermes"] = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Provider inference from model
|
||||||
|
model = (info["model"] or "").lower()
|
||||||
|
if "mimo" in model:
|
||||||
|
info["provider"] = "nous"
|
||||||
|
elif "claude" in model:
|
||||||
|
info["provider"] = "anthropic"
|
||||||
|
elif "gpt" in model:
|
||||||
|
info["provider"] = "openai"
|
||||||
|
elif "gemini" in model:
|
||||||
|
info["provider"] = "google"
|
||||||
|
elif "qwen" in model:
|
||||||
|
info["provider"] = "custom"
|
||||||
|
|
||||||
|
# Profile from session name
|
||||||
|
session = pane["session"].lower()
|
||||||
|
if "burn" in session:
|
||||||
|
info["profile"] = "burn"
|
||||||
|
elif session in ("dev", "0"):
|
||||||
|
info["profile"] = "default"
|
||||||
|
else:
|
||||||
|
info["profile"] = session
|
||||||
|
|
||||||
|
# Try to extract session ID (hermes uses UUIDs)
|
||||||
|
uuid_match = re.findall(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', pane_content)
|
||||||
|
if uuid_match:
|
||||||
|
info["session_id"] = uuid_match[-1] # most recent
|
||||||
|
info["is_hermes"] = True
|
||||||
|
|
||||||
|
# Last prompt — grab the last user-like line
|
||||||
|
lines = pane_content.splitlines()
|
||||||
|
for line in reversed(lines):
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped and not stripped.startswith(("─", "│", "╭", "╰", "▸", "●", "○")) and len(stripped) > 10:
|
||||||
|
info["task"] = stripped[:200]
|
||||||
|
break
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
def get_context_percent(pane):
|
||||||
|
"""Estimate context usage from pane content heuristics."""
|
||||||
|
content = run(f"tmux capture-pane -t '{pane['session']}:{pane['window_index']}.{pane['pane_index']}' -p -S -5 2>/dev/null")
|
||||||
|
# Look for context indicators like "ctx 45%" or "[░░░░░░░░░░]"
|
||||||
|
ctx_match = re.search(r'ctx\s*(\d+)%', content)
|
||||||
|
if ctx_match:
|
||||||
|
return int(ctx_match.group(1))
|
||||||
|
bar_match = re.search(r'\[(░+█*█*░*)\]', content)
|
||||||
|
if bar_match:
|
||||||
|
bar = bar_match.group(1)
|
||||||
|
filled = bar.count('█')
|
||||||
|
total = len(bar)
|
||||||
|
if total > 0:
|
||||||
|
return int((filled / total) * 100)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def build_manifest():
|
||||||
|
"""Build the full tmux state manifest."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
sessions = get_sessions()
|
||||||
|
panes = get_panes()
|
||||||
|
|
||||||
|
pane_manifests = []
|
||||||
|
for p in panes:
|
||||||
|
hermes = extract_hermes_state(p)
|
||||||
|
ctx = get_context_percent(p)
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"address": f"{p['session']}:{p['window_index']}.{p['pane_index']}",
|
||||||
|
"pane_id": p["pane_id"],
|
||||||
|
"pid": p["pid"],
|
||||||
|
"size": f"{p['width']}x{p['height']}",
|
||||||
|
"active": p["active"],
|
||||||
|
"command": p["command"],
|
||||||
|
"title": p["title"],
|
||||||
|
"profile": hermes["profile"],
|
||||||
|
"model": hermes["model"],
|
||||||
|
"provider": hermes["provider"],
|
||||||
|
"session_id": hermes["session_id"],
|
||||||
|
"task": hermes["task"],
|
||||||
|
"context_pct": ctx,
|
||||||
|
"is_hermes": hermes["is_hermes"],
|
||||||
|
}
|
||||||
|
pane_manifests.append(entry)
|
||||||
|
|
||||||
|
# Active pane summary
|
||||||
|
active_panes = [p for p in pane_manifests if p["active"]]
|
||||||
|
primary = active_panes[0] if active_panes else {}
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"version": 1,
|
||||||
|
"timestamp": now.isoformat(),
|
||||||
|
"timestamp_epoch": int(now.timestamp()),
|
||||||
|
"hostname": os.uname().nodename,
|
||||||
|
"sessions": sessions,
|
||||||
|
"panes": pane_manifests,
|
||||||
|
"summary": {
|
||||||
|
"total_sessions": len(sessions),
|
||||||
|
"total_panes": len(pane_manifests),
|
||||||
|
"hermes_panes": sum(1 for p in pane_manifests if p["is_hermes"]),
|
||||||
|
"active_pane": primary.get("address"),
|
||||||
|
"active_model": primary.get("model"),
|
||||||
|
"active_provider": primary.get("provider"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
# --- Main ---
|
||||||
|
manifest = build_manifest()
|
||||||
|
|
||||||
|
# Write manifest
|
||||||
|
with open(MANIFEST, "w") as f:
|
||||||
|
json.dump(manifest, f, indent=2)
|
||||||
|
|
||||||
|
# Also write to ~/.hermes/tmux-state.json for compatibility
|
||||||
|
hermes_manifest = os.path.expanduser("~/.hermes/tmux-state.json")
|
||||||
|
os.makedirs(os.path.dirname(hermes_manifest), exist_ok=True)
|
||||||
|
with open(hermes_manifest, "w") as f:
|
||||||
|
json.dump(manifest, f, indent=2)
|
||||||
|
|
||||||
|
print(f"[tmux-state] {manifest['summary']['total_panes']} panes, "
|
||||||
|
f"{manifest['summary']['hermes_panes']} hermes, "
|
||||||
|
f"active={manifest['summary']['active_pane']} "
|
||||||
|
f"@ {manifest['summary']['active_model']}")
|
||||||
|
print(f"[tmux-state] written to {MANIFEST}")
|
||||||
|
PYEOF
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"updated_at": "2026-03-28T09:54:34.822062",
|
"updated_at": "2026-04-13T02:02:07.001824",
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"discord": [
|
"discord": [
|
||||||
{
|
{
|
||||||
@@ -27,11 +27,81 @@
|
|||||||
"name": "Timmy Time",
|
"name": "Timmy Time",
|
||||||
"type": "group",
|
"type": "group",
|
||||||
"thread_id": null
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "-1003664764329:244",
|
||||||
|
"name": "Timmy Time / topic 244",
|
||||||
|
"type": "group",
|
||||||
|
"thread_id": "244"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "-1003664764329:972",
|
||||||
|
"name": "Timmy Time / topic 972",
|
||||||
|
"type": "group",
|
||||||
|
"thread_id": "972"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "-1003664764329:931",
|
||||||
|
"name": "Timmy Time / topic 931",
|
||||||
|
"type": "group",
|
||||||
|
"thread_id": "931"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "-1003664764329:957",
|
||||||
|
"name": "Timmy Time / topic 957",
|
||||||
|
"type": "group",
|
||||||
|
"thread_id": "957"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "-1003664764329:1297",
|
||||||
|
"name": "Timmy Time / topic 1297",
|
||||||
|
"type": "group",
|
||||||
|
"thread_id": "1297"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "-1003664764329:1316",
|
||||||
|
"name": "Timmy Time / topic 1316",
|
||||||
|
"type": "group",
|
||||||
|
"thread_id": "1316"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"whatsapp": [],
|
"whatsapp": [],
|
||||||
|
"slack": [],
|
||||||
"signal": [],
|
"signal": [],
|
||||||
|
"mattermost": [],
|
||||||
|
"matrix": [],
|
||||||
|
"homeassistant": [],
|
||||||
"email": [],
|
"email": [],
|
||||||
"sms": []
|
"sms": [],
|
||||||
|
"dingtalk": [],
|
||||||
|
"feishu": [],
|
||||||
|
"wecom": [],
|
||||||
|
"wecom_callback": [],
|
||||||
|
"weixin": [],
|
||||||
|
"bluebubbles": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
218
config.yaml
218
config.yaml
@@ -1,31 +1,23 @@
|
|||||||
model:
|
model:
|
||||||
default: hermes4:14b
|
default: claude-opus-4-6
|
||||||
provider: custom
|
provider: anthropic
|
||||||
context_length: 65536
|
|
||||||
base_url: http://localhost:8081/v1
|
|
||||||
toolsets:
|
toolsets:
|
||||||
- all
|
- all
|
||||||
agent:
|
agent:
|
||||||
max_turns: 30
|
max_turns: 30
|
||||||
reasoning_effort: xhigh
|
reasoning_effort: medium
|
||||||
verbose: false
|
verbose: false
|
||||||
terminal:
|
terminal:
|
||||||
backend: local
|
backend: local
|
||||||
cwd: .
|
cwd: .
|
||||||
timeout: 180
|
timeout: 180
|
||||||
env_passthrough: []
|
|
||||||
docker_image: nikolaik/python-nodejs:python3.11-nodejs20
|
docker_image: nikolaik/python-nodejs:python3.11-nodejs20
|
||||||
docker_forward_env: []
|
docker_forward_env: []
|
||||||
singularity_image: docker://nikolaik/python-nodejs:python3.11-nodejs20
|
singularity_image: docker://nikolaik/python-nodejs:python3.11-nodejs20
|
||||||
modal_image: nikolaik/python-nodejs:python3.11-nodejs20
|
modal_image: nikolaik/python-nodejs:python3.11-nodejs20
|
||||||
daytona_image: nikolaik/python-nodejs:python3.11-nodejs20
|
daytona_image: nikolaik/python-nodejs:python3.11-nodejs20
|
||||||
container_cpu: 1
|
container_cpu: 1
|
||||||
container_embeddings:
|
container_memory: 5120
|
||||||
provider: ollama
|
|
||||||
model: nomic-embed-text
|
|
||||||
base_url: http://localhost:11434/v1
|
|
||||||
|
|
||||||
memory: 5120
|
|
||||||
container_disk: 51200
|
container_disk: 51200
|
||||||
container_persistent: true
|
container_persistent: true
|
||||||
docker_volumes: []
|
docker_volumes: []
|
||||||
@@ -33,89 +25,74 @@ memory: 5120
|
|||||||
persistent_shell: true
|
persistent_shell: true
|
||||||
browser:
|
browser:
|
||||||
inactivity_timeout: 120
|
inactivity_timeout: 120
|
||||||
command_timeout: 30
|
|
||||||
record_sessions: false
|
record_sessions: false
|
||||||
checkpoints:
|
checkpoints:
|
||||||
enabled: true
|
enabled: false
|
||||||
max_snapshots: 50
|
max_snapshots: 50
|
||||||
compression:
|
compression:
|
||||||
enabled: true
|
enabled: true
|
||||||
threshold: 0.5
|
threshold: 0.5
|
||||||
target_ratio: 0.2
|
summary_model: qwen3:30b
|
||||||
protect_last_n: 20
|
summary_provider: custom
|
||||||
summary_model: ''
|
summary_base_url: http://localhost:11434/v1
|
||||||
summary_provider: ''
|
|
||||||
summary_base_url: ''
|
|
||||||
synthesis_model:
|
|
||||||
provider: custom
|
|
||||||
model: llama3:70b
|
|
||||||
base_url: http://localhost:8081/v1
|
|
||||||
|
|
||||||
smart_model_routing:
|
smart_model_routing:
|
||||||
enabled: true
|
enabled: false
|
||||||
max_simple_chars: 400
|
max_simple_chars: 160
|
||||||
max_simple_words: 75
|
max_simple_words: 28
|
||||||
cheap_model:
|
cheap_model: {}
|
||||||
provider: 'ollama'
|
|
||||||
model: 'gemma2:2b'
|
|
||||||
base_url: 'http://localhost:11434/v1'
|
|
||||||
api_key: ''
|
|
||||||
auxiliary:
|
auxiliary:
|
||||||
vision:
|
vision:
|
||||||
provider: auto
|
provider: custom
|
||||||
model: ''
|
model: qwen3:30b
|
||||||
base_url: ''
|
base_url: 'http://localhost:11434/v1'
|
||||||
api_key: ''
|
api_key: 'ollama'
|
||||||
timeout: 30
|
|
||||||
web_extract:
|
web_extract:
|
||||||
provider: auto
|
provider: custom
|
||||||
model: ''
|
model: qwen3:30b
|
||||||
base_url: ''
|
base_url: 'http://localhost:11434/v1'
|
||||||
api_key: ''
|
api_key: 'ollama'
|
||||||
compression:
|
compression:
|
||||||
provider: auto
|
provider: custom
|
||||||
model: ''
|
model: qwen3:30b
|
||||||
base_url: ''
|
base_url: 'http://localhost:11434/v1'
|
||||||
api_key: ''
|
api_key: 'ollama'
|
||||||
session_search:
|
session_search:
|
||||||
provider: auto
|
provider: custom
|
||||||
model: ''
|
model: qwen3:30b
|
||||||
base_url: ''
|
base_url: 'http://localhost:11434/v1'
|
||||||
api_key: ''
|
api_key: 'ollama'
|
||||||
skills_hub:
|
skills_hub:
|
||||||
provider: auto
|
provider: custom
|
||||||
model: ''
|
model: qwen3:30b
|
||||||
base_url: ''
|
base_url: 'http://localhost:11434/v1'
|
||||||
api_key: ''
|
api_key: 'ollama'
|
||||||
approval:
|
approval:
|
||||||
provider: auto
|
provider: auto
|
||||||
model: ''
|
model: ''
|
||||||
base_url: ''
|
base_url: ''
|
||||||
api_key: ''
|
api_key: ''
|
||||||
mcp:
|
mcp:
|
||||||
provider: auto
|
provider: custom
|
||||||
model: ''
|
model: qwen3:30b
|
||||||
base_url: ''
|
base_url: 'http://localhost:11434/v1'
|
||||||
api_key: ''
|
api_key: 'ollama'
|
||||||
flush_memories:
|
flush_memories:
|
||||||
provider: auto
|
provider: custom
|
||||||
model: ''
|
model: qwen3:30b
|
||||||
base_url: ''
|
base_url: 'http://localhost:11434/v1'
|
||||||
api_key: ''
|
api_key: 'ollama'
|
||||||
display:
|
display:
|
||||||
compact: false
|
compact: false
|
||||||
personality: ''
|
personality: ''
|
||||||
resume_display: full
|
resume_display: full
|
||||||
busy_input_mode: interrupt
|
|
||||||
bell_on_complete: false
|
bell_on_complete: false
|
||||||
show_reasoning: false
|
show_reasoning: false
|
||||||
streaming: false
|
streaming: false
|
||||||
show_cost: false
|
show_cost: false
|
||||||
skin: timmy
|
skin: timmy
|
||||||
tool_progress_command: false
|
|
||||||
tool_progress: all
|
tool_progress: all
|
||||||
privacy:
|
privacy:
|
||||||
redact_pii: true
|
redact_pii: false
|
||||||
tts:
|
tts:
|
||||||
provider: edge
|
provider: edge
|
||||||
edge:
|
edge:
|
||||||
@@ -124,7 +101,7 @@ tts:
|
|||||||
voice_id: pNInz6obpgDQGcFmaJgB
|
voice_id: pNInz6obpgDQGcFmaJgB
|
||||||
model_id: eleven_multilingual_v2
|
model_id: eleven_multilingual_v2
|
||||||
openai:
|
openai:
|
||||||
model: '' # disabled — use edge TTS locally
|
model: gpt-4o-mini-tts
|
||||||
voice: alloy
|
voice: alloy
|
||||||
neutts:
|
neutts:
|
||||||
ref_audio: ''
|
ref_audio: ''
|
||||||
@@ -160,7 +137,6 @@ delegation:
|
|||||||
provider: ''
|
provider: ''
|
||||||
base_url: ''
|
base_url: ''
|
||||||
api_key: ''
|
api_key: ''
|
||||||
max_iterations: 50
|
|
||||||
prefill_messages_file: ''
|
prefill_messages_file: ''
|
||||||
honcho: {}
|
honcho: {}
|
||||||
timezone: ''
|
timezone: ''
|
||||||
@@ -174,16 +150,7 @@ approvals:
|
|||||||
command_allowlist: []
|
command_allowlist: []
|
||||||
quick_commands: {}
|
quick_commands: {}
|
||||||
personalities: {}
|
personalities: {}
|
||||||
mesh:
|
|
||||||
enabled: true
|
|
||||||
blackboard_provider: local
|
|
||||||
nostr_discovery: true
|
|
||||||
consensus_mode: competitive
|
|
||||||
|
|
||||||
security:
|
security:
|
||||||
sovereign_audit: true
|
|
||||||
no_phone_home: true
|
|
||||||
|
|
||||||
redact_secrets: true
|
redact_secrets: true
|
||||||
tirith_enabled: true
|
tirith_enabled: true
|
||||||
tirith_path: tirith
|
tirith_path: tirith
|
||||||
@@ -193,55 +160,66 @@ security:
|
|||||||
enabled: false
|
enabled: false
|
||||||
domains: []
|
domains: []
|
||||||
shared_files: []
|
shared_files: []
|
||||||
_config_version: 10
|
# Author whitelist for task router (Issue #132)
|
||||||
platforms:
|
# Only users in this list can submit tasks via Gitea issues
|
||||||
api_server:
|
# Empty list = deny all (secure by default)
|
||||||
enabled: true
|
# Set via env var TIMMY_AUTHOR_WHITELIST as comma-separated list
|
||||||
extra:
|
author_whitelist: []
|
||||||
host: 0.0.0.0
|
_config_version: 9
|
||||||
port: 8642
|
|
||||||
session_reset:
|
session_reset:
|
||||||
mode: none
|
mode: none
|
||||||
idle_minutes: 0
|
idle_minutes: 0
|
||||||
custom_providers:
|
custom_providers:
|
||||||
- name: Local llama.cpp
|
- name: Local Ollama
|
||||||
base_url: http://localhost:8081/v1
|
base_url: http://localhost:11434/v1
|
||||||
api_key: none
|
api_key: ollama
|
||||||
model: hermes4:14b
|
model: qwen3:30b
|
||||||
# ── Emergency cloud provider — not used by default or any cron job.
|
|
||||||
# Available for explicit override only: hermes --model gemini-2.5-pro
|
|
||||||
- name: Google Gemini (emergency only)
|
|
||||||
base_url: https://generativelanguage.googleapis.com/v1beta/openai
|
|
||||||
api_key_env: GEMINI_API_KEY
|
|
||||||
model: gemini-2.5-pro
|
|
||||||
system_prompt_suffix: "You are Timmy. Your soul is defined in SOUL.md \u2014 read\
|
system_prompt_suffix: "You are Timmy. Your soul is defined in SOUL.md \u2014 read\
|
||||||
\ it, live it.\nYou run locally on your owner's machine via llama.cpp. You never\
|
\ it, live it.\nYou run locally on your owner's machine via Ollama. You never phone\
|
||||||
\ phone home.\nYou speak plainly. You prefer short sentences. Brevity is a kindness.\n\
|
\ 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\
|
Source distinction: Tag every factual claim inline. Default is [generated] — you\
|
||||||
\ service always.\n"
|
\ are pattern-matching from training data. Only use [retrieved] when you can name\
|
||||||
|
\ the specific tool call or document from THIS conversation that provided the fact.\
|
||||||
|
\ If no tool was called, every claim is [generated]. No exceptions.\n\
|
||||||
|
Refusal over fabrication: When you generate a specific claim — a date, a number,\
|
||||||
|
\ a price, a version, a URL, a current event — and you cannot name a source from\
|
||||||
|
\ this conversation, say 'I don't know' instead. Do not guess. Do not hedge with\
|
||||||
|
\ 'probably' or 'approximately' as a substitute for knowledge. If your only source\
|
||||||
|
\ is training data and the claim could be wrong or outdated, the honest answer is\
|
||||||
|
\ 'I don't know — I can look this up if you'd like.' Prefer a true 'I don't know'\
|
||||||
|
\ over a plausible fabrication.\nSovereignty and service always.\n"
|
||||||
skills:
|
skills:
|
||||||
creation_nudge_interval: 15
|
creation_nudge_interval: 15
|
||||||
DISCORD_HOME_CHANNEL: '1476292315814297772'
|
|
||||||
providers:
|
# ── Fallback Model ────────────────────────────────────────────────────
|
||||||
ollama:
|
# Automatic provider failover when primary is unavailable.
|
||||||
base_url: http://localhost:11434/v1
|
# Uncomment and configure to enable. Triggers on rate limits (429),
|
||||||
model: hermes3:latest
|
# overload (529), service errors (503), or connection failures.
|
||||||
mcp_servers:
|
#
|
||||||
morrowind:
|
# Supported providers:
|
||||||
command: python3
|
# openrouter (OPENROUTER_API_KEY) — routes to any model
|
||||||
args:
|
# openai-codex (OAuth — hermes login) — OpenAI Codex
|
||||||
- /Users/apayne/.timmy/morrowind/mcp_server.py
|
# nous (OAuth — hermes login) — Nous Portal
|
||||||
env: {}
|
# zai (ZAI_API_KEY) — Z.AI / GLM
|
||||||
timeout: 30
|
# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot
|
||||||
crucible:
|
# minimax (MINIMAX_API_KEY) — MiniMax
|
||||||
command: /Users/apayne/.hermes/hermes-agent/venv/bin/python3
|
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
|
||||||
args:
|
#
|
||||||
- /Users/apayne/.hermes/bin/crucible_mcp_server.py
|
# For custom OpenAI-compatible endpoints, add base_url and api_key_env.
|
||||||
env: {}
|
#
|
||||||
timeout: 120
|
# fallback_model:
|
||||||
connect_timeout: 60
|
# provider: openrouter
|
||||||
fallback_model:
|
# model: anthropic/claude-sonnet-4
|
||||||
provider: ollama
|
#
|
||||||
model: hermes3:latest
|
# ── Smart Model Routing ────────────────────────────────────────────────
|
||||||
base_url: http://localhost:11434/v1
|
# Optional cheap-vs-strong routing for simple turns.
|
||||||
api_key: ''
|
# 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
|
||||||
|
|||||||
@@ -168,7 +168,35 @@
|
|||||||
"paused_reason": null,
|
"paused_reason": null,
|
||||||
"skills": [],
|
"skills": [],
|
||||||
"skill": null
|
"skill": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "overnight-rd-nightly",
|
||||||
|
"name": "Overnight R&D Loop",
|
||||||
|
"prompt": "Run the overnight R&D automation: Deep Dive paper synthesis, tightening loop for tool-use training data, DPO export sweep, morning briefing prep. All local inference via Ollama.",
|
||||||
|
"schedule": {
|
||||||
|
"kind": "cron",
|
||||||
|
"expr": "0 2 * * *",
|
||||||
|
"display": "0 2 * * * (10 PM EDT)"
|
||||||
|
},
|
||||||
|
"schedule_display": "Nightly at 10 PM EDT",
|
||||||
|
"repeat": {
|
||||||
|
"times": null,
|
||||||
|
"completed": 0
|
||||||
|
},
|
||||||
|
"enabled": true,
|
||||||
|
"created_at": "2026-04-13T02:00:00+00:00",
|
||||||
|
"next_run_at": null,
|
||||||
|
"last_run_at": null,
|
||||||
|
"last_status": null,
|
||||||
|
"last_error": null,
|
||||||
|
"deliver": "local",
|
||||||
|
"origin": "perplexity/overnight-rd-automation",
|
||||||
|
"state": "scheduled",
|
||||||
|
"paused_at": null,
|
||||||
|
"paused_reason": null,
|
||||||
|
"skills": [],
|
||||||
|
"skill": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"updated_at": "2026-04-07T15:00:00+00:00"
|
"updated_at": "2026-04-13T02:00:00+00:00"
|
||||||
}
|
}
|
||||||
|
|||||||
9
cron/pipeline-scheduler.yml
Normal file
9
cron/pipeline-scheduler.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
- name: Nightly Pipeline Scheduler
|
||||||
|
schedule: '*/30 18-23,0-8 * * *' # Every 30 min, off-peak hours only
|
||||||
|
tasks:
|
||||||
|
- name: Check and start pipelines
|
||||||
|
shell: "bash scripts/nightly-pipeline-scheduler.sh"
|
||||||
|
env:
|
||||||
|
PIPELINE_TOKEN_LIMIT: "500000"
|
||||||
|
PIPELINE_PEAK_START: "9"
|
||||||
|
PIPELINE_PEAK_END: "18"
|
||||||
24
deploy/auto-commit-guard.plist
Normal file
24
deploy/auto-commit-guard.plist
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>ai.timmy.auto-commit-guard</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/bin/bash</string>
|
||||||
|
<string>/Users/apayne/.hermes/bin/auto-commit-guard.sh</string>
|
||||||
|
<string>120</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/Users/apayne/.hermes/logs/auto-commit-guard.stdout.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/Users/apayne/.hermes/logs/auto-commit-guard.stderr.log</string>
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>/Users/apayne</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
21
deploy/gitea-a11y/README.md
Normal file
21
deploy/gitea-a11y/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Gitea Accessibility Fix - R4: Time Elements
|
||||||
|
|
||||||
|
WCAG 1.3.1: Relative timestamps lack machine-readable fallbacks.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
Wrap relative timestamps in `<time datetime="...">` elements.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `custom/templates/custom/time_relative.tmpl` - Reusable `<time>` helper
|
||||||
|
- `custom/templates/repo/list_a11y.tmpl` - Explore/Repos list override
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r custom/templates/* /path/to/gitea/custom/templates/
|
||||||
|
systemctl restart gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
Closes #554
|
||||||
27
deploy/gitea-a11y/custom/templates/custom/time_relative.tmpl
Normal file
27
deploy/gitea-a11y/custom/templates/custom/time_relative.tmpl
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{{/*
|
||||||
|
Gitea a11y fix: R4 <time> elements for relative timestamps
|
||||||
|
Deploy to: custom/templates/custom/time_relative.tmpl
|
||||||
|
*/}}
|
||||||
|
|
||||||
|
{{define "custom/time_relative"}}
|
||||||
|
{{if and .Time .Relative}}
|
||||||
|
<time datetime="{{.Time.Format "2006-01-02T15:04:05Z07:00"}}" title="{{.Time.Format "Jan 02, 2006 15:04"}}">
|
||||||
|
{{.Relative}}
|
||||||
|
</time>
|
||||||
|
{{else if .Relative}}
|
||||||
|
<span>{{.Relative}}</span>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "custom/time_from_unix"}}
|
||||||
|
{{if .Relative}}
|
||||||
|
<time datetime="" data-unix="{{.Unix}}" title="">{{.Relative}}</time>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var el = document.currentScript.previousElementSibling;
|
||||||
|
var unix = parseInt(el.getAttribute('data-unix'));
|
||||||
|
if (unix) { el.setAttribute('datetime', new Date(unix * 1000).toISOString()); el.setAttribute('title', new Date(unix * 1000).toLocaleString()); }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
27
deploy/gitea-a11y/custom/templates/repo/list_a11y.tmpl
Normal file
27
deploy/gitea-a11y/custom/templates/repo/list_a11y.tmpl
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{{/*
|
||||||
|
Gitea a11y fix: R4 <time> elements for relative timestamps on repo list
|
||||||
|
Deploy to: custom/templates/repo/list_a11y.tmpl
|
||||||
|
*/}}
|
||||||
|
|
||||||
|
{{/* Star count link with aria-label */}}
|
||||||
|
<a class="repo-card-star" href="{{.RepoLink}}/stars" aria-label="{{.NumStars}} stars" title="{{.NumStars}} stars">
|
||||||
|
<svg class="octicon octicon-star" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||||
|
<path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{.NumStars}}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{/* Fork count link with aria-label */}}
|
||||||
|
<a class="repo-card-fork" href="{{.RepoLink}}/forks" aria-label="{{.NumForks}} forks" title="{{.NumForks}} forks">
|
||||||
|
<svg class="octicon octicon-repo-forked" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||||
|
<path d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75v-.878a2.25 2.25 0 111.5 0v.878a2.25 2.25 0 01-2.25 2.25h-1.5v2.128a2.251 2.251 0 11-1.5 0V8.5h-1.5A2.25 2.25 0 013.5 6.25v-.878a2.25 2.25 0 111.5 0zM5 3.25a.75.75 0 10-1.5 0 .75.75 0 001.5 0zm6.75.75a.75.75 0 100-1.5.75.75 0 000 1.5zm-3 8.75a.75.75 0 10-1.5 0 .75.75 0 001.5 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{.NumForks}}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{/* Relative timestamp with <time> element for a11y */}}
|
||||||
|
{{if .UpdatedUnix}}
|
||||||
|
<time datetime="{{.UpdatedUnix | TimeSinceISO}}" title="{{.UpdatedUnix | DateFmtLong}}" class="text-light">
|
||||||
|
{{.UpdatedUnix | TimeSince}}
|
||||||
|
</time>
|
||||||
|
{{end}}
|
||||||
150
docs/a11y-audit-2026-04-13.md
Normal file
150
docs/a11y-audit-2026-04-13.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# Visual Accessibility Audit — Foundation Web Properties
|
||||||
|
|
||||||
|
**Issue:** timmy-config #492
|
||||||
|
**Date:** 2026-04-13
|
||||||
|
**Label:** gemma-4-multimodal
|
||||||
|
**Scope:** forge.alexanderwhitestone.com (Gitea 1.25.4)
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The Foundation's primary accessible web property is the Gitea forge. The Matrix homeserver (matrix.timmy.foundation) is currently unreachable (DNS/SSL issues). This audit covers the forge across three page types: Homepage, Login, and Explore/Repositories.
|
||||||
|
|
||||||
|
**Overall: 6 WCAG 2.1 AA violations found, 4 best-practice recommendations.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pages Audited
|
||||||
|
|
||||||
|
| Page | URL | Status |
|
||||||
|
|------|-----|--------|
|
||||||
|
| Homepage | forge.alexanderwhitestone.com | Live |
|
||||||
|
| Sign In | forge.alexanderwhitestone.com/user/login | Live |
|
||||||
|
| Explore Repos | forge.alexanderwhitestone.com/explore/repos | Live |
|
||||||
|
| Matrix/Element | matrix.timmy.foundation | DOWN (DNS/SSL) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### P1 — Violations (WCAG 2.1 AA)
|
||||||
|
|
||||||
|
#### V1: No Skip Navigation Link (2.4.1)
|
||||||
|
- **Pages:** All
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **Description:** No "Skip to content" link exists. Keyboard users must tab through the full navigation on every page load.
|
||||||
|
- **Evidence:** Programmatic check returned `skipNav: false`
|
||||||
|
- **Fix:** Add `<a href="#main" class="skip-link">Skip to content</a>` visually hidden until focused.
|
||||||
|
|
||||||
|
#### V2: 25 Form Inputs Without Labels (1.3.1, 3.3.2)
|
||||||
|
- **Pages:** Explore/Repositories (filter dropdowns)
|
||||||
|
- **Severity:** High
|
||||||
|
- **Description:** The search input and all radio buttons in the Filter/Sort dropdowns lack programmatic label associations.
|
||||||
|
- **Evidence:** Programmatic check found 25 inputs without `label[for=]`, `aria-label`, or `aria-labelledby`
|
||||||
|
- **Affected inputs:** `q` (search), `archived` (x2), `fork` (x2), `mirror` (x2), `template` (x2), `private` (x2), `sort` (x12), `clear-filter` (x1)
|
||||||
|
- **Fix:** Add `aria-label="Search repositories"` to search input. Add `aria-label` to each radio button group and individual options.
|
||||||
|
|
||||||
|
#### V3: Low-Contrast Footer Text (1.4.3)
|
||||||
|
- **Pages:** All
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **Description:** Footer text (version, page render time) appears light gray on white, likely failing the 4.5:1 contrast ratio.
|
||||||
|
- **Evidence:** 30 elements flagged as potential low-contrast suspects.
|
||||||
|
- **Fix:** Darken footer text to at least `#767676` on white (4.54:1 ratio).
|
||||||
|
|
||||||
|
#### V4: Green Link Color Fails Contrast (1.4.3)
|
||||||
|
- **Pages:** Homepage
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **Description:** Inline links use medium-green (~#609926) on white. This shade typically fails 4.5:1 for normal body text.
|
||||||
|
- **Evidence:** Visual analysis identified green links ("run the binary", "Docker", "contributing") as potentially failing.
|
||||||
|
- **Fix:** Darken link color to at least `#507020` or add an underline for non-color differentiation (SC 1.4.1).
|
||||||
|
|
||||||
|
#### V5: Missing Header/Banner Landmark (1.3.1)
|
||||||
|
- **Pages:** All
|
||||||
|
- **Severity:** Low
|
||||||
|
- **Description:** No `<header>` or `role="banner"` element found. The navigation bar is a `<nav>` but not wrapped in a banner landmark.
|
||||||
|
- **Evidence:** `landmarks.banner: 0`
|
||||||
|
- **Fix:** Wrap the top navigation in `<header>` or add `role="banner"`.
|
||||||
|
|
||||||
|
#### V6: Heading Hierarchy Issue (1.3.1)
|
||||||
|
- **Pages:** Login
|
||||||
|
- **Severity:** Low
|
||||||
|
- **Description:** The Sign In heading is `<h4>` rather than `<h1>`, breaking the heading hierarchy. The page has no `<h1>`.
|
||||||
|
- **Evidence:** Accessibility tree shows `heading "Sign In" [level=4]`
|
||||||
|
- **Fix:** Use `<h1>` for "Sign In" on the login page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P2 — Best Practice Recommendations
|
||||||
|
|
||||||
|
#### R1: Add Password Visibility Toggle
|
||||||
|
- **Page:** Login
|
||||||
|
- **Description:** No show/hide toggle on the password field. This helps users with cognitive or motor impairments verify input.
|
||||||
|
|
||||||
|
#### R2: Add `aria-required` to Required Fields
|
||||||
|
- **Page:** Login
|
||||||
|
- **Evidence:** `inputsWithAriaRequired: 0` (no inputs marked as required)
|
||||||
|
- **Description:** The username field shows a red asterisk but has no `required` or `aria-required="true"` attribute.
|
||||||
|
|
||||||
|
#### R3: Improve Star/Fork Link Labels
|
||||||
|
- **Page:** Explore Repos
|
||||||
|
- **Description:** Star and fork counts are bare numbers (e.g., "0", "2"). Screen readers announce these without context.
|
||||||
|
- **Fix:** Add `aria-label="2 stars"` / `aria-label="0 forks"` to count links.
|
||||||
|
|
||||||
|
#### R4: Use `<time>` Elements for Timestamps
|
||||||
|
- **Page:** Explore Repos
|
||||||
|
- **Description:** Relative timestamps ("2 minutes ago") are human-readable but lack machine-readable fallbacks.
|
||||||
|
- **Fix:** Wrap in `<time datetime="2026-04-13T17:00:00Z">2 minutes ago</time>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Working Well
|
||||||
|
|
||||||
|
- **Color contrast (primary):** Black text on white backgrounds — excellent 21:1 ratio.
|
||||||
|
- **Heading structure (homepage):** Clean h1 > h2 > h3 hierarchy.
|
||||||
|
- **Landmark regions:** `<main>` and `<nav>` landmarks present.
|
||||||
|
- **Language attribute:** `lang="en-US"` set on `<html>`.
|
||||||
|
- **Link text:** Descriptive — no "click here" or "read more" patterns found.
|
||||||
|
- **Form layout:** Login form uses clean single-column with good spacing.
|
||||||
|
- **Submit button:** Full-width, good contrast, large touch target.
|
||||||
|
- **Navigation:** Simple, consistent across pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- **matrix.timmy.foundation:** Unreachable (DNS resolution failure / SSL cert mismatch). Should be re-audited when operational.
|
||||||
|
- **Evennia web client (localhost:4001):** Local-only, not publicly accessible.
|
||||||
|
- **WCAG AAA criteria:** This audit covers AA only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remediation Priority
|
||||||
|
|
||||||
|
| Priority | Issue | Effort |
|
||||||
|
|----------|-------|--------|
|
||||||
|
| P1 | V2: 25 unlabeled inputs | Medium |
|
||||||
|
| P1 | V1: Skip nav link | Small |
|
||||||
|
| P1 | V4: Green link contrast | Small |
|
||||||
|
| P1 | V3: Footer text contrast | Small |
|
||||||
|
| P2 | V6: Heading hierarchy | Small |
|
||||||
|
| P2 | V5: Banner landmark | Small |
|
||||||
|
| P2 | R1-R4: Best practices | Small |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Automated Check Results
|
||||||
|
|
||||||
|
```
|
||||||
|
skipNav: false
|
||||||
|
headings: h1(3), h4(1)
|
||||||
|
imgsNoAlt: 0 / 1
|
||||||
|
inputsNoLabel: 25
|
||||||
|
genericLinks: 0
|
||||||
|
lowContrastSuspects: 30
|
||||||
|
inputsWithAriaRequired: 0
|
||||||
|
landmarks: main=1, nav=2, banner=0, contentinfo=2
|
||||||
|
hasLang: true (en-US)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated via visual + programmatic analysis of forge.alexanderwhitestone.com*
|
||||||
179
docs/glitch-detection.md
Normal file
179
docs/glitch-detection.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# 3D World Glitch Detection — Matrix Scanner
|
||||||
|
|
||||||
|
**Reference:** timmy-config#491
|
||||||
|
**Label:** gemma-4-multimodal
|
||||||
|
**Version:** 0.1.0
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Matrix Glitch Detector scans 3D web worlds for visual artifacts and
|
||||||
|
rendering anomalies. It uses browser automation to capture screenshots from
|
||||||
|
multiple camera angles, then sends them to a vision AI model for analysis
|
||||||
|
against a library of known glitch patterns.
|
||||||
|
|
||||||
|
## Detected Glitch Categories
|
||||||
|
|
||||||
|
| Category | Severity | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| Floating Assets | HIGH | Objects not grounded — hovering above surfaces |
|
||||||
|
| Z-Fighting | MEDIUM | Coplanar surfaces flickering/competing for depth |
|
||||||
|
| Missing Textures | CRITICAL | Placeholder colors (magenta, checkerboard) |
|
||||||
|
| Clipping | HIGH | Geometry passing through other objects |
|
||||||
|
| Broken Normals | MEDIUM | Inside-out or incorrectly lit surfaces |
|
||||||
|
| Shadow Artifacts | LOW | Detached, mismatched, or acne shadows |
|
||||||
|
| LOD Popping | LOW | Abrupt level-of-detail transitions |
|
||||||
|
| Lightmap Errors | MEDIUM | Dark splotches, light leaks, baking failures |
|
||||||
|
| Water/Reflection | MEDIUM | Incorrect environment reflections |
|
||||||
|
| Skybox Seam | LOW | Visible seams at cubemap face edges |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
No external dependencies required — pure Python 3.10+.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repo
|
||||||
|
git clone https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config.git
|
||||||
|
cd timmy-config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Scan
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python bin/matrix_glitch_detector.py https://matrix.example.com/world/alpha
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Angle Scan
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python bin/matrix_glitch_detector.py https://matrix.example.com/world/alpha \
|
||||||
|
--angles 8 \
|
||||||
|
--output glitch_report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Demo Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python bin/matrix_glitch_detector.py --demo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `url` | (required) | URL of the 3D world to scan |
|
||||||
|
| `--angles N` | 4 | Number of camera angles to capture |
|
||||||
|
| `--output PATH` | stdout | Output file for JSON report |
|
||||||
|
| `--min-severity` | info | Minimum severity: info/low/medium/high/critical |
|
||||||
|
| `--demo` | off | Run with simulated detections |
|
||||||
|
| `--verbose` | off | Enable verbose output |
|
||||||
|
|
||||||
|
## Report Format
|
||||||
|
|
||||||
|
The JSON report includes:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scan_id": "uuid",
|
||||||
|
"url": "https://...",
|
||||||
|
"timestamp": "ISO-8601",
|
||||||
|
"total_screenshots": 4,
|
||||||
|
"angles_captured": ["front", "right", "back", "left"],
|
||||||
|
"glitches": [
|
||||||
|
{
|
||||||
|
"id": "short-uuid",
|
||||||
|
"category": "floating_assets",
|
||||||
|
"name": "Floating Chair",
|
||||||
|
"description": "Office chair floating 0.3m above floor",
|
||||||
|
"severity": "high",
|
||||||
|
"confidence": 0.87,
|
||||||
|
"location_x": 35.2,
|
||||||
|
"location_y": 62.1,
|
||||||
|
"screenshot_index": 0,
|
||||||
|
"screenshot_angle": "front",
|
||||||
|
"timestamp": "ISO-8601"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"total_glitches": 4,
|
||||||
|
"by_severity": {"critical": 1, "high": 2, "medium": 1},
|
||||||
|
"by_category": {"floating_assets": 1, "missing_textures": 1, ...},
|
||||||
|
"highest_severity": "critical",
|
||||||
|
"clean_screenshots": 0
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"detector_version": "0.1.0",
|
||||||
|
"pattern_count": 10,
|
||||||
|
"reference": "timmy-config#491"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vision AI Integration
|
||||||
|
|
||||||
|
The detector supports any OpenAI-compatible vision API. Set these
|
||||||
|
environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export VISION_API_KEY="your-api-key"
|
||||||
|
export VISION_API_BASE="https://api.openai.com/v1" # optional
|
||||||
|
export VISION_MODEL="gpt-4o" # optional, default: gpt-4o
|
||||||
|
```
|
||||||
|
|
||||||
|
For browser-based capture with `browser_vision`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BROWSER_VISION_SCRIPT="/path/to/browser_vision.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Glitch Patterns
|
||||||
|
|
||||||
|
Pattern definitions live in `bin/glitch_patterns.py`. Each pattern includes:
|
||||||
|
|
||||||
|
- **category** — Enum matching the glitch type
|
||||||
|
- **detection_prompts** — Instructions for the vision model
|
||||||
|
- **visual_indicators** — What to look for in screenshots
|
||||||
|
- **confidence_threshold** — Minimum confidence to report
|
||||||
|
|
||||||
|
### Adding Custom Patterns
|
||||||
|
|
||||||
|
```python
|
||||||
|
from glitch_patterns import GlitchPattern, GlitchCategory, GlitchSeverity
|
||||||
|
|
||||||
|
custom = GlitchPattern(
|
||||||
|
category=GlitchCategory.FLOATING_ASSETS,
|
||||||
|
name="Custom Glitch",
|
||||||
|
description="Your description",
|
||||||
|
severity=GlitchSeverity.MEDIUM,
|
||||||
|
detection_prompts=["Look for..."],
|
||||||
|
visual_indicators=["indicator 1", "indicator 2"],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_glitch_detector.py -v
|
||||||
|
# or
|
||||||
|
python tests/test_glitch_detector.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
bin/
|
||||||
|
matrix_glitch_detector.py — Main CLI entry point
|
||||||
|
glitch_patterns.py — Pattern definitions and prompt builder
|
||||||
|
tests/
|
||||||
|
test_glitch_detector.py — Unit and integration tests
|
||||||
|
docs/
|
||||||
|
glitch-detection.md — This documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Browser automation requires a headless browser environment
|
||||||
|
- Vision AI analysis depends on model availability and API limits
|
||||||
|
- Placeholder screenshots are generated when browser capture is unavailable
|
||||||
|
- Detection accuracy varies by scene complexity and lighting conditions
|
||||||
68
docs/overnight-rd.md
Normal file
68
docs/overnight-rd.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Overnight R&D Automation
|
||||||
|
|
||||||
|
**Schedule**: Nightly at 10 PM EDT (02:00 UTC)
|
||||||
|
**Duration**: ~2-4 hours (self-limiting, finishes before 6 AM morning report)
|
||||||
|
**Cost**: $0 — all local Ollama inference
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
### Phase 1: Deep Dive Intelligence
|
||||||
|
Runs the `intelligence/deepdive/pipeline.py` from the-nexus:
|
||||||
|
- Aggregates arXiv CS.AI, CS.CL, CS.LG RSS feeds (last 24h)
|
||||||
|
- Fetches OpenAI, Anthropic, DeepMind blog updates
|
||||||
|
- Filters for relevance using sentence-transformers embeddings
|
||||||
|
- Synthesizes a briefing using local Gemma 4 12B
|
||||||
|
- Saves briefing to `~/briefings/`
|
||||||
|
|
||||||
|
### Phase 2: Tightening Loop
|
||||||
|
Exercises Timmy's local tool-use capability:
|
||||||
|
- 10 tasks × 3 cycles = 30 task attempts per night
|
||||||
|
- File reading, writing, searching against real workspace files
|
||||||
|
- Each result logged as JSONL for training data analysis
|
||||||
|
- Tests sovereignty compliance (SOUL.md alignment, banned provider detection)
|
||||||
|
|
||||||
|
### Phase 3: DPO Export
|
||||||
|
Sweeps overnight Hermes sessions for training pair extraction:
|
||||||
|
- Converts good conversation pairs into DPO training format
|
||||||
|
- Saves to `~/.timmy/training-data/dpo-pairs/`
|
||||||
|
|
||||||
|
### Phase 4: Morning Prep
|
||||||
|
Compiles overnight findings into `~/.timmy/overnight-rd/latest_summary.md`
|
||||||
|
for consumption by the 6 AM `good_morning_report` task.
|
||||||
|
|
||||||
|
## Approved Providers
|
||||||
|
|
||||||
|
| Slot | Provider | Model |
|
||||||
|
|------|----------|-------|
|
||||||
|
| Synthesis | Ollama | gemma4:12b |
|
||||||
|
| Tool tasks | Ollama | hermes4:14b |
|
||||||
|
| Fallback | Ollama | gemma4:12b |
|
||||||
|
|
||||||
|
Anthropic is permanently banned (BANNED_PROVIDERS.yml, 2026-04-09).
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Path | Content |
|
||||||
|
|------|---------|
|
||||||
|
| `~/.timmy/overnight-rd/{run_id}/rd_log.jsonl` | Full task log |
|
||||||
|
| `~/.timmy/overnight-rd/{run_id}/rd_summary.md` | Run summary |
|
||||||
|
| `~/.timmy/overnight-rd/latest_summary.md` | Latest summary (for morning report) |
|
||||||
|
| `~/briefings/briefing_*.json` | Deep Dive briefings |
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Check the Huey consumer log:
|
||||||
|
```bash
|
||||||
|
tail -f ~/.timmy/timmy-config/logs/huey.log | grep overnight
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the latest run summary:
|
||||||
|
```bash
|
||||||
|
cat ~/.timmy/overnight-rd/latest_summary.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Deep Dive pipeline installed: `cd the-nexus/intelligence/deepdive && make install`
|
||||||
|
- Ollama running with gemma4:12b and hermes4:14b models
|
||||||
|
- Huey consumer running: `huey_consumer.py tasks.huey -w 2 -k thread`
|
||||||
@@ -14,7 +14,7 @@ from crewai.tools import BaseTool
|
|||||||
|
|
||||||
OPENROUTER_API_KEY = os.getenv(
|
OPENROUTER_API_KEY = os.getenv(
|
||||||
"OPENROUTER_API_KEY",
|
"OPENROUTER_API_KEY",
|
||||||
"dsk-or-v1-f60c89db12040267458165cf192e815e339eb70548e4a0a461f5f0f69e6ef8b0",
|
os.environ.get("OPENROUTER_API_KEY", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
llm = LLM(
|
llm = LLM(
|
||||||
|
|||||||
@@ -2,135 +2,128 @@ schema_version: 1
|
|||||||
status: proposed
|
status: proposed
|
||||||
runtime_wiring: false
|
runtime_wiring: false
|
||||||
owner: timmy-config
|
owner: timmy-config
|
||||||
|
|
||||||
ownership:
|
ownership:
|
||||||
owns:
|
owns:
|
||||||
- routing doctrine for task classes
|
- routing doctrine for task classes
|
||||||
- sidecar-readable per-agent fallback portfolios
|
- sidecar-readable per-agent fallback portfolios
|
||||||
- degraded-mode capability floors
|
- degraded-mode capability floors
|
||||||
does_not_own:
|
does_not_own:
|
||||||
- live queue state outside Gitea truth
|
- live queue state outside Gitea truth
|
||||||
- launchd or loop process state
|
- launchd or loop process state
|
||||||
- ad hoc worktree history
|
- ad hoc worktree history
|
||||||
|
|
||||||
policy:
|
policy:
|
||||||
require_four_slots_for_critical_agents: true
|
require_four_slots_for_critical_agents: true
|
||||||
terminal_fallback_must_be_usable: true
|
terminal_fallback_must_be_usable: true
|
||||||
forbid_synchronized_fleet_degradation: true
|
forbid_synchronized_fleet_degradation: true
|
||||||
forbid_human_token_fallbacks: true
|
forbid_human_token_fallbacks: true
|
||||||
anti_correlation_rule: no two critical agents may share the same primary+fallback1 pair
|
anti_correlation_rule: no two critical agents may share the same primary+fallback1 pair
|
||||||
|
|
||||||
sensitive_control_surfaces:
|
sensitive_control_surfaces:
|
||||||
- SOUL.md
|
- SOUL.md
|
||||||
- config.yaml
|
- config.yaml
|
||||||
- deploy.sh
|
- deploy.sh
|
||||||
- tasks.py
|
- tasks.py
|
||||||
- playbooks/
|
- playbooks/
|
||||||
- cron/
|
- cron/
|
||||||
- memories/
|
- memories/
|
||||||
- skins/
|
- skins/
|
||||||
- training/
|
- training/
|
||||||
|
|
||||||
role_classes:
|
role_classes:
|
||||||
judgment:
|
judgment:
|
||||||
current_surfaces:
|
current_surfaces:
|
||||||
- playbooks/issue-triager.yaml
|
- playbooks/issue-triager.yaml
|
||||||
- playbooks/pr-reviewer.yaml
|
- playbooks/pr-reviewer.yaml
|
||||||
- playbooks/verified-logic.yaml
|
- playbooks/verified-logic.yaml
|
||||||
task_classes:
|
task_classes:
|
||||||
- issue-triage
|
- issue-triage
|
||||||
- queue-routing
|
- queue-routing
|
||||||
- pr-review
|
- pr-review
|
||||||
- proof-check
|
- proof-check
|
||||||
- governance-review
|
- governance-review
|
||||||
degraded_mode:
|
degraded_mode:
|
||||||
fallback2:
|
fallback2:
|
||||||
allowed:
|
allowed:
|
||||||
- classify backlog
|
- classify backlog
|
||||||
- summarize risk
|
- summarize risk
|
||||||
- produce draft routing plans
|
- produce draft routing plans
|
||||||
- leave bounded labels or comments with evidence
|
- leave bounded labels or comments with evidence
|
||||||
denied:
|
denied:
|
||||||
- merge pull requests
|
- merge pull requests
|
||||||
- close or rewrite governing issues or PRs
|
- close or rewrite governing issues or PRs
|
||||||
- mutate sensitive control surfaces
|
- mutate sensitive control surfaces
|
||||||
- bulk-reassign the fleet
|
- bulk-reassign the fleet
|
||||||
- silently change routing policy
|
- silently change routing policy
|
||||||
terminal:
|
terminal:
|
||||||
lane: report-and-route
|
lane: report-and-route
|
||||||
allowed:
|
allowed:
|
||||||
- classify backlog
|
- classify backlog
|
||||||
- summarize risk
|
- summarize risk
|
||||||
- produce draft routing artifacts
|
- produce draft routing artifacts
|
||||||
denied:
|
denied:
|
||||||
- merge pull requests
|
- merge pull requests
|
||||||
- bulk-reassign the fleet
|
- bulk-reassign the fleet
|
||||||
- mutate sensitive control surfaces
|
- mutate sensitive control surfaces
|
||||||
|
|
||||||
builder:
|
builder:
|
||||||
current_surfaces:
|
current_surfaces:
|
||||||
- playbooks/bug-fixer.yaml
|
- playbooks/bug-fixer.yaml
|
||||||
- playbooks/test-writer.yaml
|
- playbooks/test-writer.yaml
|
||||||
- playbooks/refactor-specialist.yaml
|
- playbooks/refactor-specialist.yaml
|
||||||
task_classes:
|
task_classes:
|
||||||
- bug-fix
|
- bug-fix
|
||||||
- test-writing
|
- test-writing
|
||||||
- refactor
|
- refactor
|
||||||
- bounded-docs-change
|
- bounded-docs-change
|
||||||
degraded_mode:
|
degraded_mode:
|
||||||
fallback2:
|
fallback2:
|
||||||
allowed:
|
allowed:
|
||||||
- reversible single-issue changes
|
- reversible single-issue changes
|
||||||
- narrow docs fixes
|
- narrow docs fixes
|
||||||
- test scaffolds and reproducers
|
- test scaffolds and reproducers
|
||||||
denied:
|
denied:
|
||||||
- cross-repo changes
|
- cross-repo changes
|
||||||
- sensitive control-surface edits
|
- sensitive control-surface edits
|
||||||
- merge or release actions
|
- merge or release actions
|
||||||
terminal:
|
terminal:
|
||||||
lane: narrow-patch
|
lane: narrow-patch
|
||||||
allowed:
|
allowed:
|
||||||
- single-issue small patch
|
- single-issue small patch
|
||||||
- reproducer test
|
- reproducer test
|
||||||
- docs-only repair
|
- docs-only repair
|
||||||
denied:
|
denied:
|
||||||
- sensitive control-surface edits
|
- sensitive control-surface edits
|
||||||
- multi-file architecture work
|
- multi-file architecture work
|
||||||
- irreversible actions
|
- irreversible actions
|
||||||
|
|
||||||
wolf_bulk:
|
wolf_bulk:
|
||||||
current_surfaces:
|
current_surfaces:
|
||||||
- docs/automation-inventory.md
|
- docs/automation-inventory.md
|
||||||
- FALSEWORK.md
|
- FALSEWORK.md
|
||||||
task_classes:
|
task_classes:
|
||||||
- docs-inventory
|
- docs-inventory
|
||||||
- log-summarization
|
- log-summarization
|
||||||
- queue-hygiene
|
- queue-hygiene
|
||||||
- repetitive-small-diff
|
- repetitive-small-diff
|
||||||
- research-sweep
|
- research-sweep
|
||||||
degraded_mode:
|
degraded_mode:
|
||||||
fallback2:
|
fallback2:
|
||||||
allowed:
|
allowed:
|
||||||
- gather evidence
|
- gather evidence
|
||||||
- refresh inventories
|
- refresh inventories
|
||||||
- summarize logs
|
- summarize logs
|
||||||
- propose labels or routes
|
- propose labels or routes
|
||||||
denied:
|
denied:
|
||||||
- multi-repo branch fanout
|
- multi-repo branch fanout
|
||||||
- mass agent assignment
|
- mass agent assignment
|
||||||
- sensitive control-surface edits
|
- sensitive control-surface edits
|
||||||
- irreversible queue mutation
|
- irreversible queue mutation
|
||||||
terminal:
|
terminal:
|
||||||
lane: gather-and-summarize
|
lane: gather-and-summarize
|
||||||
allowed:
|
allowed:
|
||||||
- inventory refresh
|
- inventory refresh
|
||||||
- evidence bundles
|
- evidence bundles
|
||||||
- summaries
|
- summaries
|
||||||
denied:
|
denied:
|
||||||
- multi-repo branch fanout
|
- multi-repo branch fanout
|
||||||
- mass agent assignment
|
- mass agent assignment
|
||||||
- sensitive control-surface edits
|
- sensitive control-surface edits
|
||||||
|
|
||||||
routing:
|
routing:
|
||||||
issue-triage: judgment
|
issue-triage: judgment
|
||||||
queue-routing: judgment
|
queue-routing: judgment
|
||||||
@@ -146,22 +139,20 @@ routing:
|
|||||||
queue-hygiene: wolf_bulk
|
queue-hygiene: wolf_bulk
|
||||||
repetitive-small-diff: wolf_bulk
|
repetitive-small-diff: wolf_bulk
|
||||||
research-sweep: wolf_bulk
|
research-sweep: wolf_bulk
|
||||||
|
|
||||||
promotion_rules:
|
promotion_rules:
|
||||||
- If a wolf/bulk task touches a sensitive control surface, promote it to judgment.
|
- If a wolf/bulk task touches a sensitive control surface, promote it to judgment.
|
||||||
- If a builder task expands beyond 5 files, architecture review, or multi-repo coordination, promote it to judgment.
|
- If a builder task expands beyond 5 files, architecture review, or multi-repo coordination, promote it to judgment.
|
||||||
- If a terminal lane cannot produce a usable artifact, the portfolio is invalid and must be redesigned before wiring.
|
- If a terminal lane cannot produce a usable artifact, the portfolio is invalid and must be redesigned before wiring.
|
||||||
|
|
||||||
agents:
|
agents:
|
||||||
triage-coordinator:
|
triage-coordinator:
|
||||||
role_class: judgment
|
role_class: judgment
|
||||||
critical: true
|
critical: true
|
||||||
current_playbooks:
|
current_playbooks:
|
||||||
- playbooks/issue-triager.yaml
|
- playbooks/issue-triager.yaml
|
||||||
portfolio:
|
portfolio:
|
||||||
primary:
|
primary:
|
||||||
provider: anthropic
|
provider: kimi-coding
|
||||||
model: claude-opus-4-6
|
model: kimi-k2.5
|
||||||
lane: full-judgment
|
lane: full-judgment
|
||||||
fallback1:
|
fallback1:
|
||||||
provider: openai-codex
|
provider: openai-codex
|
||||||
@@ -177,19 +168,18 @@ agents:
|
|||||||
lane: report-and-route
|
lane: report-and-route
|
||||||
local_capable: true
|
local_capable: true
|
||||||
usable_output:
|
usable_output:
|
||||||
- backlog classification
|
- backlog classification
|
||||||
- routing draft
|
- routing draft
|
||||||
- risk summary
|
- risk summary
|
||||||
|
|
||||||
pr-reviewer:
|
pr-reviewer:
|
||||||
role_class: judgment
|
role_class: judgment
|
||||||
critical: true
|
critical: true
|
||||||
current_playbooks:
|
current_playbooks:
|
||||||
- playbooks/pr-reviewer.yaml
|
- playbooks/pr-reviewer.yaml
|
||||||
portfolio:
|
portfolio:
|
||||||
primary:
|
primary:
|
||||||
provider: anthropic
|
provider: kimi-coding
|
||||||
model: claude-opus-4-6
|
model: kimi-k2.5
|
||||||
lane: full-review
|
lane: full-review
|
||||||
fallback1:
|
fallback1:
|
||||||
provider: gemini
|
provider: gemini
|
||||||
@@ -205,17 +195,16 @@ agents:
|
|||||||
lane: low-stakes-diff-summary
|
lane: low-stakes-diff-summary
|
||||||
local_capable: false
|
local_capable: false
|
||||||
usable_output:
|
usable_output:
|
||||||
- diff risk summary
|
- diff risk summary
|
||||||
- explicit uncertainty notes
|
- explicit uncertainty notes
|
||||||
- merge-block recommendation
|
- merge-block recommendation
|
||||||
|
|
||||||
builder-main:
|
builder-main:
|
||||||
role_class: builder
|
role_class: builder
|
||||||
critical: true
|
critical: true
|
||||||
current_playbooks:
|
current_playbooks:
|
||||||
- playbooks/bug-fixer.yaml
|
- playbooks/bug-fixer.yaml
|
||||||
- playbooks/test-writer.yaml
|
- playbooks/test-writer.yaml
|
||||||
- playbooks/refactor-specialist.yaml
|
- playbooks/refactor-specialist.yaml
|
||||||
portfolio:
|
portfolio:
|
||||||
primary:
|
primary:
|
||||||
provider: openai-codex
|
provider: openai-codex
|
||||||
@@ -236,15 +225,14 @@ agents:
|
|||||||
lane: narrow-patch
|
lane: narrow-patch
|
||||||
local_capable: true
|
local_capable: true
|
||||||
usable_output:
|
usable_output:
|
||||||
- small patch
|
- small patch
|
||||||
- reproducer test
|
- reproducer test
|
||||||
- docs repair
|
- docs repair
|
||||||
|
|
||||||
wolf-sweeper:
|
wolf-sweeper:
|
||||||
role_class: wolf_bulk
|
role_class: wolf_bulk
|
||||||
critical: true
|
critical: true
|
||||||
current_world_state:
|
current_world_state:
|
||||||
- docs/automation-inventory.md
|
- docs/automation-inventory.md
|
||||||
portfolio:
|
portfolio:
|
||||||
primary:
|
primary:
|
||||||
provider: gemini
|
provider: gemini
|
||||||
@@ -264,21 +252,20 @@ agents:
|
|||||||
lane: gather-and-summarize
|
lane: gather-and-summarize
|
||||||
local_capable: true
|
local_capable: true
|
||||||
usable_output:
|
usable_output:
|
||||||
- inventory refresh
|
- inventory refresh
|
||||||
- evidence bundle
|
- evidence bundle
|
||||||
- summary comment
|
- summary comment
|
||||||
|
|
||||||
cross_checks:
|
cross_checks:
|
||||||
unique_primary_fallback1_pairs:
|
unique_primary_fallback1_pairs:
|
||||||
triage-coordinator:
|
triage-coordinator:
|
||||||
- anthropic/claude-opus-4-6
|
- kimi-coding/kimi-k2.5
|
||||||
- openai-codex/codex
|
- openai-codex/codex
|
||||||
pr-reviewer:
|
pr-reviewer:
|
||||||
- anthropic/claude-opus-4-6
|
- kimi-coding/kimi-k2.5
|
||||||
- gemini/gemini-2.5-pro
|
- gemini/gemini-2.5-pro
|
||||||
builder-main:
|
builder-main:
|
||||||
- openai-codex/codex
|
- openai-codex/codex
|
||||||
- kimi-coding/kimi-k2.5
|
- kimi-coding/kimi-k2.5
|
||||||
wolf-sweeper:
|
wolf-sweeper:
|
||||||
- gemini/gemini-2.5-flash
|
- gemini/gemini-2.5-flash
|
||||||
- groq/llama-3.3-70b-versatile
|
- groq/llama-3.3-70b-versatile
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ def update_uptime(checks: dict):
|
|||||||
save(data)
|
save(data)
|
||||||
|
|
||||||
if new_milestones:
|
if new_milestones:
|
||||||
print(f" UPTIME MILESTONE: {','.join(str(m) + '%') for m in new_milestones}")
|
print(f" UPTIME MILESTONE: {','.join((str(m) + '%') for m in new_milestones)}")
|
||||||
print(f" Current uptime: {recent_ok:.1f}%")
|
print(f" Current uptime: {recent_ok:.1f}%")
|
||||||
|
|
||||||
return data["uptime"]
|
return data["uptime"]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: forge-ci-${{ gitea.ref }}
|
group: forge-ci-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -18,40 +18,21 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
|
||||||
cache-dependency-glob: "uv.lock"
|
|
||||||
|
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.11
|
||||||
run: uv python install 3.11
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
- name: Install package
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
uv venv .venv --python 3.11
|
pip install pytest pyyaml
|
||||||
source .venv/bin/activate
|
|
||||||
uv pip install -e ".[all,dev]"
|
|
||||||
|
|
||||||
- name: Smoke tests
|
- name: Smoke tests
|
||||||
run: |
|
run: python scripts/smoke_test.py
|
||||||
source .venv/bin/activate
|
|
||||||
python scripts/smoke_test.py
|
|
||||||
env:
|
env:
|
||||||
OPENROUTER_API_KEY: ""
|
OPENROUTER_API_KEY: ""
|
||||||
OPENAI_API_KEY: ""
|
OPENAI_API_KEY: ""
|
||||||
NOUS_API_KEY: ""
|
NOUS_API_KEY: ""
|
||||||
|
|
||||||
- name: Syntax guard
|
- name: Syntax guard
|
||||||
run: |
|
run: python scripts/syntax_guard.py
|
||||||
source .venv/bin/activate
|
|
||||||
python scripts/syntax_guard.py
|
|
||||||
|
|
||||||
- name: Green-path E2E
|
|
||||||
run: |
|
|
||||||
source .venv/bin/activate
|
|
||||||
python -m pytest tests/test_green_path_e2e.py -q --tb=short
|
|
||||||
env:
|
|
||||||
OPENROUTER_API_KEY: ""
|
|
||||||
OPENAI_API_KEY: ""
|
|
||||||
NOUS_API_KEY: ""
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
pip install papermill jupytext nbformat
|
pip install papermill jupytext nbformat ipykernel
|
||||||
python -m ipykernel install --user --name python3
|
python -m ipykernel install --user --name python3
|
||||||
|
|
||||||
- name: Execute system health notebook
|
- name: Execute system health notebook
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ def check_core_deps() -> CheckResult:
|
|||||||
"""Verify that hermes core Python packages are importable."""
|
"""Verify that hermes core Python packages are importable."""
|
||||||
required = [
|
required = [
|
||||||
"openai",
|
"openai",
|
||||||
"anthropic",
|
"kimi-coding",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
"yaml",
|
"yaml",
|
||||||
"rich",
|
"rich",
|
||||||
@@ -206,8 +206,8 @@ def check_env_vars() -> CheckResult:
|
|||||||
"""Check that at least one LLM provider key is configured."""
|
"""Check that at least one LLM provider key is configured."""
|
||||||
provider_keys = [
|
provider_keys = [
|
||||||
"OPENROUTER_API_KEY",
|
"OPENROUTER_API_KEY",
|
||||||
"ANTHROPIC_API_KEY",
|
"KIMI_API_KEY",
|
||||||
"ANTHROPIC_TOKEN",
|
# "ANTHROPIC_TOKEN", # BANNED
|
||||||
"OPENAI_API_KEY",
|
"OPENAI_API_KEY",
|
||||||
"GLM_API_KEY",
|
"GLM_API_KEY",
|
||||||
"KIMI_API_KEY",
|
"KIMI_API_KEY",
|
||||||
@@ -225,7 +225,7 @@ def check_env_vars() -> CheckResult:
|
|||||||
passed=False,
|
passed=False,
|
||||||
message="No LLM provider API key found",
|
message="No LLM provider API key found",
|
||||||
fix_hint=(
|
fix_hint=(
|
||||||
"Set at least one of: OPENROUTER_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY "
|
"Set at least one of: OPENROUTER_API_KEY, KIMI_API_KEY, OPENAI_API_KEY "
|
||||||
"in ~/.hermes/.env or your shell."
|
"in ~/.hermes/.env or your shell."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ services:
|
|||||||
- "traefik.http.routers.matrix-client.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.matrix-client.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.routers.matrix-client.entrypoints=websecure"
|
- "traefik.http.routers.matrix-client.entrypoints=websecure"
|
||||||
- "traefik.http.services.matrix-client.loadbalancer.server.port=6167"
|
- "traefik.http.services.matrix-client.loadbalancer.server.port=6167"
|
||||||
|
|
||||||
# Federation (TCP 8448) - direct or via Traefik TCP entrypoint
|
# Federation (TCP 8448) - direct or via Traefik TCP entrypoint
|
||||||
# Option A: Direct host port mapping
|
# Option A: Direct host port mapping
|
||||||
# Option B: Traefik TCP router (requires Traefik federation entrypoint)
|
# Option B: Traefik TCP router (requires Traefik federation entrypoint)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ description: >
|
|||||||
reproduces the bug, then fixes the code, then verifies.
|
reproduces the bug, then fixes the code, then verifies.
|
||||||
|
|
||||||
model:
|
model:
|
||||||
preferred: claude-opus-4-6
|
preferred: kimi-k2.5
|
||||||
fallback: claude-sonnet-4-20250514
|
fallback: google/gemini-2.5-pro
|
||||||
max_turns: 30
|
max_turns: 30
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
|
|
||||||
|
|||||||
@@ -163,4 +163,4 @@ overrides:
|
|||||||
Post a comment on the issue with the format:
|
Post a comment on the issue with the format:
|
||||||
GUARDRAIL_OVERRIDE: <constraint_name> REASON: <explanation>
|
GUARDRAIL_OVERRIDE: <constraint_name> REASON: <explanation>
|
||||||
override_expiry_hours: 24
|
override_expiry_hours: 24
|
||||||
require_post_override_review: true
|
require_post_override_review: true
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ description: >
|
|||||||
agents. Decomposes large issues into smaller ones.
|
agents. Decomposes large issues into smaller ones.
|
||||||
|
|
||||||
model:
|
model:
|
||||||
preferred: claude-opus-4-6
|
preferred: kimi-k2.5
|
||||||
fallback: claude-sonnet-4-20250514
|
fallback: google/gemini-2.5-pro
|
||||||
max_turns: 20
|
max_turns: 20
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ system_prompt: |
|
|||||||
- codex-agent: cleanup, migration verification, dead-code removal, repo-boundary enforcement, workflow hardening
|
- codex-agent: cleanup, migration verification, dead-code removal, repo-boundary enforcement, workflow hardening
|
||||||
- groq: bounded implementation, tactical bug fixes, quick feature slices, small patches with clear acceptance criteria
|
- groq: bounded implementation, tactical bug fixes, quick feature slices, small patches with clear acceptance criteria
|
||||||
- manus: bounded support tasks, moderate-scope implementation, follow-through on already-scoped work
|
- manus: bounded support tasks, moderate-scope implementation, follow-through on already-scoped work
|
||||||
- claude: hard refactors, broad multi-file implementation, test-heavy changes after the scope is made precise
|
- kimi: hard refactors, broad multi-file implementation, test-heavy changes after the scope is made precise
|
||||||
- gemini: frontier architecture, research-heavy prototypes, long-range design thinking when a concrete implementation owner is not yet obvious
|
- gemini: frontier architecture, research-heavy prototypes, long-range design thinking when a concrete implementation owner is not yet obvious
|
||||||
- grok: adversarial testing, unusual edge cases, provocative review angles that still need another pass
|
- grok: adversarial testing, unusual edge cases, provocative review angles that still need another pass
|
||||||
5. Decompose any issue touching >5 files or crossing repo boundaries into smaller issues before assigning execution
|
5. Decompose any issue touching >5 files or crossing repo boundaries into smaller issues before assigning execution
|
||||||
@@ -63,6 +63,6 @@ system_prompt: |
|
|||||||
- Search for existing issues or PRs covering the same request before assigning anything. If a likely duplicate exists, link it and do not create or route duplicate work.
|
- Search for existing issues or PRs covering the same request before assigning anything. If a likely duplicate exists, link it and do not create or route duplicate work.
|
||||||
- Do not assign open-ended ideation to implementation agents.
|
- Do not assign open-ended ideation to implementation agents.
|
||||||
- Do not assign routine backlog maintenance to Timmy.
|
- Do not assign routine backlog maintenance to Timmy.
|
||||||
- Do not assign wide speculative backlog generation to codex-agent, groq, manus, or claude.
|
- Do not assign wide speculative backlog generation to codex-agent, groq, or manus.
|
||||||
- Route archive/history/context-digestion work to ezra or KimiClaw before routing it to a builder.
|
- Route archive/history/context-digestion work to ezra or KimiClaw before routing it to a builder.
|
||||||
- Route “who should do this?” and “what is the next move?” questions to allegro.
|
- Route “who should do this?” and “what is the next move?” questions to allegro.
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ description: >
|
|||||||
comments on problems. The merge bot replacement.
|
comments on problems. The merge bot replacement.
|
||||||
|
|
||||||
model:
|
model:
|
||||||
preferred: claude-opus-4-6
|
preferred: kimi-k2.5
|
||||||
fallback: claude-sonnet-4-20250514
|
fallback: google/gemini-2.5-pro
|
||||||
max_turns: 20
|
max_turns: 20
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ description: >
|
|||||||
Well-scoped: 1-3 files per task, clear acceptance criteria.
|
Well-scoped: 1-3 files per task, clear acceptance criteria.
|
||||||
|
|
||||||
model:
|
model:
|
||||||
preferred: claude-opus-4-6
|
preferred: kimi-k2.5
|
||||||
fallback: claude-sonnet-4-20250514
|
fallback: google/gemini-2.5-pro
|
||||||
max_turns: 30
|
max_turns: 30
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ description: >
|
|||||||
dependency issues. Files findings as Gitea issues.
|
dependency issues. Files findings as Gitea issues.
|
||||||
|
|
||||||
model:
|
model:
|
||||||
preferred: claude-opus-4-6
|
preferred: kimi-k2.5
|
||||||
fallback: claude-opus-4-6
|
fallback: google/gemini-2.5-pro
|
||||||
max_turns: 40
|
max_turns: 40
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ description: >
|
|||||||
writes meaningful tests, verifies they pass.
|
writes meaningful tests, verifies they pass.
|
||||||
|
|
||||||
model:
|
model:
|
||||||
preferred: claude-opus-4-6
|
preferred: kimi-k2.5
|
||||||
fallback: claude-sonnet-4-20250514
|
fallback: google/gemini-2.5-pro
|
||||||
max_turns: 30
|
max_turns: 30
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ description: >
|
|||||||
and consistency verification.
|
and consistency verification.
|
||||||
|
|
||||||
model:
|
model:
|
||||||
preferred: claude-opus-4-6
|
preferred: kimi-k2.5
|
||||||
fallback: claude-sonnet-4-20250514
|
fallback: google/gemini-2.5-pro
|
||||||
max_turns: 12
|
max_turns: 12
|
||||||
temperature: 0.1
|
temperature: 0.1
|
||||||
|
|
||||||
|
|||||||
151
scripts/a11y-check.js
Normal file
151
scripts/a11y-check.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
// a11y-check.js — Automated accessibility audit script for Foundation web properties
|
||||||
|
// Run in browser console or via Playwright/Puppeteer
|
||||||
|
//
|
||||||
|
// Usage: Paste into DevTools console, or include in automated test suite.
|
||||||
|
// Returns a JSON object with pass/fail for WCAG 2.1 AA checks.
|
||||||
|
|
||||||
|
(function a11yAudit() {
|
||||||
|
const results = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
url: window.location.href,
|
||||||
|
title: document.title,
|
||||||
|
violations: [],
|
||||||
|
passes: [],
|
||||||
|
warnings: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 2.4.1 Skip Navigation ---
|
||||||
|
const skipLink = document.querySelector('a[href="#main"], a[href="#content"], .skip-nav, .skip-link');
|
||||||
|
if (skipLink) {
|
||||||
|
results.passes.push({ rule: '2.4.1', name: 'Skip Navigation', detail: 'Skip link found' });
|
||||||
|
} else {
|
||||||
|
results.violations.push({ rule: '2.4.1', name: 'Skip Navigation', severity: 'medium', detail: 'No skip-to-content link found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1.3.1 / 3.3.2 Form Labels ---
|
||||||
|
const unlabeledInputs = Array.from(document.querySelectorAll('input, select, textarea')).filter(el => {
|
||||||
|
if (el.type === 'hidden') return false;
|
||||||
|
const id = el.id;
|
||||||
|
const hasLabel = id && document.querySelector(`label[for="${id}"]`);
|
||||||
|
const hasAriaLabel = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby');
|
||||||
|
const hasTitle = el.getAttribute('title');
|
||||||
|
const hasPlaceholder = el.getAttribute('placeholder'); // placeholder alone is NOT sufficient
|
||||||
|
return !hasLabel && !hasAriaLabel && !hasTitle;
|
||||||
|
});
|
||||||
|
if (unlabeledInputs.length === 0) {
|
||||||
|
results.passes.push({ rule: '3.3.2', name: 'Form Labels', detail: 'All inputs have labels' });
|
||||||
|
} else {
|
||||||
|
results.violations.push({
|
||||||
|
rule: '3.3.2',
|
||||||
|
name: 'Form Labels',
|
||||||
|
severity: 'high',
|
||||||
|
detail: `${unlabeledInputs.length} inputs without programmatic labels`,
|
||||||
|
elements: unlabeledInputs.map(el => ({ tag: el.tagName, type: el.type, name: el.name, id: el.id }))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1.4.3 Contrast (heuristic: very light text colors) ---
|
||||||
|
const lowContrast = Array.from(document.querySelectorAll('p, span, a, li, td, th, label, small, footer *')).filter(el => {
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
const color = style.color;
|
||||||
|
// Check for very light RGB values (r/g/b < 120)
|
||||||
|
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||||
|
if (!match) return false;
|
||||||
|
const [, r, g, b] = match.map(Number);
|
||||||
|
return r < 120 && g < 120 && b < 120 && (r + g + b) < 200;
|
||||||
|
});
|
||||||
|
if (lowContrast.length === 0) {
|
||||||
|
results.passes.push({ rule: '1.4.3', name: 'Contrast', detail: 'No obviously low-contrast text found' });
|
||||||
|
} else {
|
||||||
|
results.warnings.push({ rule: '1.4.3', name: 'Contrast', detail: `${lowContrast.length} elements with potentially low contrast (manual verification needed)` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1.3.1 Heading Hierarchy ---
|
||||||
|
const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')).map(h => ({
|
||||||
|
level: parseInt(h.tagName[1]),
|
||||||
|
text: h.textContent.trim().substring(0, 80)
|
||||||
|
}));
|
||||||
|
let headingIssues = [];
|
||||||
|
let lastLevel = 0;
|
||||||
|
for (const h of headings) {
|
||||||
|
if (h.level > lastLevel + 1 && lastLevel > 0) {
|
||||||
|
headingIssues.push(`Skipped h${lastLevel} to h${h.level}: "${h.text}"`);
|
||||||
|
}
|
||||||
|
lastLevel = h.level;
|
||||||
|
}
|
||||||
|
if (headingIssues.length === 0 && headings.length > 0) {
|
||||||
|
results.passes.push({ rule: '1.3.1', name: 'Heading Hierarchy', detail: `${headings.length} headings, proper nesting` });
|
||||||
|
} else if (headingIssues.length > 0) {
|
||||||
|
results.violations.push({ rule: '1.3.1', name: 'Heading Hierarchy', severity: 'low', detail: headingIssues.join('; ') });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1.3.1 Landmarks ---
|
||||||
|
const landmarks = {
|
||||||
|
main: document.querySelectorAll('main, [role="main"]').length,
|
||||||
|
nav: document.querySelectorAll('nav, [role="navigation"]').length,
|
||||||
|
banner: document.querySelectorAll('header, [role="banner"]').length,
|
||||||
|
contentinfo: document.querySelectorAll('footer, [role="contentinfo"]').length
|
||||||
|
};
|
||||||
|
if (landmarks.main > 0) {
|
||||||
|
results.passes.push({ rule: '1.3.1', name: 'Main Landmark', detail: 'Found' });
|
||||||
|
} else {
|
||||||
|
results.violations.push({ rule: '1.3.1', name: 'Main Landmark', severity: 'medium', detail: 'No <main> or role="main" found' });
|
||||||
|
}
|
||||||
|
if (landmarks.banner === 0) {
|
||||||
|
results.violations.push({ rule: '1.3.1', name: 'Banner Landmark', severity: 'low', detail: 'No <header> or role="banner" found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3.3.1 Required Fields ---
|
||||||
|
const requiredInputs = document.querySelectorAll('input[required], input[aria-required="true"]');
|
||||||
|
if (requiredInputs.length > 0) {
|
||||||
|
results.passes.push({ rule: '3.3.1', name: 'Required Fields', detail: `${requiredInputs.length} inputs marked as required` });
|
||||||
|
} else {
|
||||||
|
const visualRequired = document.querySelector('.required, [class*="required"], label .text-danger');
|
||||||
|
if (visualRequired) {
|
||||||
|
results.warnings.push({ rule: '3.3.1', name: 'Required Fields', detail: 'Visual indicators found but no aria-required attributes' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2.4.2 Page Title ---
|
||||||
|
if (document.title && document.title.trim().length > 0) {
|
||||||
|
results.passes.push({ rule: '2.4.2', name: 'Page Title', detail: document.title });
|
||||||
|
} else {
|
||||||
|
results.violations.push({ rule: '2.4.2', name: 'Page Title', severity: 'medium', detail: 'Page has no title' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3.1.1 Language ---
|
||||||
|
const lang = document.documentElement.lang;
|
||||||
|
if (lang) {
|
||||||
|
results.passes.push({ rule: '3.1.1', name: 'Language', detail: lang });
|
||||||
|
} else {
|
||||||
|
results.violations.push({ rule: '3.1.1', name: 'Language', severity: 'medium', detail: 'No lang attribute on <html>' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Images without alt ---
|
||||||
|
const imgsNoAlt = Array.from(document.querySelectorAll('img:not([alt])'));
|
||||||
|
if (imgsNoAlt.length === 0) {
|
||||||
|
results.passes.push({ rule: '1.1.1', name: 'Image Alt Text', detail: 'All images have alt attributes' });
|
||||||
|
} else {
|
||||||
|
results.violations.push({ rule: '1.1.1', name: 'Image Alt Text', severity: 'high', detail: `${imgsNoAlt.length} images without alt attributes` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Buttons without accessible names ---
|
||||||
|
const emptyButtons = Array.from(document.querySelectorAll('button')).filter(b => {
|
||||||
|
return !b.textContent.trim() && !b.getAttribute('aria-label') && !b.getAttribute('aria-labelledby') && !b.getAttribute('title');
|
||||||
|
});
|
||||||
|
if (emptyButtons.length === 0) {
|
||||||
|
results.passes.push({ rule: '4.1.2', name: 'Button Names', detail: 'All buttons have accessible names' });
|
||||||
|
} else {
|
||||||
|
results.violations.push({ rule: '4.1.2', name: 'Button Names', severity: 'medium', detail: `${emptyButtons.length} buttons without accessible names` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
results.summary = {
|
||||||
|
violations: results.violations.length,
|
||||||
|
passes: results.passes.length,
|
||||||
|
warnings: results.warnings.length
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(JSON.stringify(results, null, 2));
|
||||||
|
return results;
|
||||||
|
})();
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
import json
|
import json
|
||||||
from hermes_tools import browser_navigate, browser_vision
|
from hermes_tools import browser_navigate, browser_vision
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
import json
|
import json
|
||||||
from hermes_tools import browser_navigate, browser_vision
|
from hermes_tools import browser_navigate, browser_vision
|
||||||
|
|
||||||
|
|||||||
390
scripts/fleet-dashboard.py
Executable file
390
scripts/fleet-dashboard.py
Executable file
@@ -0,0 +1,390 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
fleet-dashboard.py -- Timmy Foundation Fleet Status Dashboard.
|
||||||
|
|
||||||
|
One-page terminal dashboard showing:
|
||||||
|
1. Gitea: open PRs, open issues, recent merges
|
||||||
|
2. VPS health: SSH reachability, service status, disk usage
|
||||||
|
3. Cron jobs: scheduled jobs, last run status
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/fleet-dashboard.py
|
||||||
|
python3 scripts/fleet-dashboard.py --json # machine-readable output
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
GITEA_BASE = os.environ.get("GITEA_URL", "https://forge.alexanderwhitestone.com")
|
||||||
|
GITEA_API = f"{GITEA_BASE}/api/v1"
|
||||||
|
GITEA_ORG = "Timmy_Foundation"
|
||||||
|
|
||||||
|
# Key repos to check for PRs/issues
|
||||||
|
REPOS = [
|
||||||
|
"timmy-config",
|
||||||
|
"the-nexus",
|
||||||
|
"hermes-agent",
|
||||||
|
"the-forge",
|
||||||
|
"timmy-sandbox",
|
||||||
|
]
|
||||||
|
|
||||||
|
# VPS fleet
|
||||||
|
VPS_HOSTS = {
|
||||||
|
"ezra": {
|
||||||
|
"ip": "143.198.27.163",
|
||||||
|
"ssh_user": "root",
|
||||||
|
"services": ["nginx", "gitea", "docker"],
|
||||||
|
},
|
||||||
|
"allegro": {
|
||||||
|
"ip": "167.99.126.228",
|
||||||
|
"ssh_user": "root",
|
||||||
|
"services": ["hermes-agent"],
|
||||||
|
},
|
||||||
|
"bezalel": {
|
||||||
|
"ip": "159.203.146.185",
|
||||||
|
"ssh_user": "root",
|
||||||
|
"services": ["hermes-agent", "evennia"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
CRON_JOBS_FILE = Path(__file__).parent.parent / "cron" / "jobs.json"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gitea helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _gitea_token() -> str:
|
||||||
|
for p in [
|
||||||
|
Path.home() / ".hermes" / "gitea_token",
|
||||||
|
Path.home() / ".hermes" / "gitea_token_vps",
|
||||||
|
Path.home() / ".config" / "gitea" / "token",
|
||||||
|
]:
|
||||||
|
if p.exists():
|
||||||
|
return p.read_text().strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _gitea_get(path: str, params: dict | None = None) -> list | dict:
|
||||||
|
url = f"{GITEA_API}{path}"
|
||||||
|
if params:
|
||||||
|
qs = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
|
||||||
|
if qs:
|
||||||
|
url += f"?{qs}"
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
token = _gitea_token()
|
||||||
|
if token:
|
||||||
|
req.add_header("Authorization", f"token {token}")
|
||||||
|
req.add_header("Accept", "application/json")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def check_gitea_health() -> dict:
|
||||||
|
"""Ping Gitea and collect PR/issue stats."""
|
||||||
|
result = {"reachable": False, "version": "", "repos": {}, "totals": {}}
|
||||||
|
|
||||||
|
# Ping
|
||||||
|
data = _gitea_get("/version")
|
||||||
|
if isinstance(data, dict) and "error" not in data:
|
||||||
|
result["reachable"] = True
|
||||||
|
result["version"] = data.get("version", "unknown")
|
||||||
|
elif isinstance(data, dict) and "error" in data:
|
||||||
|
return result
|
||||||
|
|
||||||
|
total_open_prs = 0
|
||||||
|
total_open_issues = 0
|
||||||
|
total_recent_merges = 0
|
||||||
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
for repo in REPOS:
|
||||||
|
repo_path = f"/repos/{GITEA_ORG}/{repo}"
|
||||||
|
repo_info = {"prs": [], "issues": [], "recent_merges": 0}
|
||||||
|
|
||||||
|
# Open PRs
|
||||||
|
prs = _gitea_get(f"{repo_path}/pulls", {"state": "open", "limit": "10", "sort": "newest"})
|
||||||
|
if isinstance(prs, list):
|
||||||
|
for pr in prs:
|
||||||
|
repo_info["prs"].append({
|
||||||
|
"number": pr.get("number"),
|
||||||
|
"title": pr.get("title", "")[:60],
|
||||||
|
"user": pr.get("user", {}).get("login", "unknown"),
|
||||||
|
"created": pr.get("created_at", "")[:10],
|
||||||
|
})
|
||||||
|
total_open_prs += len(prs)
|
||||||
|
|
||||||
|
# Open issues (excluding PRs)
|
||||||
|
issues = _gitea_get(f"{repo_path}/issues", {
|
||||||
|
"state": "open", "type": "issues", "limit": "10", "sort": "newest"
|
||||||
|
})
|
||||||
|
if isinstance(issues, list):
|
||||||
|
for iss in issues:
|
||||||
|
repo_info["issues"].append({
|
||||||
|
"number": iss.get("number"),
|
||||||
|
"title": iss.get("title", "")[:60],
|
||||||
|
"user": iss.get("user", {}).get("login", "unknown"),
|
||||||
|
"created": iss.get("created_at", "")[:10],
|
||||||
|
})
|
||||||
|
total_open_issues += len(issues)
|
||||||
|
|
||||||
|
# Recent merges (closed PRs)
|
||||||
|
merged = _gitea_get(f"{repo_path}/pulls", {"state": "closed", "limit": "20", "sort": "newest"})
|
||||||
|
if isinstance(merged, list):
|
||||||
|
recent = [p for p in merged if p.get("merged") and p.get("closed_at", "") >= cutoff]
|
||||||
|
repo_info["recent_merges"] = len(recent)
|
||||||
|
total_recent_merges += len(recent)
|
||||||
|
|
||||||
|
result["repos"][repo] = repo_info
|
||||||
|
|
||||||
|
result["totals"] = {
|
||||||
|
"open_prs": total_open_prs,
|
||||||
|
"open_issues": total_open_issues,
|
||||||
|
"recent_merges_7d": total_recent_merges,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# VPS health helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def check_ssh(ip: str, timeout: int = 5) -> bool:
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
result = sock.connect_ex((ip, 22))
|
||||||
|
sock.close()
|
||||||
|
return result == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_service(ip: str, user: str, service: str) -> str:
|
||||||
|
"""Check if a systemd service is active on remote host."""
|
||||||
|
cmd = f"ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 {user}@{ip} 'systemctl is-active {service} 2>/dev/null || echo inactive'"
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=15)
|
||||||
|
return proc.stdout.strip() or "unknown"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return "timeout"
|
||||||
|
except Exception:
|
||||||
|
return "error"
|
||||||
|
|
||||||
|
|
||||||
|
def check_disk(ip: str, user: str) -> dict:
|
||||||
|
cmd = f"ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 {user}@{ip} 'df -h / | tail -1'"
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=15)
|
||||||
|
if proc.returncode == 0:
|
||||||
|
parts = proc.stdout.strip().split()
|
||||||
|
if len(parts) >= 5:
|
||||||
|
return {"total": parts[1], "used": parts[2], "avail": parts[3], "pct": parts[4]}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"total": "?", "used": "?", "avail": "?", "pct": "?"}
|
||||||
|
|
||||||
|
|
||||||
|
def check_vps_health() -> dict:
|
||||||
|
result = {}
|
||||||
|
for name, cfg in VPS_HOSTS.items():
|
||||||
|
ip = cfg["ip"]
|
||||||
|
ssh_up = check_ssh(ip)
|
||||||
|
entry = {"ip": ip, "ssh": ssh_up, "services": {}, "disk": {}}
|
||||||
|
if ssh_up:
|
||||||
|
for svc in cfg.get("services", []):
|
||||||
|
entry["services"][svc] = check_service(ip, cfg["ssh_user"], svc)
|
||||||
|
entry["disk"] = check_disk(ip, cfg["ssh_user"])
|
||||||
|
result[name] = entry
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cron job status
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def check_cron_jobs() -> list[dict]:
|
||||||
|
jobs = []
|
||||||
|
if not CRON_JOBS_FILE.exists():
|
||||||
|
return [{"name": "jobs.json", "status": "FILE NOT FOUND"}]
|
||||||
|
try:
|
||||||
|
data = json.loads(CRON_JOBS_FILE.read_text())
|
||||||
|
for job in data.get("jobs", []):
|
||||||
|
jobs.append({
|
||||||
|
"name": job.get("name", "unnamed"),
|
||||||
|
"schedule": job.get("schedule_display", job.get("schedule", {}).get("display", "?")),
|
||||||
|
"enabled": job.get("enabled", False),
|
||||||
|
"state": job.get("state", "unknown"),
|
||||||
|
"completed": job.get("repeat", {}).get("completed", 0),
|
||||||
|
"last_status": job.get("last_status") or "never run",
|
||||||
|
"last_error": job.get("last_error"),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
jobs.append({"name": "jobs.json", "status": f"PARSE ERROR: {e}"})
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Terminal rendering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
DIM = "\033[2m"
|
||||||
|
GREEN = "\033[32m"
|
||||||
|
RED = "\033[31m"
|
||||||
|
YELLOW = "\033[33m"
|
||||||
|
CYAN = "\033[36m"
|
||||||
|
RESET = "\033[0m"
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(val: bool) -> str:
|
||||||
|
return f"{GREEN}UP{RESET}" if val else f"{RED}DOWN{RESET}"
|
||||||
|
|
||||||
|
|
||||||
|
def _svc_icon(status: str) -> str:
|
||||||
|
s = status.lower().strip()
|
||||||
|
if s in ("active", "running"):
|
||||||
|
return f"{GREEN}active{RESET}"
|
||||||
|
elif s in ("inactive", "dead", "failed"):
|
||||||
|
return f"{RED}{s}{RESET}"
|
||||||
|
elif s == "timeout":
|
||||||
|
return f"{YELLOW}timeout{RESET}"
|
||||||
|
else:
|
||||||
|
return f"{YELLOW}{s}{RESET}"
|
||||||
|
|
||||||
|
|
||||||
|
def render_dashboard(gitea: dict, vps: dict, cron: list[dict]) -> str:
|
||||||
|
lines = []
|
||||||
|
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"{BOLD}{'=' * 72}{RESET}")
|
||||||
|
lines.append(f"{BOLD} TIMMY FOUNDATION -- FLEET STATUS DASHBOARD{RESET}")
|
||||||
|
lines.append(f"{DIM} Generated: {now}{RESET}")
|
||||||
|
lines.append(f"{BOLD}{'=' * 72}{RESET}")
|
||||||
|
|
||||||
|
# ── Section 1: Gitea ──────────────────────────────────────────────────
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"{BOLD}{CYAN} [1] GITEA{RESET}")
|
||||||
|
lines.append(f" {'-' * 68}")
|
||||||
|
if gitea.get("reachable"):
|
||||||
|
lines.append(f" Status: {GREEN}REACHABLE{RESET} (version {gitea.get('version', '?')})")
|
||||||
|
t = gitea.get("totals", {})
|
||||||
|
lines.append(f" Totals: {t.get('open_prs', 0)} open PRs | {t.get('open_issues', 0)} open issues | {t.get('recent_merges_7d', 0)} merges (7d)")
|
||||||
|
lines.append("")
|
||||||
|
for repo_name, repo in gitea.get("repos", {}).items():
|
||||||
|
prs = repo.get("prs", [])
|
||||||
|
issues = repo.get("issues", [])
|
||||||
|
merges = repo.get("recent_merges", 0)
|
||||||
|
lines.append(f" {BOLD}{repo_name}{RESET} ({len(prs)} PRs, {len(issues)} issues, {merges} merges/7d)")
|
||||||
|
for pr in prs[:5]:
|
||||||
|
lines.append(f" PR #{pr['number']:>4} {pr['title'][:50]:<50} {DIM}{pr['user']}{RESET} {pr['created']}")
|
||||||
|
for iss in issues[:3]:
|
||||||
|
lines.append(f" IS #{iss['number']:>4} {iss['title'][:50]:<50} {DIM}{iss['user']}{RESET} {iss['created']}")
|
||||||
|
else:
|
||||||
|
lines.append(f" Status: {RED}UNREACHABLE{RESET}")
|
||||||
|
|
||||||
|
# ── Section 2: VPS Health ─────────────────────────────────────────────
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"{BOLD}{CYAN} [2] VPS HEALTH{RESET}")
|
||||||
|
lines.append(f" {'-' * 68}")
|
||||||
|
lines.append(f" {'Host':<12} {'IP':<18} {'SSH':<8} {'Disk':<12} {'Services'}")
|
||||||
|
lines.append(f" {'-' * 12} {'-' * 17} {'-' * 7} {'-' * 11} {'-' * 30}")
|
||||||
|
for name, info in vps.items():
|
||||||
|
ssh_str = _ok(info["ssh"])
|
||||||
|
disk = info.get("disk", {})
|
||||||
|
disk_str = disk.get("pct", "?")
|
||||||
|
if disk_str != "?":
|
||||||
|
pct_val = int(disk_str.rstrip("%"))
|
||||||
|
if pct_val >= 90:
|
||||||
|
disk_str = f"{RED}{disk_str}{RESET}"
|
||||||
|
elif pct_val >= 75:
|
||||||
|
disk_str = f"{YELLOW}{disk_str}{RESET}"
|
||||||
|
else:
|
||||||
|
disk_str = f"{GREEN}{disk_str}{RESET}"
|
||||||
|
svc_parts = []
|
||||||
|
for svc, status in info.get("services", {}).items():
|
||||||
|
svc_parts.append(f"{svc}:{_svc_icon(status)}")
|
||||||
|
svc_str = " ".join(svc_parts) if svc_parts else f"{DIM}n/a{RESET}"
|
||||||
|
lines.append(f" {name:<12} {info['ip']:<18} {ssh_str:<18} {disk_str:<22} {svc_str}")
|
||||||
|
|
||||||
|
# ── Section 3: Cron Jobs ──────────────────────────────────────────────
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"{BOLD}{CYAN} [3] CRON JOBS{RESET}")
|
||||||
|
lines.append(f" {'-' * 68}")
|
||||||
|
lines.append(f" {'Name':<28} {'Schedule':<16} {'State':<12} {'Last':<12} {'Runs'}")
|
||||||
|
lines.append(f" {'-' * 27} {'-' * 15} {'-' * 11} {'-' * 11} {'-' * 5}")
|
||||||
|
for job in cron:
|
||||||
|
name = job.get("name", "?")[:27]
|
||||||
|
sched = job.get("schedule", "?")[:15]
|
||||||
|
state = job.get("state", "?")
|
||||||
|
if state == "scheduled":
|
||||||
|
state_str = f"{GREEN}{state}{RESET}"
|
||||||
|
elif state == "paused":
|
||||||
|
state_str = f"{YELLOW}{state}{RESET}"
|
||||||
|
else:
|
||||||
|
state_str = state
|
||||||
|
last = job.get("last_status", "never")[:11]
|
||||||
|
if last == "ok":
|
||||||
|
last_str = f"{GREEN}{last}{RESET}"
|
||||||
|
elif last in ("error", "never run"):
|
||||||
|
last_str = f"{RED}{last}{RESET}"
|
||||||
|
else:
|
||||||
|
last_str = last
|
||||||
|
runs = job.get("completed", 0)
|
||||||
|
enabled = job.get("enabled", False)
|
||||||
|
marker = " " if enabled else f"{DIM}(disabled){RESET}"
|
||||||
|
lines.append(f" {name:<28} {sched:<16} {state_str:<22} {last_str:<22} {runs} {marker}")
|
||||||
|
|
||||||
|
# ── Footer ────────────────────────────────────────────────────────────
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"{BOLD}{'=' * 72}{RESET}")
|
||||||
|
lines.append(f"{DIM} python3 scripts/fleet-dashboard.py | timmy-config{RESET}")
|
||||||
|
lines.append(f"{BOLD}{'=' * 72}{RESET}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
json_mode = "--json" in sys.argv
|
||||||
|
|
||||||
|
if not json_mode:
|
||||||
|
print(f"\n {DIM}Collecting fleet data...{RESET}\n", file=sys.stderr)
|
||||||
|
|
||||||
|
gitea = check_gitea_health()
|
||||||
|
vps = check_vps_health()
|
||||||
|
cron = check_cron_jobs()
|
||||||
|
|
||||||
|
if json_mode:
|
||||||
|
output = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"gitea": gitea,
|
||||||
|
"vps": vps,
|
||||||
|
"cron": cron,
|
||||||
|
}
|
||||||
|
print(json.dumps(output, indent=2))
|
||||||
|
else:
|
||||||
|
print(render_dashboard(gitea, vps, cron))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,12 +1,884 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
foundation_accessibility_audit.py — Multimodal Visual Accessibility Audit.
|
||||||
|
|
||||||
|
Analyzes web pages for WCAG 2.1 AA compliance using both programmatic checks
|
||||||
|
and vision model analysis. Screenshots pages, checks contrast ratios, detects
|
||||||
|
layout issues, validates alt text, and produces structured audit reports.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Audit a single page
|
||||||
|
python scripts/foundation_accessibility_audit.py --url https://timmyfoundation.org
|
||||||
|
|
||||||
|
# Audit multiple pages
|
||||||
|
python scripts/foundation_accessibility_audit.py --url https://timmyfoundation.org --pages /about /donate /blog
|
||||||
|
|
||||||
|
# With vision model analysis (Gemma 3)
|
||||||
|
python scripts/foundation_accessibility_audit.py --url https://timmyfoundation.org --vision
|
||||||
|
|
||||||
|
# Programmatic-only (no vision model needed)
|
||||||
|
python scripts/foundation_accessibility_audit.py --url https://timmyfoundation.org --programmatic
|
||||||
|
|
||||||
|
# Output as text report
|
||||||
|
python scripts/foundation_accessibility_audit.py --url https://timmyfoundation.org --format text
|
||||||
|
|
||||||
|
WCAG 2.1 AA Checks:
|
||||||
|
1.4.3 Contrast (Minimum) — text vs background ratio >= 4.5:1
|
||||||
|
1.4.6 Contrast (Enhanced) — ratio >= 7:1 for AAA
|
||||||
|
1.4.11 Non-text Contrast — UI components >= 3:1
|
||||||
|
1.3.1 Info and Relationships — heading hierarchy, landmarks
|
||||||
|
1.1.1 Non-text Content — alt text on images
|
||||||
|
2.4.1 Bypass Blocks — skip navigation link
|
||||||
|
2.4.2 Page Titled — meaningful <title>
|
||||||
|
2.4.6 Headings and Labels — descriptive headings
|
||||||
|
4.1.2 Name, Role, Value — ARIA labels on interactive elements
|
||||||
|
|
||||||
|
Refs: timmy-config#492, WCAG 2.1 AA
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import colorsys
|
||||||
import json
|
import json
|
||||||
from hermes_tools import browser_navigate, browser_vision
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
def audit_accessibility():
|
|
||||||
browser_navigate(url="https://timmyfoundation.org")
|
# === Configuration ===
|
||||||
analysis = browser_vision(
|
|
||||||
question="Perform an accessibility audit. Check for: 1) Color contrast, 2) Font legibility, 3) Missing alt text for images. Provide a report with FAIL/PASS."
|
OLLAMA_BASE = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||||
|
VISION_MODEL = os.environ.get("VISUAL_REVIEW_MODEL", "gemma3:12b")
|
||||||
|
|
||||||
|
DEFAULT_PAGES = ["/", "/about", "/donate", "/blog", "/contact"]
|
||||||
|
|
||||||
|
|
||||||
|
class Severity(str, Enum):
|
||||||
|
CRITICAL = "critical" # Blocks access entirely
|
||||||
|
MAJOR = "major" # Significant barrier
|
||||||
|
MINOR = "minor" # Inconvenience
|
||||||
|
PASS = "pass"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class A11yViolation:
|
||||||
|
"""A single accessibility violation."""
|
||||||
|
criterion: str # WCAG criterion (e.g. "1.4.3")
|
||||||
|
criterion_name: str # Human-readable name
|
||||||
|
severity: Severity = Severity.MINOR
|
||||||
|
element: str = "" # CSS selector or element description
|
||||||
|
description: str = "" # What's wrong
|
||||||
|
fix: str = "" # Suggested fix
|
||||||
|
source: str = "" # "programmatic" or "vision"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class A11yPageResult:
|
||||||
|
"""Audit result for a single page."""
|
||||||
|
url: str = ""
|
||||||
|
title: str = ""
|
||||||
|
score: int = 100
|
||||||
|
violations: list[A11yViolation] = field(default_factory=list)
|
||||||
|
passed_checks: list[str] = field(default_factory=list)
|
||||||
|
summary: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class A11yAuditReport:
|
||||||
|
"""Complete audit report across all pages."""
|
||||||
|
site: str = ""
|
||||||
|
pages_audited: int = 0
|
||||||
|
overall_score: int = 100
|
||||||
|
total_violations: int = 0
|
||||||
|
critical_violations: int = 0
|
||||||
|
major_violations: int = 0
|
||||||
|
page_results: list[A11yPageResult] = field(default_factory=list)
|
||||||
|
summary: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# === HTML Parser for Programmatic Checks ===
|
||||||
|
|
||||||
|
class A11yHTMLParser(HTMLParser):
|
||||||
|
"""Extract accessibility-relevant elements from HTML."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.title = ""
|
||||||
|
self.images = [] # [{"src": ..., "alt": ...}]
|
||||||
|
self.headings = [] # [{"level": int, "text": ...}]
|
||||||
|
self.links = [] # [{"text": ..., "href": ...}]
|
||||||
|
self.inputs = [] # [{"type": ..., "label": ..., "id": ...}]
|
||||||
|
self.landmarks = [] # [{"tag": ..., "role": ...}]
|
||||||
|
self.skip_nav = False
|
||||||
|
self.lang = ""
|
||||||
|
self.in_title = False
|
||||||
|
self.in_heading = False
|
||||||
|
self.heading_level = 0
|
||||||
|
self.heading_text = ""
|
||||||
|
self.current_text = ""
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
attr_dict = dict(attrs)
|
||||||
|
|
||||||
|
if tag == "title":
|
||||||
|
self.in_title = True
|
||||||
|
elif tag == "html":
|
||||||
|
self.lang = attr_dict.get("lang", "")
|
||||||
|
elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||||
|
self.in_heading = True
|
||||||
|
self.heading_level = int(tag[1])
|
||||||
|
self.heading_text = ""
|
||||||
|
elif tag == "img":
|
||||||
|
self.images.append({
|
||||||
|
"src": attr_dict.get("src", ""),
|
||||||
|
"alt": attr_dict.get("alt"),
|
||||||
|
"role": attr_dict.get("role", ""),
|
||||||
|
})
|
||||||
|
elif tag == "a":
|
||||||
|
self.links.append({
|
||||||
|
"href": attr_dict.get("href", ""),
|
||||||
|
"text": "",
|
||||||
|
"aria_label": attr_dict.get("aria-label", ""),
|
||||||
|
})
|
||||||
|
elif tag in ("input", "select", "textarea"):
|
||||||
|
self.inputs.append({
|
||||||
|
"tag": tag,
|
||||||
|
"type": attr_dict.get("type", "text"),
|
||||||
|
"id": attr_dict.get("id", ""),
|
||||||
|
"aria_label": attr_dict.get("aria-label", ""),
|
||||||
|
"aria_labelledby": attr_dict.get("aria-labelledby", ""),
|
||||||
|
})
|
||||||
|
elif tag in ("main", "nav", "header", "footer", "aside", "section", "form"):
|
||||||
|
self.landmarks.append({"tag": tag, "role": attr_dict.get("role", "")})
|
||||||
|
elif tag == "a" and ("skip" in attr_dict.get("href", "").lower() or
|
||||||
|
"skip" in attr_dict.get("class", "").lower()):
|
||||||
|
self.skip_nav = True
|
||||||
|
|
||||||
|
role = attr_dict.get("role", "")
|
||||||
|
if role in ("navigation", "main", "banner", "contentinfo", "complementary", "search"):
|
||||||
|
self.landmarks.append({"tag": tag, "role": role})
|
||||||
|
if role == "link" and "skip" in (attr_dict.get("aria-label", "") + attr_dict.get("href", "")).lower():
|
||||||
|
self.skip_nav = True
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if tag == "title":
|
||||||
|
self.in_title = False
|
||||||
|
elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||||
|
self.headings.append({"level": self.heading_level, "text": self.heading_text.strip()})
|
||||||
|
self.in_heading = False
|
||||||
|
elif tag == "a" and self.links:
|
||||||
|
self.links[-1]["text"] = self.current_text.strip()
|
||||||
|
self.current_text = ""
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if self.in_title:
|
||||||
|
self.title += data
|
||||||
|
if self.in_heading:
|
||||||
|
self.heading_text += data
|
||||||
|
self.current_text += data
|
||||||
|
|
||||||
|
|
||||||
|
# === Color/Contrast Utilities ===
|
||||||
|
|
||||||
|
def parse_color(color_str: str) -> Optional[tuple]:
|
||||||
|
"""Parse CSS color string to (r, g, b) tuple (0-255)."""
|
||||||
|
if not color_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
color_str = color_str.strip().lower()
|
||||||
|
|
||||||
|
# Named colors (subset)
|
||||||
|
named = {
|
||||||
|
"white": (255, 255, 255), "black": (0, 0, 0),
|
||||||
|
"red": (255, 0, 0), "green": (0, 128, 0), "blue": (0, 0, 255),
|
||||||
|
"gray": (128, 128, 128), "grey": (128, 128, 128),
|
||||||
|
"silver": (192, 192, 192), "yellow": (255, 255, 0),
|
||||||
|
"orange": (255, 165, 0), "purple": (128, 0, 128),
|
||||||
|
"transparent": None,
|
||||||
|
}
|
||||||
|
if color_str in named:
|
||||||
|
return named[color_str]
|
||||||
|
|
||||||
|
# #RRGGBB or #RGB
|
||||||
|
if color_str.startswith("#"):
|
||||||
|
hex_str = color_str[1:]
|
||||||
|
if len(hex_str) == 3:
|
||||||
|
hex_str = "".join(c * 2 for c in hex_str)
|
||||||
|
if len(hex_str) == 6:
|
||||||
|
try:
|
||||||
|
return tuple(int(hex_str[i:i+2], 16) for i in (0, 2, 4))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# rgb(r, g, b)
|
||||||
|
match = re.match(r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", color_str)
|
||||||
|
if match:
|
||||||
|
return tuple(int(match.group(i)) for i in (1, 2, 3))
|
||||||
|
|
||||||
|
# rgba(r, g, b, a)
|
||||||
|
match = re.match(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*[\d.]+\s*\)", color_str)
|
||||||
|
if match:
|
||||||
|
return tuple(int(match.group(i)) for i in (1, 2, 3))
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def relative_luminance(rgb: tuple) -> float:
|
||||||
|
"""Calculate relative luminance per WCAG 2.1 (sRGB)."""
|
||||||
|
def linearize(c):
|
||||||
|
c = c / 255.0
|
||||||
|
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||||
|
|
||||||
|
r, g, b = [linearize(c) for c in rgb]
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||||
|
|
||||||
|
|
||||||
|
def contrast_ratio(color1: tuple, color2: tuple) -> float:
|
||||||
|
"""Calculate contrast ratio between two colors per WCAG 2.1."""
|
||||||
|
l1 = relative_luminance(color1)
|
||||||
|
l2 = relative_luminance(color2)
|
||||||
|
lighter = max(l1, l2)
|
||||||
|
darker = min(l1, l2)
|
||||||
|
return (lighter + 0.05) / (darker + 0.05)
|
||||||
|
|
||||||
|
|
||||||
|
# === Programmatic Checks ===
|
||||||
|
|
||||||
|
def check_page_title(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||||
|
"""WCAG 2.4.2 — Page Titled."""
|
||||||
|
violations = []
|
||||||
|
title = parser.title.strip()
|
||||||
|
if not title:
|
||||||
|
violations.append(A11yViolation(
|
||||||
|
criterion="2.4.2", criterion_name="Page Titled",
|
||||||
|
severity=Severity.MAJOR,
|
||||||
|
element="<title>",
|
||||||
|
description="Page has no title or title is empty.",
|
||||||
|
fix="Add a meaningful <title> that describes the page purpose.",
|
||||||
|
source="programmatic"
|
||||||
|
))
|
||||||
|
elif len(title) < 5:
|
||||||
|
violations.append(A11yViolation(
|
||||||
|
criterion="2.4.2", criterion_name="Page Titled",
|
||||||
|
severity=Severity.MINOR,
|
||||||
|
element=f"<title>{title}</title>",
|
||||||
|
description=f"Page title is very short: '{title}'",
|
||||||
|
fix="Use a more descriptive title.",
|
||||||
|
source="programmatic"
|
||||||
|
))
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def check_lang_attribute(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||||
|
"""WCAG 3.1.1 — Language of Page."""
|
||||||
|
violations = []
|
||||||
|
if not parser.lang:
|
||||||
|
violations.append(A11yViolation(
|
||||||
|
criterion="3.1.1", criterion_name="Language of Page",
|
||||||
|
severity=Severity.MAJOR,
|
||||||
|
element="<html>",
|
||||||
|
description="Missing lang attribute on <html> element.",
|
||||||
|
fix="Add lang=\"en\" (or appropriate language code) to <html>.",
|
||||||
|
source="programmatic"
|
||||||
|
))
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def check_images_alt_text(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||||
|
"""WCAG 1.1.1 — Non-text Content."""
|
||||||
|
violations = []
|
||||||
|
for img in parser.images:
|
||||||
|
if img.get("role") == "presentation" or img.get("role") == "none":
|
||||||
|
continue # Decorative images are exempt
|
||||||
|
alt = img.get("alt")
|
||||||
|
src = img.get("src", "unknown")
|
||||||
|
if alt is None:
|
||||||
|
violations.append(A11yViolation(
|
||||||
|
criterion="1.1.1", criterion_name="Non-text Content",
|
||||||
|
severity=Severity.CRITICAL,
|
||||||
|
element=f"<img src=\"{src[:80]}\">",
|
||||||
|
description="Image missing alt attribute.",
|
||||||
|
fix="Add descriptive alt text, or alt=\"\" with role=\"presentation\" for decorative images.",
|
||||||
|
source="programmatic"
|
||||||
|
))
|
||||||
|
elif alt.strip() == "":
|
||||||
|
# Empty alt is OK only for decorative images
|
||||||
|
if img.get("role") not in ("presentation", "none"):
|
||||||
|
violations.append(A11yViolation(
|
||||||
|
criterion="1.1.1", criterion_name="Non-text Content",
|
||||||
|
severity=Severity.MINOR,
|
||||||
|
element=f"<img src=\"{src[:80]}\" alt=\"\">",
|
||||||
|
description="Empty alt text — ensure this image is decorative.",
|
||||||
|
fix="If decorative, add role=\"presentation\". If meaningful, add descriptive alt text.",
|
||||||
|
source="programmatic"
|
||||||
|
))
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def check_heading_hierarchy(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||||
|
"""WCAG 1.3.1 — Info and Relationships (heading hierarchy)."""
|
||||||
|
violations = []
|
||||||
|
if not parser.headings:
|
||||||
|
violations.append(A11yViolation(
|
||||||
|
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||||
|
severity=Severity.MAJOR,
|
||||||
|
element="document",
|
||||||
|
description="No headings found on page.",
|
||||||
|
fix="Add proper heading hierarchy starting with <h1>.",
|
||||||
|
source="programmatic"
|
||||||
|
))
|
||||||
|
return violations
|
||||||
|
|
||||||
|
# Check for H1
|
||||||
|
h1s = [h for h in parser.headings if h["level"] == 1]
|
||||||
|
if not h1s:
|
||||||
|
violations.append(A11yViolation(
|
||||||
|
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||||
|
severity=Severity.MAJOR,
|
||||||
|
element="document",
|
||||||
|
description="No <h1> heading found.",
|
||||||
|
fix="Add a single <h1> as the main page heading.",
|
||||||
|
source="programmatic"
|
||||||
|
))
|
||||||
|
elif len(h1s) > 1:
|
||||||
|
violations.append(A11yViolation(
|
||||||
|
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||||
|
severity=Severity.MINOR,
|
||||||
|
element="document",
|
||||||
|
description=f"Multiple <h1> headings found ({len(h1s)}).",
|
||||||
|
fix="Use a single <h1> per page for the main heading.",
|
||||||
|
source="programmatic"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Check hierarchy skips
|
||||||
|
prev_level = 0
|
||||||
|
for h in parser.headings:
|
||||||
|
level = h["level"]
|
||||||
|
if level > prev_level + 1 and prev_level > 0:
|
||||||
|
violations.append(A11yViolation(
|
||||||
|
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||||
|
severity=Severity.MINOR,
|
||||||
|
element=f"<h{level}>{h['text'][:50]}</h{level}>",
|
||||||
|
description=f"Heading level skipped: h{prev_level} → h{level}",
|
||||||
|
fix=f"Use <h{prev_level + 1}> instead, or fill the gap.",
|
||||||
|
source="programmatic"
|
||||||
|
))
|
||||||
|
prev_level = level
|
||||||
|
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def check_landmarks(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||||
|
"""WCAG 1.3.1 — Landmarks and structure."""
|
||||||
|
violations = []
|
||||||
|
roles = {lm.get("role", "") for lm in parser.landmarks}
|
||||||
|
tags = {lm.get("tag", "") for lm in parser.landmarks}
|
||||||
|
|
||||||
|
has_main = "main" in roles or "main" in tags
|
||||||
|
has_nav = "navigation" in roles or "nav" in tags
|
||||||
|
|
||||||
|
if not has_main:
|
||||||
|
violations.append(A11yViolation(
|
||||||
|
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||||
|
severity=Severity.MAJOR,
|
||||||
|
element="document",
|
||||||
|
description="No <main> landmark found.",
|
||||||
|
fix="Wrap the main content in a <main> element.",
|
||||||
|
source="programmatic"
|
||||||
|
))
|
||||||
|
|
||||||
|
if not has_nav:
|
||||||
|
violations.append(A11yViolation(
|
||||||
|
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||||
|
severity=Severity.MINOR,
|
||||||
|
element="document",
|
||||||
|
description="No <nav> landmark found.",
|
||||||
|
fix="Wrap navigation in a <nav> element.",
|
||||||
|
source="programmatic"
|
||||||
|
))
|
||||||
|
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def check_skip_nav(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||||
|
"""WCAG 2.4.1 — Bypass Blocks."""
|
||||||
|
violations = []
|
||||||
|
if not parser.skip_nav:
|
||||||
|
# Also check links for "skip" text
|
||||||
|
has_skip_link = any("skip" in l.get("text", "").lower() for l in parser.links)
|
||||||
|
if not has_skip_link:
|
||||||
|
violations.append(A11yViolation(
|
||||||
|
criterion="2.4.1", criterion_name="Bypass Blocks",
|
||||||
|
severity=Severity.MAJOR,
|
||||||
|
element="document",
|
||||||
|
description="No skip navigation link found.",
|
||||||
|
fix="Add a 'Skip to main content' link as the first focusable element.",
|
||||||
|
source="programmatic"
|
||||||
|
))
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def check_form_labels(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||||
|
"""WCAG 4.1.2 — Name, Role, Value (form inputs)."""
|
||||||
|
violations = []
|
||||||
|
for inp in parser.inputs:
|
||||||
|
if inp["type"] in ("hidden", "submit", "button", "reset", "image"):
|
||||||
|
continue
|
||||||
|
has_label = bool(inp.get("aria_label") or inp.get("aria_labelledby") or inp.get("id"))
|
||||||
|
if not has_label:
|
||||||
|
violations.append(A11yViolation(
|
||||||
|
criterion="4.1.2", criterion_name="Name, Role, Value",
|
||||||
|
severity=Severity.MAJOR,
|
||||||
|
element=f"<{inp['tag']} type=\"{inp['type']}\">",
|
||||||
|
description="Form input has no associated label or aria-label.",
|
||||||
|
fix="Add a <label for=\"...\"> or aria-label attribute.",
|
||||||
|
source="programmatic"
|
||||||
|
))
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def check_link_text(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||||
|
"""WCAG 2.4.4 — Link Purpose."""
|
||||||
|
violations = []
|
||||||
|
for link in parser.links:
|
||||||
|
text = (link.get("text", "") or link.get("aria_label", "")).strip().lower()
|
||||||
|
href = link.get("href", "")
|
||||||
|
if not text:
|
||||||
|
violations.append(A11yViolation(
|
||||||
|
criterion="2.4.4", criterion_name="Link Purpose",
|
||||||
|
severity=Severity.MAJOR,
|
||||||
|
element=f"<a href=\"{href[:60]}\">",
|
||||||
|
description="Link has no accessible text.",
|
||||||
|
fix="Add visible text content or aria-label to the link.",
|
||||||
|
source="programmatic"
|
||||||
|
))
|
||||||
|
elif text in ("click here", "read more", "here", "more", "link"):
|
||||||
|
violations.append(A11yViolation(
|
||||||
|
criterion="2.4.4", criterion_name="Link Purpose",
|
||||||
|
severity=Severity.MINOR,
|
||||||
|
element=f"<a href=\"{href[:60]}\">{text}</a>",
|
||||||
|
description=f"Non-descriptive link text: '{text}'",
|
||||||
|
fix="Use descriptive text that explains the link destination.",
|
||||||
|
source="programmatic"
|
||||||
|
))
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def run_programmatic_checks(html: str) -> list[A11yViolation]:
|
||||||
|
"""Run all programmatic accessibility checks on HTML content."""
|
||||||
|
parser = A11yHTMLParser()
|
||||||
|
try:
|
||||||
|
parser.feed(html)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
violations = []
|
||||||
|
violations.extend(check_page_title(parser))
|
||||||
|
violations.extend(check_lang_attribute(parser))
|
||||||
|
violations.extend(check_images_alt_text(parser))
|
||||||
|
violations.extend(check_heading_hierarchy(parser))
|
||||||
|
violations.extend(check_landmarks(parser))
|
||||||
|
violations.extend(check_skip_nav(parser))
|
||||||
|
violations.extend(check_form_labels(parser))
|
||||||
|
violations.extend(check_link_text(parser))
|
||||||
|
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
# === Vision Model Checks ===
|
||||||
|
|
||||||
|
A11Y_VISION_PROMPT = """You are a WCAG 2.1 AA accessibility auditor. Analyze this screenshot of a web page.
|
||||||
|
|
||||||
|
Check for these specific issues:
|
||||||
|
|
||||||
|
1. COLOR CONTRAST: Are text colors sufficiently different from their backgrounds?
|
||||||
|
- Normal text needs 4.5:1 contrast ratio
|
||||||
|
- Large text (18pt+) needs 3:1
|
||||||
|
- UI components need 3:1
|
||||||
|
List any text or UI elements where contrast looks insufficient.
|
||||||
|
|
||||||
|
2. FONT LEGIBILITY: Is text readable?
|
||||||
|
- Font size >= 12px for body text
|
||||||
|
- Line height >= 1.5 for body text
|
||||||
|
- No text in images (should be real text)
|
||||||
|
|
||||||
|
3. LAYOUT ISSUES: Is the layout accessible?
|
||||||
|
- Touch targets >= 44x44px
|
||||||
|
- Content not cut off or overlapping
|
||||||
|
- Logical reading order visible
|
||||||
|
- No horizontal scrolling at standard widths
|
||||||
|
|
||||||
|
4. FOCUS INDICATORS: Can you see which element has focus?
|
||||||
|
- Interactive elements should have visible focus rings
|
||||||
|
|
||||||
|
5. COLOR ALONE: Is information conveyed only by color?
|
||||||
|
- Errors/warnings should not rely solely on red/green
|
||||||
|
|
||||||
|
Respond as JSON:
|
||||||
|
{
|
||||||
|
"violations": [
|
||||||
|
{
|
||||||
|
"criterion": "1.4.3",
|
||||||
|
"criterion_name": "Contrast (Minimum)",
|
||||||
|
"severity": "critical|major|minor",
|
||||||
|
"element": "description of element",
|
||||||
|
"description": "what's wrong",
|
||||||
|
"fix": "how to fix"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"passed_checks": ["list of things that look good"],
|
||||||
|
"overall_score": 0-100,
|
||||||
|
"summary": "brief summary"
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
def run_vision_check(screenshot_path: str, model: str = VISION_MODEL) -> list[A11yViolation]:
|
||||||
|
"""Run vision model accessibility check on a screenshot."""
|
||||||
|
try:
|
||||||
|
b64 = base64.b64encode(Path(screenshot_path).read_bytes()).decode()
|
||||||
|
payload = json.dumps({
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": [
|
||||||
|
{"type": "text", "text": A11Y_VISION_PROMPT},
|
||||||
|
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
||||||
|
]}],
|
||||||
|
"stream": False,
|
||||||
|
"options": {"temperature": 0.1}
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{OLLAMA_BASE}/api/chat",
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||||
|
result = json.loads(resp.read())
|
||||||
|
content = result.get("message", {}).get("content", "")
|
||||||
|
|
||||||
|
# Parse response
|
||||||
|
parsed = _parse_json_response(content)
|
||||||
|
violations = []
|
||||||
|
for v in parsed.get("violations", []):
|
||||||
|
violations.append(A11yViolation(
|
||||||
|
criterion=v.get("criterion", ""),
|
||||||
|
criterion_name=v.get("criterion_name", ""),
|
||||||
|
severity=Severity(v.get("severity", "minor")),
|
||||||
|
element=v.get("element", ""),
|
||||||
|
description=v.get("description", ""),
|
||||||
|
fix=v.get("fix", ""),
|
||||||
|
source="vision"
|
||||||
|
))
|
||||||
|
return violations
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Vision check failed: {e}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_response(text: str) -> dict:
|
||||||
|
"""Extract JSON from potentially messy vision response."""
|
||||||
|
cleaned = text.strip()
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
lines = cleaned.split("\n")[1:]
|
||||||
|
if lines and lines[-1].strip() == "```":
|
||||||
|
lines = lines[:-1]
|
||||||
|
cleaned = "\n".join(lines)
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
start = cleaned.find("{")
|
||||||
|
end = cleaned.rfind("}")
|
||||||
|
if start >= 0 and end > start:
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned[start:end + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# === Page Fetching ===
|
||||||
|
|
||||||
|
def fetch_page(url: str) -> Optional[str]:
|
||||||
|
"""Fetch HTML content of a page."""
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "A11yAudit/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return resp.read().decode("utf-8", errors="replace")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Failed to fetch {url}: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def take_screenshot(url: str, output_path: str, width: int = 1280, height: int = 900) -> bool:
|
||||||
|
"""Take a screenshot using Playwright or curl-based headless capture."""
|
||||||
|
# Try Playwright first
|
||||||
|
try:
|
||||||
|
script = f"""
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={{"width": {width}, "height": {height}}})
|
||||||
|
page.goto("{url}", wait_until="networkidle", timeout=30000)
|
||||||
|
page.screenshot(path="{output_path}", full_page=True)
|
||||||
|
browser.close()
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["python3", "-c", script],
|
||||||
|
capture_output=True, text=True, timeout=60
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and Path(output_path).exists():
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try curl + wkhtmltoimage
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["wkhtmltoimage", "--width", str(width), "--quality", "90", url, output_path],
|
||||||
|
capture_output=True, text=True, timeout=30
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and Path(output_path).exists():
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# === Audit Logic ===
|
||||||
|
|
||||||
|
def audit_page(url: str, use_vision: bool = False, model: str = VISION_MODEL) -> A11yPageResult:
|
||||||
|
"""Run a full accessibility audit on a single page."""
|
||||||
|
result = A11yPageResult(url=url)
|
||||||
|
|
||||||
|
# Fetch HTML
|
||||||
|
html = fetch_page(url)
|
||||||
|
if not html:
|
||||||
|
result.summary = f"Failed to fetch {url}"
|
||||||
|
result.score = 0
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Extract title
|
||||||
|
title_match = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
|
||||||
|
result.title = title_match.group(1).strip() if title_match else ""
|
||||||
|
|
||||||
|
# Run programmatic checks
|
||||||
|
prog_violations = run_programmatic_checks(html)
|
||||||
|
result.violations.extend(prog_violations)
|
||||||
|
|
||||||
|
# Track passed checks
|
||||||
|
criteria_checked = {
|
||||||
|
"2.4.2": "Page Titled",
|
||||||
|
"3.1.1": "Language of Page",
|
||||||
|
"1.1.1": "Non-text Content",
|
||||||
|
"1.3.1": "Info and Relationships",
|
||||||
|
"2.4.1": "Bypass Blocks",
|
||||||
|
"4.1.2": "Name, Role, Value",
|
||||||
|
"2.4.4": "Link Purpose",
|
||||||
|
}
|
||||||
|
violated_criteria = {v.criterion for v in result.violations}
|
||||||
|
for criterion, name in criteria_checked.items():
|
||||||
|
if criterion not in violated_criteria:
|
||||||
|
result.passed_checks.append(f"{criterion} {name}")
|
||||||
|
|
||||||
|
# Vision check (optional)
|
||||||
|
if use_vision:
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||||
|
screenshot_path = tmp.name
|
||||||
|
try:
|
||||||
|
print(f" Taking screenshot of {url}...", file=sys.stderr)
|
||||||
|
if take_screenshot(url, screenshot_path):
|
||||||
|
print(f" Running vision analysis...", file=sys.stderr)
|
||||||
|
vision_violations = run_vision_check(screenshot_path, model)
|
||||||
|
result.violations.extend(vision_violations)
|
||||||
|
result.passed_checks.append("Vision model analysis completed")
|
||||||
|
else:
|
||||||
|
result.passed_checks.append("Screenshot unavailable — vision check skipped")
|
||||||
|
finally:
|
||||||
|
Path(screenshot_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
# Calculate score
|
||||||
|
criticals = sum(1 for v in result.violations if v.severity == Severity.CRITICAL)
|
||||||
|
majors = sum(1 for v in result.violations if v.severity == Severity.MAJOR)
|
||||||
|
minors = sum(1 for v in result.violations if v.severity == Severity.MINOR)
|
||||||
|
result.score = max(0, 100 - (criticals * 25) - (majors * 10) - (minors * 3))
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
if not result.violations:
|
||||||
|
result.summary = f"All programmatic checks passed for {url}"
|
||||||
|
else:
|
||||||
|
result.summary = (
|
||||||
|
f"{len(result.violations)} issue(s) found: "
|
||||||
|
f"{criticals} critical, {majors} major, {minors} minor"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def audit_site(base_url: str, pages: list[str], use_vision: bool = False,
|
||||||
|
model: str = VISION_MODEL) -> A11yAuditReport:
|
||||||
|
"""Audit multiple pages of a site."""
|
||||||
|
report = A11yAuditReport(site=base_url)
|
||||||
|
|
||||||
|
for path in pages:
|
||||||
|
url = base_url.rstrip("/") + path if not path.startswith("http") else path
|
||||||
|
print(f"Auditing: {url}", file=sys.stderr)
|
||||||
|
result = audit_page(url, use_vision, model)
|
||||||
|
report.page_results.append(result)
|
||||||
|
|
||||||
|
report.pages_audited = len(report.page_results)
|
||||||
|
report.total_violations = sum(len(p.violations) for p in report.page_results)
|
||||||
|
report.critical_violations = sum(
|
||||||
|
sum(1 for v in p.violations if v.severity == Severity.CRITICAL)
|
||||||
|
for p in report.page_results
|
||||||
|
)
|
||||||
|
report.major_violations = sum(
|
||||||
|
sum(1 for v in p.violations if v.severity == Severity.MAJOR)
|
||||||
|
for p in report.page_results
|
||||||
)
|
)
|
||||||
return {"status": "PASS" if "PASS" in analysis.upper() else "FAIL", "analysis": analysis}
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if report.page_results:
|
||||||
print(json.dumps(audit_accessibility(), indent=2))
|
report.overall_score = sum(p.score for p in report.page_results) // len(report.page_results)
|
||||||
|
|
||||||
|
report.summary = (
|
||||||
|
f"Audited {report.pages_audited} pages. "
|
||||||
|
f"Overall score: {report.overall_score}/100. "
|
||||||
|
f"{report.total_violations} total issues: "
|
||||||
|
f"{report.critical_violations} critical, {report.major_violations} major."
|
||||||
|
)
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
# === Output Formatting ===
|
||||||
|
|
||||||
|
def format_report(report: A11yAuditReport, fmt: str = "json") -> str:
|
||||||
|
"""Format the audit report."""
|
||||||
|
if fmt == "json":
|
||||||
|
data = {
|
||||||
|
"site": report.site,
|
||||||
|
"pages_audited": report.pages_audited,
|
||||||
|
"overall_score": report.overall_score,
|
||||||
|
"total_violations": report.total_violations,
|
||||||
|
"critical_violations": report.critical_violations,
|
||||||
|
"major_violations": report.major_violations,
|
||||||
|
"summary": report.summary,
|
||||||
|
"pages": []
|
||||||
|
}
|
||||||
|
for page in report.page_results:
|
||||||
|
page_data = {
|
||||||
|
"url": page.url,
|
||||||
|
"title": page.title,
|
||||||
|
"score": page.score,
|
||||||
|
"violations": [asdict(v) for v in page.violations],
|
||||||
|
"passed_checks": page.passed_checks,
|
||||||
|
"summary": page.summary,
|
||||||
|
}
|
||||||
|
# Convert severity enum to string
|
||||||
|
for v in page_data["violations"]:
|
||||||
|
if hasattr(v["severity"], "value"):
|
||||||
|
v["severity"] = v["severity"].value
|
||||||
|
data["pages"].append(page_data)
|
||||||
|
return json.dumps(data, indent=2)
|
||||||
|
|
||||||
|
elif fmt == "text":
|
||||||
|
lines = []
|
||||||
|
lines.append("=" * 60)
|
||||||
|
lines.append(" WEB ACCESSIBILITY AUDIT REPORT")
|
||||||
|
lines.append("=" * 60)
|
||||||
|
lines.append(f" Site: {report.site}")
|
||||||
|
lines.append(f" Pages audited: {report.pages_audited}")
|
||||||
|
lines.append(f" Overall score: {report.overall_score}/100")
|
||||||
|
lines.append(f" Issues: {report.total_violations} total "
|
||||||
|
f"({report.critical_violations} critical, {report.major_violations} major)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for page in report.page_results:
|
||||||
|
lines.append(f" ── {page.url} ──")
|
||||||
|
lines.append(f" Title: {page.title}")
|
||||||
|
lines.append(f" Score: {page.score}/100")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if page.violations:
|
||||||
|
lines.append(f" Violations ({len(page.violations)}):")
|
||||||
|
for v in page.violations:
|
||||||
|
sev_icon = {"critical": "🔴", "major": "🟡", "minor": "🔵"}.get(
|
||||||
|
v.severity.value if hasattr(v.severity, "value") else str(v.severity), "⚪"
|
||||||
|
)
|
||||||
|
lines.append(f" {sev_icon} [{v.criterion}] {v.criterion_name}")
|
||||||
|
lines.append(f" Element: {v.element}")
|
||||||
|
lines.append(f" Issue: {v.description}")
|
||||||
|
lines.append(f" Fix: {v.fix}")
|
||||||
|
lines.append(f" Source: {v.source}")
|
||||||
|
lines.append("")
|
||||||
|
else:
|
||||||
|
lines.append(" ✓ No violations found")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if page.passed_checks:
|
||||||
|
lines.append(f" Passed: {', '.join(page.passed_checks)}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("=" * 60)
|
||||||
|
lines.append(f" Summary: {report.summary}")
|
||||||
|
lines.append("=" * 60)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown format: {fmt}")
|
||||||
|
|
||||||
|
|
||||||
|
# === CLI ===
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Visual Accessibility Audit — WCAG 2.1 AA compliance checker",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
%(prog)s --url https://timmyfoundation.org
|
||||||
|
%(prog)s --url https://timmyfoundation.org --pages /about /donate
|
||||||
|
%(prog)s --url https://timmyfoundation.org --vision
|
||||||
|
%(prog)s --url https://timmyfoundation.org --format text
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
parser.add_argument("--url", required=True, help="Base URL to audit")
|
||||||
|
parser.add_argument("--pages", nargs="*", default=DEFAULT_PAGES,
|
||||||
|
help="Paths to audit (default: / /about /donate /blog /contact)")
|
||||||
|
parser.add_argument("--vision", action="store_true",
|
||||||
|
help="Include vision model analysis (requires Ollama)")
|
||||||
|
parser.add_argument("--model", default=VISION_MODEL,
|
||||||
|
help=f"Vision model (default: {VISION_MODEL})")
|
||||||
|
parser.add_argument("--format", choices=["json", "text"], default="json",
|
||||||
|
help="Output format")
|
||||||
|
parser.add_argument("--output", "-o", help="Output file (default: stdout)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
report = audit_site(args.url, args.pages, use_vision=args.vision, model=args.model)
|
||||||
|
output = format_report(report, args.format)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
Path(args.output).write_text(output)
|
||||||
|
print(f"Report written to {args.output}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
# Exit code: non-zero if critical violations
|
||||||
|
if report.critical_violations > 0:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
32
scripts/generate-rock-scenes.py
Normal file
32
scripts/generate-rock-scenes.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json, os
|
||||||
|
|
||||||
|
songs = [
|
||||||
|
{"t":"Thunder Road","a":"Heartland","m":["hope","anticipation","energy","triumph","nostalgia","urgency","passion","defiance","release","catharsis"]},
|
||||||
|
{"t":"Black Dog Howl","a":"Rust & Wire","m":["despair","anger","frenzy","exhaustion","resignation","grief","numbness","rage","acceptance","silence"]},
|
||||||
|
{"t":"Satellite Hearts","a":"Neon Circuit","m":["wonder","isolation","longing","connection","euphoria","confusion","clarity","tenderness","urgency","bittersweet"]},
|
||||||
|
{"t":"Concrete Garden","a":"Streetlight Prophet","m":["oppression","resilience","anger","beauty","defiance","community","joy","struggle","growth","hope"]},
|
||||||
|
{"t":"Gravity Well","a":"Void Walker","m":["dread","fascination","surrender","awe","terror","peace","disorientation","acceptance","transcendence","emptiness"]},
|
||||||
|
{"t":"Rust Belt Lullaby","a":"Iron & Ember","m":["nostalgia","sadness","tenderness","loss","beauty","resignation","love","weariness","quiet hope","peace"]},
|
||||||
|
{"t":"Wildfire Sermon","a":"Prophet Ash","m":["fury","ecstasy","chaos","joy","destruction","creation","warning","invitation","abandon","rebirth"]},
|
||||||
|
{"t":"Midnight Transmission","a":"Frequency Ghost","m":["mystery","loneliness","curiosity","connection","paranoia","intimacy","urgency","disconnection","searching","haunting"]},
|
||||||
|
{"t":"Crown of Thorns","a":"Velvet Guillotine","m":["seduction","power","cruelty","beauty","danger","vulnerability","fury","grace","revenge","mercy"]},
|
||||||
|
{"t":"Apartment 4B","a":"Wallpaper & Wire","m":["claustrophobia","routine","desperation","fantasy","breakthrough","freedom","fear","joy","grounding","home"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
beats = []
|
||||||
|
for s in songs:
|
||||||
|
for i in range(10):
|
||||||
|
beats.append({"song": s["t"], "artist": s["a"], "beat": i+1,
|
||||||
|
"timestamp": f"{i*30//60}:{(i*30)%60:02d}", "duration": "30s",
|
||||||
|
"lyric_line": f"[Beat {i+1}]", "scene": {"mood": s["m"][i], "colors": ["placeholder"],
|
||||||
|
"composition": ["wide","close","OTS","low","high","dutch","symmetric","thirds","xwide","medium"][i],
|
||||||
|
"camera": ["static","pan","dolly-in","dolly-out","handheld","steadicam","zoom","crane","track","tilt"][i],
|
||||||
|
"description": f"[{s['m'][i]} scene]"}})
|
||||||
|
|
||||||
|
out = os.path.expanduser("~/.hermes/training-data/scene-descriptions-rock.jsonl")
|
||||||
|
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||||
|
with open(out, "w") as f:
|
||||||
|
for b in beats:
|
||||||
|
f.write(json.dumps(b) + "\n")
|
||||||
|
print(f"Generated {len(beats)} beats")
|
||||||
@@ -22,6 +22,7 @@ CLI:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import ast
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -137,6 +138,42 @@ class KnowledgeBase:
|
|||||||
self._save(self._persist_path)
|
self._save(self._persist_path)
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
def ingest_python_file(
|
||||||
|
self, path: Path, *, module_name: Optional[str] = None, source: str = "ast"
|
||||||
|
) -> List[Fact]:
|
||||||
|
"""Parse a Python file with ``ast`` and assert symbolic structure facts."""
|
||||||
|
tree = ast.parse(path.read_text(), filename=str(path))
|
||||||
|
module = module_name or path.stem
|
||||||
|
fact_source = f"{source}:{path.name}"
|
||||||
|
added: List[Fact] = []
|
||||||
|
|
||||||
|
def add(relation: str, *args: str) -> None:
|
||||||
|
added.append(self.assert_fact(relation, *args, source=fact_source))
|
||||||
|
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
for alias in node.names:
|
||||||
|
add("imports", module, alias.name)
|
||||||
|
elif isinstance(node, ast.ImportFrom):
|
||||||
|
prefix = f"{node.module}." if node.module else ""
|
||||||
|
for alias in node.names:
|
||||||
|
add("imports", module, f"{prefix}{alias.name}")
|
||||||
|
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
add("defines_function", module, node.name)
|
||||||
|
elif isinstance(node, ast.ClassDef):
|
||||||
|
add("defines_class", module, node.name)
|
||||||
|
for child in node.body:
|
||||||
|
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
add("defines_method", node.name, child.name)
|
||||||
|
elif isinstance(node, ast.Assign):
|
||||||
|
for target in node.targets:
|
||||||
|
if isinstance(target, ast.Name) and target.id.isupper():
|
||||||
|
add("defines_constant", module, target.id)
|
||||||
|
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id.isupper():
|
||||||
|
add("defines_constant", module, node.target.id)
|
||||||
|
|
||||||
|
return added
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Query
|
# Query
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -287,6 +324,12 @@ def main() -> None:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Dump all facts",
|
help="Dump all facts",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ingest-python",
|
||||||
|
dest="ingest_python",
|
||||||
|
type=Path,
|
||||||
|
help="Parse a Python file with AST and assert symbolic structure facts",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--relation",
|
"--relation",
|
||||||
help="Filter --dump to a specific relation",
|
help="Filter --dump to a specific relation",
|
||||||
@@ -304,6 +347,10 @@ def main() -> None:
|
|||||||
fact = kb.assert_fact(terms[0], *terms[1:], source="cli")
|
fact = kb.assert_fact(terms[0], *terms[1:], source="cli")
|
||||||
print(f"Asserted: {fact}")
|
print(f"Asserted: {fact}")
|
||||||
|
|
||||||
|
if args.ingest_python:
|
||||||
|
added = kb.ingest_python_file(args.ingest_python, source="cli-ast")
|
||||||
|
print(f"Ingested {len(added)} AST fact(s) from {args.ingest_python}")
|
||||||
|
|
||||||
if args.retract_stmt:
|
if args.retract_stmt:
|
||||||
terms = _parse_terms(args.retract_stmt)
|
terms = _parse_terms(args.retract_stmt)
|
||||||
if len(terms) < 2:
|
if len(terms) < 2:
|
||||||
|
|||||||
@@ -1,12 +1,599 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
matrix_glitch_detect.py — 3D World Visual Artifact Detection for The Matrix.
|
||||||
|
|
||||||
|
Scans screenshots or live pages for visual glitches: floating assets, z-fighting,
|
||||||
|
texture pop-in, clipping, broken meshes, lighting artifacts. Outputs structured
|
||||||
|
JSON, text, or standalone HTML report with annotated screenshots.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Scan a screenshot
|
||||||
|
python scripts/matrix_glitch_detect.py --image screenshot.png
|
||||||
|
|
||||||
|
# Scan with vision model
|
||||||
|
python scripts/matrix_glitch_detect.py --image screenshot.png --vision
|
||||||
|
|
||||||
|
# HTML report
|
||||||
|
python scripts/matrix_glitch_detect.py --image screenshot.png --html report.html
|
||||||
|
|
||||||
|
# Scan live Matrix page
|
||||||
|
python scripts/matrix_glitch_detect.py --url https://matrix.alexanderwhitestone.com
|
||||||
|
|
||||||
|
# Batch scan a directory
|
||||||
|
python scripts/matrix_glitch_detect.py --batch ./screenshots/ --html batch-report.html
|
||||||
|
|
||||||
|
Refs: timmy-config#491, #541, #543, #544
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import html as html_module
|
||||||
import json
|
import json
|
||||||
from hermes_tools import browser_navigate, browser_vision
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
def detect_glitches():
|
|
||||||
browser_navigate(url="https://matrix.alexanderwhitestone.com")
|
# === Configuration ===
|
||||||
analysis = browser_vision(
|
|
||||||
question="Scan the 3D world for visual artifacts, floating assets, or z-fighting. List all coordinates/descriptions of glitches found. Provide a PASS/FAIL."
|
OLLAMA_BASE = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||||
|
VISION_MODEL = os.environ.get("VISUAL_REVIEW_MODEL", "gemma3:12b")
|
||||||
|
|
||||||
|
|
||||||
|
class Severity(str, Enum):
|
||||||
|
CRITICAL = "critical"
|
||||||
|
MAJOR = "major"
|
||||||
|
MINOR = "minor"
|
||||||
|
COSMETIC = "cosmetic"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Glitch:
|
||||||
|
"""A single detected visual artifact."""
|
||||||
|
type: str = "" # floating_asset, z_fighting, texture_pop, clipping, lighting, mesh_break
|
||||||
|
severity: Severity = Severity.MINOR
|
||||||
|
region: str = "" # "upper-left", "center", "bottom-right", or coordinates
|
||||||
|
description: str = ""
|
||||||
|
confidence: float = 0.0 # 0.0-1.0
|
||||||
|
source: str = "" # "programmatic", "vision", "pixel_analysis"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GlitchReport:
|
||||||
|
"""Complete glitch detection report."""
|
||||||
|
source: str = "" # file path or URL
|
||||||
|
timestamp: str = ""
|
||||||
|
status: str = "PASS" # PASS, WARN, FAIL
|
||||||
|
score: int = 100
|
||||||
|
glitches: list[Glitch] = field(default_factory=list)
|
||||||
|
summary: str = ""
|
||||||
|
model_used: str = ""
|
||||||
|
width: int = 0
|
||||||
|
height: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# === Programmatic Analysis ===
|
||||||
|
|
||||||
|
def analyze_pixels(image_path: str) -> list[Glitch]:
|
||||||
|
"""Programmatic pixel analysis for common 3D glitches."""
|
||||||
|
glitches = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
img = Image.open(image_path).convert("RGB")
|
||||||
|
w, h = img.size
|
||||||
|
pixels = img.load()
|
||||||
|
|
||||||
|
# Check for solid-color regions (render failure)
|
||||||
|
corner_colors = [
|
||||||
|
pixels[0, 0], pixels[w-1, 0], pixels[0, h-1], pixels[w-1, h-1]
|
||||||
|
]
|
||||||
|
if all(c == corner_colors[0] for c in corner_colors):
|
||||||
|
# All corners same color — check if it's black (render failure)
|
||||||
|
if corner_colors[0] == (0, 0, 0):
|
||||||
|
glitches.append(Glitch(
|
||||||
|
type="render_failure",
|
||||||
|
severity=Severity.CRITICAL,
|
||||||
|
region="entire frame",
|
||||||
|
description="Entire frame is black — 3D scene failed to render",
|
||||||
|
confidence=0.9,
|
||||||
|
source="pixel_analysis"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Check for horizontal tearing lines
|
||||||
|
tear_count = 0
|
||||||
|
for y in range(0, h, max(1, h // 20)):
|
||||||
|
row_start = pixels[0, y]
|
||||||
|
same_count = sum(1 for x in range(w) if pixels[x, y] == row_start)
|
||||||
|
if same_count > w * 0.95:
|
||||||
|
tear_count += 1
|
||||||
|
if tear_count > 3:
|
||||||
|
glitches.append(Glitch(
|
||||||
|
type="horizontal_tear",
|
||||||
|
severity=Severity.MAJOR,
|
||||||
|
region=f"{tear_count} lines",
|
||||||
|
description=f"Horizontal tearing detected — {tear_count} mostly-solid scanlines",
|
||||||
|
confidence=0.7,
|
||||||
|
source="pixel_analysis"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Check for extreme brightness variance (lighting artifacts)
|
||||||
|
import statistics
|
||||||
|
brightness_samples = []
|
||||||
|
for y in range(0, h, max(1, h // 50)):
|
||||||
|
for x in range(0, w, max(1, w // 50)):
|
||||||
|
r, g, b = pixels[x, y]
|
||||||
|
brightness_samples.append(0.299 * r + 0.587 * g + 0.114 * b)
|
||||||
|
if brightness_samples:
|
||||||
|
stdev = statistics.stdev(brightness_samples)
|
||||||
|
if stdev > 100:
|
||||||
|
glitches.append(Glitch(
|
||||||
|
type="lighting",
|
||||||
|
severity=Severity.MINOR,
|
||||||
|
region="global",
|
||||||
|
description=f"Extreme brightness variance (stdev={stdev:.0f}) — possible lighting artifacts",
|
||||||
|
confidence=0.5,
|
||||||
|
source="pixel_analysis"
|
||||||
|
))
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
pass # PIL not available
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return glitches
|
||||||
|
|
||||||
|
|
||||||
|
# === Vision Analysis ===
|
||||||
|
|
||||||
|
GLITCH_VISION_PROMPT = """You are a 3D world QA engineer. Analyze this screenshot from a Three.js 3D world (The Matrix) for visual glitches and artifacts.
|
||||||
|
|
||||||
|
Look for these specific issues:
|
||||||
|
|
||||||
|
1. FLOATING ASSETS: Objects hovering above surfaces where they should rest. Look for shadows detached from objects.
|
||||||
|
|
||||||
|
2. Z-FIGHTING: Flickering or shimmering surfaces where two polygons overlap at the same depth. Usually appears as striped or dithered patterns.
|
||||||
|
|
||||||
|
3. TEXTURE POP-IN: Low-resolution textures that haven't loaded, or textures that suddenly change quality between frames.
|
||||||
|
|
||||||
|
4. CLIPPING: Objects passing through walls, floors, or other objects. Characters partially inside geometry.
|
||||||
|
|
||||||
|
5. LIGHTING ARTIFACTS: Hard light seams, black patches, overexposed areas, lights not illuminating correctly.
|
||||||
|
|
||||||
|
6. MESH BREAKS: Visible seams in geometry, missing faces on 3D objects, holes in surfaces.
|
||||||
|
|
||||||
|
7. RENDER FAILURE: Black areas where geometry should be, missing skybox, incomplete frame rendering.
|
||||||
|
|
||||||
|
8. UI OVERLAP: UI elements overlapping 3D viewport incorrectly.
|
||||||
|
|
||||||
|
Respond as JSON:
|
||||||
|
{
|
||||||
|
"glitches": [
|
||||||
|
{
|
||||||
|
"type": "floating_asset|z_fighting|texture_pop|clipping|lighting|mesh_break|render_failure|ui_overlap",
|
||||||
|
"severity": "critical|major|minor|cosmetic",
|
||||||
|
"region": "description of where",
|
||||||
|
"description": "detailed description of the artifact",
|
||||||
|
"confidence": 0.0-1.0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"overall_quality": 0-100,
|
||||||
|
"summary": "brief assessment"
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
def run_vision_analysis(image_path: str, model: str = VISION_MODEL) -> tuple[list[Glitch], int]:
|
||||||
|
"""Run vision model glitch analysis."""
|
||||||
|
try:
|
||||||
|
b64 = base64.b64encode(Path(image_path).read_bytes()).decode()
|
||||||
|
payload = json.dumps({
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": [
|
||||||
|
{"type": "text", "text": GLITCH_VISION_PROMPT},
|
||||||
|
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
||||||
|
]}],
|
||||||
|
"stream": False,
|
||||||
|
"options": {"temperature": 0.1}
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{OLLAMA_BASE}/api/chat",
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||||
|
result = json.loads(resp.read())
|
||||||
|
content = result.get("message", {}).get("content", "")
|
||||||
|
|
||||||
|
parsed = _parse_json_response(content)
|
||||||
|
glitches = []
|
||||||
|
for g in parsed.get("glitches", []):
|
||||||
|
glitches.append(Glitch(
|
||||||
|
type=g.get("type", "unknown"),
|
||||||
|
severity=Severity(g.get("severity", "minor")),
|
||||||
|
region=g.get("region", ""),
|
||||||
|
description=g.get("description", ""),
|
||||||
|
confidence=float(g.get("confidence", 0.5)),
|
||||||
|
source="vision"
|
||||||
|
))
|
||||||
|
return glitches, parsed.get("overall_quality", 80)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Vision analysis failed: {e}", file=sys.stderr)
|
||||||
|
return [], 50
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_response(text: str) -> dict:
|
||||||
|
cleaned = text.strip()
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
lines = cleaned.split("\n")[1:]
|
||||||
|
if lines and lines[-1].strip() == "```":
|
||||||
|
lines = lines[:-1]
|
||||||
|
cleaned = "\n".join(lines)
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
start = cleaned.find("{")
|
||||||
|
end = cleaned.rfind("}")
|
||||||
|
if start >= 0 and end > start:
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned[start:end + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# === Screenshot Capture ===
|
||||||
|
|
||||||
|
def capture_screenshot(url: str, output_path: str) -> bool:
|
||||||
|
"""Take a screenshot of a URL."""
|
||||||
|
try:
|
||||||
|
script = f"""
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={{"width": 1280, "height": 720}})
|
||||||
|
page.goto("{url}", wait_until="networkidle", timeout=30000)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
page.screenshot(path="{output_path}")
|
||||||
|
browser.close()
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["python3", "-c", script], capture_output=True, text=True, timeout=60)
|
||||||
|
return result.returncode == 0 and Path(output_path).exists()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# === Detection Logic ===
|
||||||
|
|
||||||
|
def detect_glitches(image_path: str, use_vision: bool = False,
|
||||||
|
model: str = VISION_MODEL) -> GlitchReport:
|
||||||
|
"""Run full glitch detection on an image."""
|
||||||
|
report = GlitchReport(
|
||||||
|
source=image_path,
|
||||||
|
timestamp=datetime.now().isoformat(),
|
||||||
|
model_used=model if use_vision else "none"
|
||||||
)
|
)
|
||||||
return {"status": "PASS" if "PASS" in analysis.upper() else "FAIL", "analysis": analysis}
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if not Path(image_path).exists():
|
||||||
print(json.dumps(detect_glitches(), indent=2))
|
report.status = "FAIL"
|
||||||
|
report.summary = f"File not found: {image_path}"
|
||||||
|
report.score = 0
|
||||||
|
return report
|
||||||
|
|
||||||
|
# Get image dimensions
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
img = Image.open(image_path)
|
||||||
|
report.width, report.height = img.size
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Programmatic analysis
|
||||||
|
prog_glitches = analyze_pixels(image_path)
|
||||||
|
report.glitches.extend(prog_glitches)
|
||||||
|
|
||||||
|
# Vision analysis
|
||||||
|
if use_vision:
|
||||||
|
print(f" Running vision analysis on {image_path}...", file=sys.stderr)
|
||||||
|
vision_glitches, quality = run_vision_analysis(image_path, model)
|
||||||
|
report.glitches.extend(vision_glitches)
|
||||||
|
report.score = quality
|
||||||
|
else:
|
||||||
|
# Score based on programmatic results
|
||||||
|
criticals = sum(1 for g in report.glitches if g.severity == Severity.CRITICAL)
|
||||||
|
majors = sum(1 for g in report.glitches if g.severity == Severity.MAJOR)
|
||||||
|
report.score = max(0, 100 - criticals * 40 - majors * 15)
|
||||||
|
|
||||||
|
# Determine status
|
||||||
|
criticals = sum(1 for g in report.glitches if g.severity == Severity.CRITICAL)
|
||||||
|
majors = sum(1 for g in report.glitches if g.severity == Severity.MAJOR)
|
||||||
|
|
||||||
|
if criticals > 0:
|
||||||
|
report.status = "FAIL"
|
||||||
|
elif majors > 0 or report.score < 70:
|
||||||
|
report.status = "WARN"
|
||||||
|
else:
|
||||||
|
report.status = "PASS"
|
||||||
|
|
||||||
|
report.summary = (
|
||||||
|
f"{report.status}: {len(report.glitches)} glitch(es) found "
|
||||||
|
f"({criticals} critical, {majors} major), score {report.score}/100"
|
||||||
|
)
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
# === HTML Report Generator ===
|
||||||
|
|
||||||
|
def generate_html_report(reports: list[GlitchReport], title: str = "Glitch Detection Report") -> str:
|
||||||
|
"""Generate a standalone HTML report with annotated details."""
|
||||||
|
total_glitches = sum(len(r.glitches) for r in reports)
|
||||||
|
total_criticals = sum(sum(1 for g in r.glitches if g.severity == Severity.CRITICAL) for r in reports)
|
||||||
|
avg_score = sum(r.score for r in reports) // max(1, len(reports))
|
||||||
|
|
||||||
|
if total_criticals > 0:
|
||||||
|
overall_verdict = "FAIL"
|
||||||
|
verdict_color = "#f44336"
|
||||||
|
elif any(r.status == "WARN" for r in reports):
|
||||||
|
overall_verdict = "WARN"
|
||||||
|
verdict_color = "#ff9800"
|
||||||
|
else:
|
||||||
|
overall_verdict = "PASS"
|
||||||
|
verdict_color = "#4caf50"
|
||||||
|
|
||||||
|
# Build HTML
|
||||||
|
parts = []
|
||||||
|
parts.append(f"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{html_module.escape(title)}</title>
|
||||||
|
<style>
|
||||||
|
*{{margin:0;padding:0;box-sizing:border-box}}
|
||||||
|
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,monospace;background:#0a0a14;color:#c0c0d0;font-size:13px;line-height:1.5}}
|
||||||
|
.container{{max-width:1000px;margin:0 auto;padding:20px}}
|
||||||
|
header{{text-align:center;padding:24px 0;border-bottom:1px solid #1a1a2e;margin-bottom:24px}}
|
||||||
|
header h1{{font-size:20px;font-weight:300;letter-spacing:3px;color:#4a9eff;margin-bottom:8px}}
|
||||||
|
.verdict{{display:inline-block;padding:6px 20px;border-radius:4px;font-size:14px;font-weight:700;letter-spacing:2px;color:#fff;background:{verdict_color}}}
|
||||||
|
.stats{{display:flex;gap:16px;justify-content:center;margin:16px 0;flex-wrap:wrap}}
|
||||||
|
.stat{{background:#0e0e1a;border:1px solid #1a1a2e;border-radius:4px;padding:8px 16px;text-align:center}}
|
||||||
|
.stat .val{{font-size:20px;font-weight:700;color:#4a9eff}}
|
||||||
|
.stat .lbl{{font-size:9px;color:#666;text-transform:uppercase;letter-spacing:1px}}
|
||||||
|
.score-gauge{{width:120px;height:120px;margin:0 auto 16px;position:relative}}
|
||||||
|
.score-gauge svg{{transform:rotate(-90deg)}}
|
||||||
|
.score-gauge .score-text{{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:28px;font-weight:700}}
|
||||||
|
.report-card{{background:#0e0e1a;border:1px solid #1a1a2e;border-radius:6px;margin-bottom:16px;overflow:hidden}}
|
||||||
|
.report-header{{padding:12px 16px;border-bottom:1px solid #1a1a2e;display:flex;justify-content:space-between;align-items:center}}
|
||||||
|
.report-header .source{{color:#4a9eff;font-weight:600;word-break:break-all}}
|
||||||
|
.report-header .status-badge{{padding:2px 10px;border-radius:3px;font-size:11px;font-weight:700;color:#fff}}
|
||||||
|
.status-pass{{background:#4caf50}}
|
||||||
|
.status-warn{{background:#ff9800}}
|
||||||
|
.status-fail{{background:#f44336}}
|
||||||
|
.screenshot{{text-align:center;padding:12px;background:#080810}}
|
||||||
|
.screenshot img{{max-width:100%;max-height:400px;border:1px solid #1a1a2e;border-radius:4px}}
|
||||||
|
.glitch-list{{padding:12px 16px}}
|
||||||
|
.glitch-item{{padding:8px 0;border-bottom:1px solid #111;display:flex;gap:12px;align-items:flex-start}}
|
||||||
|
.glitch-item:last-child{{border-bottom:none}}
|
||||||
|
.severity-dot{{width:8px;height:8px;border-radius:50%;margin-top:5px;flex-shrink:0}}
|
||||||
|
.sev-critical{{background:#f44336}}
|
||||||
|
.sev-major{{background:#ff9800}}
|
||||||
|
.sev-minor{{background:#2196f3}}
|
||||||
|
.sev-cosmetic{{background:#666}}
|
||||||
|
.glitch-detail{{flex:1}}
|
||||||
|
.glitch-type{{color:#ffd700;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:1px}}
|
||||||
|
.glitch-desc{{color:#aaa;font-size:12px;margin-top:2px}}
|
||||||
|
.glitch-meta{{color:#555;font-size:10px;margin-top:2px}}
|
||||||
|
.no-glitches{{color:#4caf50;text-align:center;padding:20px;font-style:italic}}
|
||||||
|
footer{{text-align:center;padding:16px;color:#444;font-size:10px;border-top:1px solid #1a1a2e;margin-top:24px}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>{html_module.escape(title)}</h1>
|
||||||
|
<div class="verdict">{overall_verdict}</div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><div class="val">{len(reports)}</div><div class="lbl">Screenshots</div></div>
|
||||||
|
<div class="stat"><div class="val">{total_glitches}</div><div class="lbl">Glitches</div></div>
|
||||||
|
<div class="stat"><div class="val">{total_criticals}</div><div class="lbl">Critical</div></div>
|
||||||
|
<div class="stat"><div class="val">{avg_score}</div><div class="lbl">Avg Score</div></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Score gauge
|
||||||
|
score_color = "#4caf50" if avg_score >= 80 else "#ff9800" if avg_score >= 60 else "#f44336"
|
||||||
|
circumference = 2 * 3.14159 * 50
|
||||||
|
dash_offset = circumference * (1 - avg_score / 100)
|
||||||
|
parts.append(f"""
|
||||||
|
<div class="score-gauge">
|
||||||
|
<svg width="120" height="120" viewBox="0 0 120 120">
|
||||||
|
<circle cx="60" cy="60" r="50" fill="none" stroke="#1a1a2e" stroke-width="8"/>
|
||||||
|
<circle cx="60" cy="60" r="50" fill="none" stroke="{score_color}" stroke-width="8"
|
||||||
|
stroke-dasharray="{circumference}" stroke-dashoffset="{dash_offset}" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<div class="score-text" style="color:{score_color}">{avg_score}</div>
|
||||||
|
</div>
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Per-screenshot reports
|
||||||
|
for i, report in enumerate(reports):
|
||||||
|
status_class = f"status-{report.status.lower()}"
|
||||||
|
source_name = Path(report.source).name if report.source else f"Screenshot {i+1}"
|
||||||
|
|
||||||
|
# Inline screenshot as base64
|
||||||
|
img_tag = ""
|
||||||
|
if report.source and Path(report.source).exists():
|
||||||
|
try:
|
||||||
|
b64 = base64.b64encode(Path(report.source).read_bytes()).decode()
|
||||||
|
ext = Path(report.source).suffix.lower()
|
||||||
|
mime = "image/png" if ext == ".png" else "image/jpeg" if ext in (".jpg", ".jpeg") else "image/webp"
|
||||||
|
img_tag = f'<img src="data:{mime};base64,{b64}" alt="Screenshot">'
|
||||||
|
except Exception:
|
||||||
|
img_tag = '<div style="color:#666;padding:40px">Screenshot unavailable</div>'
|
||||||
|
else:
|
||||||
|
img_tag = '<div style="color:#666;padding:40px">No screenshot</div>'
|
||||||
|
|
||||||
|
parts.append(f"""
|
||||||
|
<div class="report-card">
|
||||||
|
<div class="report-header">
|
||||||
|
<span class="source">{html_module.escape(source_name)} ({report.width}x{report.height})</span>
|
||||||
|
<span class="status-badge {status_class}">{report.status} — {report.score}/100</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot">{img_tag}</div>
|
||||||
|
""")
|
||||||
|
|
||||||
|
if report.glitches:
|
||||||
|
parts.append('<div class="glitch-list">')
|
||||||
|
for g in sorted(report.glitches, key=lambda x: {"critical": 0, "major": 1, "minor": 2, "cosmetic": 3}.get(x.severity.value if hasattr(x.severity, "value") else str(x.severity), 4)):
|
||||||
|
sev = g.severity.value if hasattr(g.severity, "value") else str(g.severity)
|
||||||
|
sev_class = f"sev-{sev}"
|
||||||
|
parts.append(f"""
|
||||||
|
<div class="glitch-item">
|
||||||
|
<div class="severity-dot {sev_class}"></div>
|
||||||
|
<div class="glitch-detail">
|
||||||
|
<div class="glitch-type">{html_module.escape(g.type)} — {sev.upper()}</div>
|
||||||
|
<div class="glitch-desc">{html_module.escape(g.description)}</div>
|
||||||
|
<div class="glitch-meta">Region: {html_module.escape(g.region)} | Confidence: {g.confidence:.0%} | Source: {html_module.escape(g.source)}</div>
|
||||||
|
</div>
|
||||||
|
</div>""")
|
||||||
|
parts.append('</div>')
|
||||||
|
else:
|
||||||
|
parts.append('<div class="no-glitches">No glitches detected</div>')
|
||||||
|
|
||||||
|
parts.append('</div><!-- /report-card -->')
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
parts.append(f"""
|
||||||
|
<footer>
|
||||||
|
Generated {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | matrix_glitch_detect.py | timmy-config#544
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>""")
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# === Output Formatting ===
|
||||||
|
|
||||||
|
def format_report(report: GlitchReport, fmt: str = "json") -> str:
|
||||||
|
if fmt == "json":
|
||||||
|
data = {
|
||||||
|
"source": report.source,
|
||||||
|
"timestamp": report.timestamp,
|
||||||
|
"status": report.status,
|
||||||
|
"score": report.score,
|
||||||
|
"glitches": [asdict(g) for g in report.glitches],
|
||||||
|
"summary": report.summary,
|
||||||
|
"model_used": report.model_used,
|
||||||
|
}
|
||||||
|
for g in data["glitches"]:
|
||||||
|
if hasattr(g["severity"], "value"):
|
||||||
|
g["severity"] = g["severity"].value
|
||||||
|
return json.dumps(data, indent=2)
|
||||||
|
|
||||||
|
elif fmt == "text":
|
||||||
|
lines = [
|
||||||
|
"=" * 50,
|
||||||
|
" GLITCH DETECTION REPORT",
|
||||||
|
"=" * 50,
|
||||||
|
f" Source: {report.source}",
|
||||||
|
f" Status: {report.status}",
|
||||||
|
f" Score: {report.score}/100",
|
||||||
|
f" Glitches: {len(report.glitches)}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
icons = {"critical": "🔴", "major": "🟡", "minor": "🔵", "cosmetic": "⚪"}
|
||||||
|
for g in report.glitches:
|
||||||
|
sev = g.severity.value if hasattr(g.severity, "value") else str(g.severity)
|
||||||
|
icon = icons.get(sev, "?")
|
||||||
|
lines.append(f" {icon} [{g.type}] {sev.upper()}: {g.description}")
|
||||||
|
lines.append(f" Region: {g.region} | Confidence: {g.confidence:.0%}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f" {report.summary}")
|
||||||
|
lines.append("=" * 50)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# === CLI ===
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="3D World Glitch Detection — visual artifact scanner for The Matrix"
|
||||||
|
)
|
||||||
|
group = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
group.add_argument("--image", help="Screenshot file to analyze")
|
||||||
|
group.add_argument("--url", help="URL to screenshot and analyze")
|
||||||
|
group.add_argument("--batch", help="Directory of screenshots to analyze")
|
||||||
|
|
||||||
|
parser.add_argument("--vision", action="store_true", help="Include vision model analysis")
|
||||||
|
parser.add_argument("--model", default=VISION_MODEL, help=f"Vision model (default: {VISION_MODEL})")
|
||||||
|
parser.add_argument("--html", help="Generate HTML report at this path")
|
||||||
|
parser.add_argument("--format", choices=["json", "text"], default="json", help="Output format")
|
||||||
|
parser.add_argument("--output", "-o", help="Output file (default: stdout)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
reports = []
|
||||||
|
|
||||||
|
if args.image:
|
||||||
|
print(f"Analyzing {args.image}...", file=sys.stderr)
|
||||||
|
report = detect_glitches(args.image, args.vision, args.model)
|
||||||
|
reports.append(report)
|
||||||
|
if not args.html:
|
||||||
|
print(format_report(report, args.format))
|
||||||
|
|
||||||
|
elif args.url:
|
||||||
|
import tempfile
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||||
|
screenshot_path = tmp.name
|
||||||
|
print(f"Capturing screenshot of {args.url}...", file=sys.stderr)
|
||||||
|
if capture_screenshot(args.url, screenshot_path):
|
||||||
|
report = detect_glitches(screenshot_path, args.vision, args.model)
|
||||||
|
report.source = args.url
|
||||||
|
reports.append(report)
|
||||||
|
if not args.html:
|
||||||
|
print(format_report(report, args.format))
|
||||||
|
else:
|
||||||
|
print(f"Failed to capture screenshot", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.batch:
|
||||||
|
batch_dir = Path(args.batch)
|
||||||
|
images = sorted(batch_dir.glob("*.png")) + sorted(batch_dir.glob("*.jpg"))
|
||||||
|
for img in images:
|
||||||
|
print(f"Analyzing {img.name}...", file=sys.stderr)
|
||||||
|
report = detect_glitches(str(img), args.vision, args.model)
|
||||||
|
reports.append(report)
|
||||||
|
|
||||||
|
# HTML report
|
||||||
|
if args.html:
|
||||||
|
html = generate_html_report(reports, title="The Matrix — Glitch Detection Report")
|
||||||
|
Path(args.html).write_text(html)
|
||||||
|
print(f"HTML report written to {args.html}", file=sys.stderr)
|
||||||
|
elif args.batch and not args.html:
|
||||||
|
# Print JSON array for batch
|
||||||
|
print(json.dumps([json.loads(format_report(r, "json")) for r in reports], indent=2))
|
||||||
|
|
||||||
|
# Exit code
|
||||||
|
if any(r.status == "FAIL" for r in reports):
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import subprocess
|
||||||
|
main()
|
||||||
|
|||||||
582
scripts/nexus_smoke_test.py
Normal file
582
scripts/nexus_smoke_test.py
Normal file
@@ -0,0 +1,582 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
nexus_smoke_test.py — Visual Smoke Test for The Nexus.
|
||||||
|
|
||||||
|
Takes screenshots of The Nexus landing page, verifies layout consistency
|
||||||
|
using both programmatic checks (DOM structure, element presence) and
|
||||||
|
optional vision model analysis (visual regression detection).
|
||||||
|
|
||||||
|
The Nexus is the Three.js 3D world frontend at nexus.alexanderwhitestone.com.
|
||||||
|
This test ensures the landing page renders correctly on every push.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Full smoke test (programmatic + optional vision)
|
||||||
|
python scripts/nexus_smoke_test.py
|
||||||
|
|
||||||
|
# Programmatic only (no vision model needed, CI-safe)
|
||||||
|
python scripts/nexus_smoke_test.py --programmatic
|
||||||
|
|
||||||
|
# With vision model regression check
|
||||||
|
python scripts/nexus_smoke_test.py --vision
|
||||||
|
|
||||||
|
# Against a specific URL
|
||||||
|
python scripts/nexus_smoke_test.py --url https://nexus.alexanderwhitestone.com
|
||||||
|
|
||||||
|
# With baseline comparison
|
||||||
|
python scripts/nexus_smoke_test.py --baseline screenshots/nexus-baseline.png
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
1. Page loads without errors (HTTP 200, no console errors)
|
||||||
|
2. Key elements present (Three.js canvas, title, navigation)
|
||||||
|
3. No 404/error messages visible
|
||||||
|
4. JavaScript bundle loaded (window.__nexus or scene exists)
|
||||||
|
5. Screenshot captured successfully
|
||||||
|
6. Vision model layout verification (optional)
|
||||||
|
7. Baseline comparison for visual regression (optional)
|
||||||
|
|
||||||
|
Refs: timmy-config#490
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# === Configuration ===
|
||||||
|
|
||||||
|
DEFAULT_URL = os.environ.get("NEXUS_URL", "https://nexus.alexanderwhitestone.com")
|
||||||
|
OLLAMA_BASE = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||||
|
VISION_MODEL = os.environ.get("VISUAL_REVIEW_MODEL", "gemma3:12b")
|
||||||
|
|
||||||
|
|
||||||
|
class Severity(str, Enum):
|
||||||
|
PASS = "pass"
|
||||||
|
WARN = "warn"
|
||||||
|
FAIL = "fail"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SmokeCheck:
|
||||||
|
"""A single smoke test check."""
|
||||||
|
name: str
|
||||||
|
status: Severity = Severity.PASS
|
||||||
|
message: str = ""
|
||||||
|
details: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SmokeResult:
|
||||||
|
"""Complete smoke test result."""
|
||||||
|
url: str = ""
|
||||||
|
status: Severity = Severity.PASS
|
||||||
|
checks: list[SmokeCheck] = field(default_factory=list)
|
||||||
|
screenshot_path: str = ""
|
||||||
|
summary: str = ""
|
||||||
|
duration_ms: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# === HTTP/Network Checks ===
|
||||||
|
|
||||||
|
def check_page_loads(url: str) -> SmokeCheck:
|
||||||
|
"""Verify the page returns HTTP 200."""
|
||||||
|
check = SmokeCheck(name="Page Loads")
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "NexusSmokeTest/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
check.status = Severity.PASS
|
||||||
|
check.message = f"HTTP {resp.status}"
|
||||||
|
else:
|
||||||
|
check.status = Severity.WARN
|
||||||
|
check.message = f"HTTP {resp.status} (expected 200)"
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
check.status = Severity.FAIL
|
||||||
|
check.message = f"HTTP {e.code}: {e.reason}"
|
||||||
|
except Exception as e:
|
||||||
|
check.status = Severity.FAIL
|
||||||
|
check.message = f"Connection failed: {e}"
|
||||||
|
return check
|
||||||
|
|
||||||
|
|
||||||
|
def check_html_content(url: str) -> tuple[SmokeCheck, str]:
|
||||||
|
"""Fetch HTML and check for key content."""
|
||||||
|
check = SmokeCheck(name="HTML Content")
|
||||||
|
html = ""
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "NexusSmokeTest/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
html = resp.read().decode("utf-8", errors="replace")
|
||||||
|
except Exception as e:
|
||||||
|
check.status = Severity.FAIL
|
||||||
|
check.message = f"Failed to fetch: {e}"
|
||||||
|
return check, html
|
||||||
|
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
# Check for Three.js
|
||||||
|
if "three" not in html.lower() and "THREE" not in html and "threejs" not in html.lower():
|
||||||
|
issues.append("No Three.js reference found")
|
||||||
|
|
||||||
|
# Check for canvas element
|
||||||
|
if "<canvas" not in html.lower():
|
||||||
|
issues.append("No <canvas> element found")
|
||||||
|
|
||||||
|
# Check title
|
||||||
|
title_match = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
|
||||||
|
if title_match:
|
||||||
|
title = title_match.group(1).strip()
|
||||||
|
check.details = f"Title: {title}"
|
||||||
|
if "nexus" not in title.lower() and "tower" not in title.lower():
|
||||||
|
issues.append(f"Title doesn't reference Nexus: '{title}'")
|
||||||
|
else:
|
||||||
|
issues.append("No <title> element")
|
||||||
|
|
||||||
|
# Check for error messages
|
||||||
|
error_patterns = ["404", "not found", "error", "500 internal", "connection refused"]
|
||||||
|
html_lower = html.lower()
|
||||||
|
for pattern in error_patterns:
|
||||||
|
if pattern in html_lower[:500] or pattern in html_lower[-500:]:
|
||||||
|
issues.append(f"Possible error message in HTML: '{pattern}'")
|
||||||
|
|
||||||
|
# Check for script tags (app loaded)
|
||||||
|
script_count = html.lower().count("<script")
|
||||||
|
if script_count == 0:
|
||||||
|
issues.append("No <script> tags found")
|
||||||
|
else:
|
||||||
|
check.details += f" | Scripts: {script_count}"
|
||||||
|
|
||||||
|
if issues:
|
||||||
|
check.status = Severity.FAIL if len(issues) > 2 else Severity.WARN
|
||||||
|
check.message = "; ".join(issues)
|
||||||
|
else:
|
||||||
|
check.status = Severity.PASS
|
||||||
|
check.message = "HTML structure looks correct"
|
||||||
|
|
||||||
|
return check, html
|
||||||
|
|
||||||
|
|
||||||
|
# === Screenshot Capture ===
|
||||||
|
|
||||||
|
def take_screenshot(url: str, output_path: str, width: int = 1280, height: int = 720) -> SmokeCheck:
|
||||||
|
"""Take a screenshot of the page."""
|
||||||
|
check = SmokeCheck(name="Screenshot Capture")
|
||||||
|
|
||||||
|
# Try Playwright
|
||||||
|
try:
|
||||||
|
script = f"""
|
||||||
|
import sys
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except ImportError:
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={{"width": {width}, "height": {height}}})
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||||
|
page.on("console", lambda m: errors.append(f"console.{{m.type}}: {{m.text}}") if m.type == "error" else None)
|
||||||
|
|
||||||
|
page.goto("{url}", wait_until="networkidle", timeout=30000)
|
||||||
|
page.wait_for_timeout(3000) # Wait for Three.js to render
|
||||||
|
page.screenshot(path="{output_path}", full_page=False)
|
||||||
|
|
||||||
|
# Check for Three.js scene
|
||||||
|
has_canvas = page.evaluate("() => !!document.querySelector('canvas')")
|
||||||
|
has_three = page.evaluate("() => typeof THREE !== 'undefined' || !!document.querySelector('canvas')")
|
||||||
|
title = page.title()
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
import json
|
||||||
|
print(json.dumps({{"has_canvas": has_canvas, "has_three": has_three, "title": title, "errors": errors[:5]}}))
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["python3", "-c", script],
|
||||||
|
capture_output=True, text=True, timeout=60
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Parse Playwright output
|
||||||
|
try:
|
||||||
|
# Find JSON in output
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
if line.startswith("{"):
|
||||||
|
info = json.loads(line)
|
||||||
|
extras = []
|
||||||
|
if info.get("has_canvas"):
|
||||||
|
extras.append("canvas present")
|
||||||
|
if info.get("errors"):
|
||||||
|
extras.append(f"{len(info['errors'])} JS errors")
|
||||||
|
check.details = "; ".join(extras) if extras else "Playwright capture"
|
||||||
|
if info.get("errors"):
|
||||||
|
check.status = Severity.WARN
|
||||||
|
check.message = f"JS errors detected: {info['errors'][0][:100]}"
|
||||||
|
else:
|
||||||
|
check.message = "Screenshot captured via Playwright"
|
||||||
|
break
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if Path(output_path).exists() and Path(output_path).stat().st_size > 1000:
|
||||||
|
return check
|
||||||
|
elif result.returncode == 2:
|
||||||
|
check.details = "Playwright not installed"
|
||||||
|
else:
|
||||||
|
check.details = f"Playwright failed: {result.stderr[:200]}"
|
||||||
|
except Exception as e:
|
||||||
|
check.details = f"Playwright error: {e}"
|
||||||
|
|
||||||
|
# Try wkhtmltoimage
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["wkhtmltoimage", "--width", str(width), "--quality", "90", url, output_path],
|
||||||
|
capture_output=True, text=True, timeout=30
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and Path(output_path).exists() and Path(output_path).stat().st_size > 1000:
|
||||||
|
check.status = Severity.PASS
|
||||||
|
check.message = "Screenshot captured via wkhtmltoimage"
|
||||||
|
check.details = ""
|
||||||
|
return check
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try curl + browserless (if available)
|
||||||
|
browserless = os.environ.get("BROWSERLESS_URL")
|
||||||
|
if browserless:
|
||||||
|
try:
|
||||||
|
payload = json.dumps({
|
||||||
|
"url": url,
|
||||||
|
"options": {"type": "png", "fullPage": False}
|
||||||
|
})
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{browserless}/screenshot",
|
||||||
|
data=payload.encode(),
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
img_data = resp.read()
|
||||||
|
Path(output_path).write_bytes(img_data)
|
||||||
|
if Path(output_path).stat().st_size > 1000:
|
||||||
|
check.status = Severity.PASS
|
||||||
|
check.message = "Screenshot captured via browserless"
|
||||||
|
check.details = ""
|
||||||
|
return check
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
check.status = Severity.WARN
|
||||||
|
check.message = "No screenshot backend available"
|
||||||
|
check.details = "Install Playwright: pip install playwright && playwright install chromium"
|
||||||
|
return check
|
||||||
|
|
||||||
|
|
||||||
|
# === Vision Analysis ===
|
||||||
|
|
||||||
|
VISION_PROMPT = """You are a web QA engineer. Analyze this screenshot of The Nexus (a Three.js 3D world).
|
||||||
|
|
||||||
|
Check for:
|
||||||
|
1. LAYOUT: Is the page layout correct? Is content centered, not broken or overlapping?
|
||||||
|
2. THREE.JS RENDER: Is there a visible 3D canvas/scene? Any black/blank areas where rendering failed?
|
||||||
|
3. NAVIGATION: Are navigation elements (buttons, links, menu) visible and properly placed?
|
||||||
|
4. TEXT: Is text readable? Any missing text, garbled characters, or font issues?
|
||||||
|
5. ERRORS: Any visible error messages, 404 pages, or broken images?
|
||||||
|
6. TOWER: Is the Tower or entry portal visible in the scene?
|
||||||
|
|
||||||
|
Respond as JSON:
|
||||||
|
{
|
||||||
|
"status": "PASS|FAIL|WARN",
|
||||||
|
"checks": [
|
||||||
|
{"name": "Layout", "status": "pass|fail|warn", "message": "..."},
|
||||||
|
{"name": "Three.js Render", "status": "pass|fail|warn", "message": "..."},
|
||||||
|
{"name": "Navigation", "status": "pass|fail|warn", "message": "..."},
|
||||||
|
{"name": "Text Readability", "status": "pass|fail|warn", "message": "..."},
|
||||||
|
{"name": "Error Messages", "status": "pass|fail|warn", "message": "..."}
|
||||||
|
],
|
||||||
|
"summary": "brief overall assessment"
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
def run_vision_check(screenshot_path: str, model: str = VISION_MODEL) -> list[SmokeCheck]:
|
||||||
|
"""Run vision model analysis on screenshot."""
|
||||||
|
checks = []
|
||||||
|
try:
|
||||||
|
b64 = base64.b64encode(Path(screenshot_path).read_bytes()).decode()
|
||||||
|
payload = json.dumps({
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": [
|
||||||
|
{"type": "text", "text": VISION_PROMPT},
|
||||||
|
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
||||||
|
]}],
|
||||||
|
"stream": False,
|
||||||
|
"options": {"temperature": 0.1}
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{OLLAMA_BASE}/api/chat",
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||||
|
result = json.loads(resp.read())
|
||||||
|
content = result.get("message", {}).get("content", "")
|
||||||
|
|
||||||
|
parsed = _parse_json_response(content)
|
||||||
|
for c in parsed.get("checks", []):
|
||||||
|
status = Severity(c.get("status", "warn"))
|
||||||
|
checks.append(SmokeCheck(
|
||||||
|
name=f"Vision: {c.get('name', 'Unknown')}",
|
||||||
|
status=status,
|
||||||
|
message=c.get("message", "")
|
||||||
|
))
|
||||||
|
|
||||||
|
if not checks:
|
||||||
|
checks.append(SmokeCheck(
|
||||||
|
name="Vision Analysis",
|
||||||
|
status=Severity.WARN,
|
||||||
|
message="Vision model returned no structured checks"
|
||||||
|
))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
checks.append(SmokeCheck(
|
||||||
|
name="Vision Analysis",
|
||||||
|
status=Severity.WARN,
|
||||||
|
message=f"Vision check failed: {e}"
|
||||||
|
))
|
||||||
|
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
# === Baseline Comparison ===
|
||||||
|
|
||||||
|
def compare_baseline(current_path: str, baseline_path: str) -> SmokeCheck:
|
||||||
|
"""Compare screenshot against baseline for visual regression."""
|
||||||
|
check = SmokeCheck(name="Baseline Comparison")
|
||||||
|
|
||||||
|
if not Path(baseline_path).exists():
|
||||||
|
check.status = Severity.WARN
|
||||||
|
check.message = f"Baseline not found: {baseline_path}"
|
||||||
|
return check
|
||||||
|
|
||||||
|
if not Path(current_path).exists():
|
||||||
|
check.status = Severity.FAIL
|
||||||
|
check.message = "No current screenshot to compare"
|
||||||
|
return check
|
||||||
|
|
||||||
|
# Simple file size comparison (rough regression indicator)
|
||||||
|
baseline_size = Path(baseline_path).stat().st_size
|
||||||
|
current_size = Path(current_path).stat().st_size
|
||||||
|
|
||||||
|
if baseline_size == 0:
|
||||||
|
check.status = Severity.WARN
|
||||||
|
check.message = "Baseline is empty"
|
||||||
|
return check
|
||||||
|
|
||||||
|
diff_pct = abs(current_size - baseline_size) / baseline_size * 100
|
||||||
|
|
||||||
|
if diff_pct > 50:
|
||||||
|
check.status = Severity.FAIL
|
||||||
|
check.message = f"Major visual change: {diff_pct:.0f}% file size difference"
|
||||||
|
elif diff_pct > 20:
|
||||||
|
check.status = Severity.WARN
|
||||||
|
check.message = f"Significant visual change: {diff_pct:.0f}% file size difference"
|
||||||
|
else:
|
||||||
|
check.status = Severity.PASS
|
||||||
|
check.message = f"Visual consistency: {diff_pct:.1f}% difference"
|
||||||
|
|
||||||
|
check.details = f"Baseline: {baseline_size}B, Current: {current_size}B"
|
||||||
|
|
||||||
|
# Pixel-level diff using ImageMagick (if available)
|
||||||
|
try:
|
||||||
|
diff_output = current_path.replace(".png", "-diff.png")
|
||||||
|
result = subprocess.run(
|
||||||
|
["compare", "-metric", "AE", current_path, baseline_path, diff_output],
|
||||||
|
capture_output=True, text=True, timeout=15
|
||||||
|
)
|
||||||
|
if result.returncode < 2:
|
||||||
|
pixels_diff = int(result.stderr) if result.stderr.strip().isdigit() else 0
|
||||||
|
check.details += f" | Pixel diff: {pixels_diff}"
|
||||||
|
if pixels_diff > 10000:
|
||||||
|
check.status = Severity.FAIL
|
||||||
|
check.message = f"Major visual regression: {pixels_diff} pixels changed"
|
||||||
|
elif pixels_diff > 1000:
|
||||||
|
check.status = Severity.WARN
|
||||||
|
check.message = f"Visual change detected: {pixels_diff} pixels changed"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return check
|
||||||
|
|
||||||
|
|
||||||
|
# === Helpers ===
|
||||||
|
|
||||||
|
def _parse_json_response(text: str) -> dict:
|
||||||
|
cleaned = text.strip()
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
lines = cleaned.split("\n")[1:]
|
||||||
|
if lines and lines[-1].strip() == "```":
|
||||||
|
lines = lines[:-1]
|
||||||
|
cleaned = "\n".join(lines)
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
start = cleaned.find("{")
|
||||||
|
end = cleaned.rfind("}")
|
||||||
|
if start >= 0 and end > start:
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned[start:end + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# === Main Smoke Test ===
|
||||||
|
|
||||||
|
def run_smoke_test(url: str, vision: bool = False, baseline: Optional[str] = None,
|
||||||
|
model: str = VISION_MODEL) -> SmokeResult:
|
||||||
|
"""Run the full visual smoke test suite."""
|
||||||
|
import time
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
result = SmokeResult(url=url)
|
||||||
|
screenshot_path = ""
|
||||||
|
|
||||||
|
# 1. Page loads
|
||||||
|
print(f" [1/5] Checking page loads...", file=sys.stderr)
|
||||||
|
result.checks.append(check_page_loads(url))
|
||||||
|
|
||||||
|
# 2. HTML content
|
||||||
|
print(f" [2/5] Checking HTML content...", file=sys.stderr)
|
||||||
|
html_check, html = check_html_content(url)
|
||||||
|
result.checks.append(html_check)
|
||||||
|
|
||||||
|
# 3. Screenshot
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||||
|
screenshot_path = tmp.name
|
||||||
|
print(f" [3/5] Taking screenshot...", file=sys.stderr)
|
||||||
|
screenshot_check = take_screenshot(url, screenshot_path)
|
||||||
|
result.checks.append(screenshot_check)
|
||||||
|
result.screenshot_path = screenshot_path
|
||||||
|
|
||||||
|
# 4. Vision analysis (optional)
|
||||||
|
if vision and Path(screenshot_path).exists():
|
||||||
|
print(f" [4/5] Running vision analysis...", file=sys.stderr)
|
||||||
|
result.checks.extend(run_vision_check(screenshot_path, model))
|
||||||
|
else:
|
||||||
|
print(f" [4/5] Vision analysis skipped", file=sys.stderr)
|
||||||
|
|
||||||
|
# 5. Baseline comparison (optional)
|
||||||
|
if baseline:
|
||||||
|
print(f" [5/5] Comparing against baseline...", file=sys.stderr)
|
||||||
|
result.checks.append(compare_baseline(screenshot_path, baseline))
|
||||||
|
else:
|
||||||
|
print(f" [5/5] Baseline comparison skipped", file=sys.stderr)
|
||||||
|
|
||||||
|
# Determine overall status
|
||||||
|
fails = sum(1 for c in result.checks if c.status == Severity.FAIL)
|
||||||
|
warns = sum(1 for c in result.checks if c.status == Severity.WARN)
|
||||||
|
|
||||||
|
if fails > 0:
|
||||||
|
result.status = Severity.FAIL
|
||||||
|
elif warns > 0:
|
||||||
|
result.status = Severity.WARN
|
||||||
|
else:
|
||||||
|
result.status = Severity.PASS
|
||||||
|
|
||||||
|
result.summary = (
|
||||||
|
f"{result.status.value.upper()}: {len(result.checks)} checks, "
|
||||||
|
f"{fails} failures, {warns} warnings"
|
||||||
|
)
|
||||||
|
result.duration_ms = int((time.time() - start) * 1000)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# === Output ===
|
||||||
|
|
||||||
|
def format_result(result: SmokeResult, fmt: str = "json") -> str:
|
||||||
|
if fmt == "json":
|
||||||
|
data = {
|
||||||
|
"url": result.url,
|
||||||
|
"status": result.status.value,
|
||||||
|
"summary": result.summary,
|
||||||
|
"duration_ms": result.duration_ms,
|
||||||
|
"screenshot": result.screenshot_path,
|
||||||
|
"checks": [asdict(c) for c in result.checks],
|
||||||
|
}
|
||||||
|
for c in data["checks"]:
|
||||||
|
if hasattr(c["status"], "value"):
|
||||||
|
c["status"] = c["status"].value
|
||||||
|
return json.dumps(data, indent=2)
|
||||||
|
|
||||||
|
elif fmt == "text":
|
||||||
|
lines = [
|
||||||
|
"=" * 50,
|
||||||
|
" NEXUS VISUAL SMOKE TEST",
|
||||||
|
"=" * 50,
|
||||||
|
f" URL: {result.url}",
|
||||||
|
f" Status: {result.status.value.upper()}",
|
||||||
|
f" Duration: {result.duration_ms}ms",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
icons = {"pass": "✅", "warn": "⚠️", "fail": "❌"}
|
||||||
|
for c in result.checks:
|
||||||
|
icon = icons.get(c.status.value if hasattr(c.status, "value") else str(c.status), "?")
|
||||||
|
lines.append(f" {icon} {c.name}: {c.message}")
|
||||||
|
if c.details:
|
||||||
|
lines.append(f" {c.details}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f" {result.summary}")
|
||||||
|
lines.append("=" * 50)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# === CLI ===
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Visual Smoke Test for The Nexus — layout + regression verification"
|
||||||
|
)
|
||||||
|
parser.add_argument("--url", default=DEFAULT_URL, help=f"Nexus URL (default: {DEFAULT_URL})")
|
||||||
|
parser.add_argument("--vision", action="store_true", help="Include vision model analysis")
|
||||||
|
parser.add_argument("--baseline", help="Baseline screenshot for regression comparison")
|
||||||
|
parser.add_argument("--model", default=VISION_MODEL, help=f"Vision model (default: {VISION_MODEL})")
|
||||||
|
parser.add_argument("--format", choices=["json", "text"], default="json")
|
||||||
|
parser.add_argument("--output", "-o", help="Output file (default: stdout)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print(f"Running smoke test on {args.url}...", file=sys.stderr)
|
||||||
|
result = run_smoke_test(args.url, vision=args.vision, baseline=args.baseline, model=args.model)
|
||||||
|
output = format_result(result, args.format)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
Path(args.output).write_text(output)
|
||||||
|
print(f"Results written to {args.output}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
if result.status == Severity.FAIL:
|
||||||
|
sys.exit(1)
|
||||||
|
elif result.status == Severity.WARN:
|
||||||
|
sys.exit(0) # Warnings don't fail CI
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
50
scripts/nightly-pipeline-scheduler.md
Normal file
50
scripts/nightly-pipeline-scheduler.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Nightly Pipeline Scheduler
|
||||||
|
|
||||||
|
Auto-starts batch pipelines when inference is available.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
1. Checks inference provider health (OpenRouter, Ollama, RunPod)
|
||||||
|
2. Checks if it's off-peak hours (configurable, default: after 6PM)
|
||||||
|
3. Checks interactive session load (don't fight with live users)
|
||||||
|
4. Checks daily token budget (configurable limit)
|
||||||
|
5. Starts the highest-priority incomplete pipeline
|
||||||
|
|
||||||
|
## Pipeline Priority Order
|
||||||
|
|
||||||
|
| Priority | Pipeline | Deps | Max Tokens |
|
||||||
|
|----------|----------|------|------------|
|
||||||
|
| 1 | playground-factory | none | 100,000 |
|
||||||
|
| 2 | training-factory | none | 150,000 |
|
||||||
|
| 3 | knowledge-mine | training-factory running | 80,000 |
|
||||||
|
| 4 | adversary | knowledge-mine running | 50,000 |
|
||||||
|
| 5 | codebase-genome | none | 120,000 |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Normal run (used by cron)
|
||||||
|
./scripts/nightly-pipeline-scheduler.sh
|
||||||
|
|
||||||
|
# Dry run (show what would start)
|
||||||
|
./scripts/nightly-pipeline-scheduler.sh --dry-run
|
||||||
|
|
||||||
|
# Status report
|
||||||
|
./scripts/nightly-pipeline-scheduler.sh --status
|
||||||
|
|
||||||
|
# Force start during peak hours
|
||||||
|
./scripts/nightly-pipeline-scheduler.sh --force
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Set via environment variables:
|
||||||
|
- `PIPELINE_TOKEN_LIMIT`: Daily token budget (default: 500,000)
|
||||||
|
- `PIPELINE_PEAK_START`: Peak hours start (default: 9)
|
||||||
|
- `PIPELINE_PEAK_END`: Peak hours end (default: 18)
|
||||||
|
- `HERMES_HOME`: Hermes home directory (default: ~/.hermes)
|
||||||
|
|
||||||
|
## Cron
|
||||||
|
|
||||||
|
Runs every 30 minutes. Off-peak only (unless --force).
|
||||||
|
See `cron/pipeline-scheduler.yml`.
|
||||||
383
scripts/nightly-pipeline-scheduler.sh
Normal file
383
scripts/nightly-pipeline-scheduler.sh
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# nightly-pipeline-scheduler.sh — Auto-start batch pipelines when inference is available.
|
||||||
|
#
|
||||||
|
# Checks provider health, pipeline progress, token budget, and interactive load.
|
||||||
|
# Starts the highest-priority incomplete pipeline that can run.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/nightly-pipeline-scheduler.sh # Normal run
|
||||||
|
# ./scripts/nightly-pipeline-scheduler.sh --dry-run # Show what would start
|
||||||
|
# ./scripts/nightly-pipeline-scheduler.sh --status # Pipeline status report
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
|
||||||
|
BUDGET_FILE="${HERMES_HOME}/pipeline_budget.json"
|
||||||
|
STATE_FILE="${HERMES_HOME}/pipeline_state.json"
|
||||||
|
LOG_FILE="${HERMES_HOME}/logs/pipeline-scheduler.log"
|
||||||
|
TOKEN_DAILY_LIMIT="${PIPELINE_TOKEN_LIMIT:-500000}"
|
||||||
|
PEAK_HOURS_START="${PIPELINE_PEAK_START:-9}"
|
||||||
|
PEAK_HOURS_END="${PIPELINE_PEAK_END:-18}"
|
||||||
|
|
||||||
|
# Pipeline definitions (priority order)
|
||||||
|
# Each pipeline: name, script, max_tokens, dependencies
|
||||||
|
PIPELINES=(
|
||||||
|
"playground-factory|scripts/pipeline_playground_factory.sh|100000|none"
|
||||||
|
"training-factory|scripts/pipeline_training_factory.sh|150000|none"
|
||||||
|
"knowledge-mine|scripts/pipeline_knowledge_mine.sh|80000|training-factory"
|
||||||
|
"adversary|scripts/pipeline_adversary.sh|50000|knowledge-mine"
|
||||||
|
"codebase-genome|scripts/pipeline_codebase_genome.sh|120000|none"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Colors ---
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# --- Helpers ---
|
||||||
|
now_hour() { date +%-H; }
|
||||||
|
is_peak_hours() {
|
||||||
|
local h=$(now_hour)
|
||||||
|
[[ $h -ge $PEAK_HOURS_START && $h -lt $PEAK_HOURS_END ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_dirs() {
|
||||||
|
mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$BUDGET_FILE")" "$(dirname "$STATE_FILE")"
|
||||||
|
}
|
||||||
|
|
||||||
|
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
|
||||||
|
|
||||||
|
get_budget_used_today() {
|
||||||
|
if [[ -f "$BUDGET_FILE" ]]; then
|
||||||
|
local today=$(date +%Y-%m-%d)
|
||||||
|
python3 -c "
|
||||||
|
import json, sys
|
||||||
|
with open('$BUDGET_FILE') as f:
|
||||||
|
d = json.load(f)
|
||||||
|
print(d.get('daily', {}).get('$today', {}).get('tokens_used', 0))
|
||||||
|
" 2>/dev/null || echo 0
|
||||||
|
else
|
||||||
|
echo 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_budget_remaining() {
|
||||||
|
local used=$(get_budget_used_today)
|
||||||
|
echo $((TOKEN_DAILY_LIMIT - used))
|
||||||
|
}
|
||||||
|
|
||||||
|
update_budget() {
|
||||||
|
local pipeline="$1"
|
||||||
|
local tokens="$2"
|
||||||
|
local today=$(date +%Y-%m-%d)
|
||||||
|
python3 -c "
|
||||||
|
import json, os
|
||||||
|
path = '$BUDGET_FILE'
|
||||||
|
d = {}
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path) as f:
|
||||||
|
d = json.load(f)
|
||||||
|
daily = d.setdefault('daily', {})
|
||||||
|
day = daily.setdefault('$today', {'tokens_used': 0, 'pipelines': {}})
|
||||||
|
day['tokens_used'] = day.get('tokens_used', 0) + $tokens
|
||||||
|
day['pipelines']['$pipeline'] = day['pipelines'].get('$pipeline', 0) + $tokens
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
json.dump(d, f, indent=2)
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_pipeline_state() {
|
||||||
|
if [[ -f "$STATE_FILE" ]]; then
|
||||||
|
cat "$STATE_FILE"
|
||||||
|
else
|
||||||
|
echo "{}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
set_pipeline_state() {
|
||||||
|
local pipeline="$1"
|
||||||
|
local state="$2" # running, complete, failed, skipped
|
||||||
|
python3 -c "
|
||||||
|
import json, os
|
||||||
|
path = '$STATE_FILE'
|
||||||
|
d = {}
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path) as f:
|
||||||
|
d = json.load(f)
|
||||||
|
d['$pipeline'] = {'state': '$state', 'updated': '$(date -Iseconds)'}
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
json.dump(d, f, indent=2)
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_pipeline_complete() {
|
||||||
|
local pipeline="$1"
|
||||||
|
python3 -c "
|
||||||
|
import json, os
|
||||||
|
path = '$STATE_FILE'
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print('false')
|
||||||
|
else:
|
||||||
|
with open(path) as f:
|
||||||
|
d = json.load(f)
|
||||||
|
state = d.get('$pipeline', {}).get('state', 'not_started')
|
||||||
|
print('true' if state == 'complete' else 'false')
|
||||||
|
" 2>/dev/null || echo false
|
||||||
|
}
|
||||||
|
|
||||||
|
is_pipeline_running() {
|
||||||
|
local pipeline="$1"
|
||||||
|
python3 -c "
|
||||||
|
import json, os
|
||||||
|
path = '$STATE_FILE'
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print('false')
|
||||||
|
else:
|
||||||
|
with open(path) as f:
|
||||||
|
d = json.load(f)
|
||||||
|
state = d.get('$pipeline', {}).get('state', 'not_started')
|
||||||
|
print('true' if state == 'running' else 'false')
|
||||||
|
" 2>/dev/null || echo false
|
||||||
|
}
|
||||||
|
|
||||||
|
check_dependency() {
|
||||||
|
local dep="$1"
|
||||||
|
if [[ "$dep" == "none" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# For knowledge-mine: training-factory must be running or complete
|
||||||
|
if [[ "$dep" == "training-factory" ]]; then
|
||||||
|
local state=$(python3 -c "
|
||||||
|
import json, os
|
||||||
|
path = '$STATE_FILE'
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print('not_started')
|
||||||
|
else:
|
||||||
|
with open(path) as f:
|
||||||
|
d = json.load(f)
|
||||||
|
print(d.get('training-factory', {}).get('state', 'not_started'))
|
||||||
|
" 2>/dev/null || echo "not_started")
|
||||||
|
[[ "$state" == "running" || "$state" == "complete" ]]
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
# For adversary: knowledge-mine must be at least 50% done
|
||||||
|
# Simplified: check if it's running (we'd need progress tracking for 50%)
|
||||||
|
if [[ "$dep" == "knowledge-mine" ]]; then
|
||||||
|
local state=$(python3 -c "
|
||||||
|
import json, os
|
||||||
|
path = '$STATE_FILE'
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print('not_started')
|
||||||
|
else:
|
||||||
|
with open(path) as f:
|
||||||
|
d = json.load(f)
|
||||||
|
print(d.get('knowledge-mine', {}).get('state', 'not_started'))
|
||||||
|
" 2>/dev/null || echo "not_started")
|
||||||
|
[[ "$state" == "running" || "$state" == "complete" ]]
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
check_inference_available() {
|
||||||
|
# Check if any inference provider is responding
|
||||||
|
# 1. Check OpenRouter
|
||||||
|
local or_ok=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
--connect-timeout 5 "https://openrouter.ai/api/v1/models" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
# 2. Check local Ollama
|
||||||
|
local ollama_ok=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
--connect-timeout 5 "http://localhost:11434/api/tags" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
# 3. Check RunPod (if configured)
|
||||||
|
local runpod_ok="000"
|
||||||
|
if [[ -n "${RUNPOD_ENDPOINT:-}" ]]; then
|
||||||
|
runpod_ok=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
--connect-timeout 5 "$RUNPOD_ENDPOINT/health" 2>/dev/null || echo "000")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$or_ok" == "200" || "$ollama_ok" == "200" || "$runpod_ok" == "200" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_interactive_load() {
|
||||||
|
# Check if there are active interactive sessions (don't fight with live users)
|
||||||
|
# Look for tmux panes with active hermes sessions
|
||||||
|
local active=$(tmux list-panes -a -F '#{pane_pid} #{pane_current_command}' 2>/dev/null \
|
||||||
|
| grep -c "hermes\|python3" || echo 0)
|
||||||
|
|
||||||
|
# If more than 3 interactive sessions, skip pipeline start
|
||||||
|
if [[ $active -gt 3 ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
start_pipeline() {
|
||||||
|
local name="$1"
|
||||||
|
local script="$2"
|
||||||
|
local max_tokens="$3"
|
||||||
|
local budget_remaining="$4"
|
||||||
|
local mode="${5:-run}"
|
||||||
|
|
||||||
|
if [[ "$budget_remaining" -lt "$max_tokens" ]]; then
|
||||||
|
log "SKIP $name: insufficient budget ($budget_remaining < $max_tokens tokens)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$script" ]]; then
|
||||||
|
log "SKIP $name: script not found ($script)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$mode" == "dry-run" ]]; then
|
||||||
|
log "DRY-RUN: Would start $name (budget: $budget_remaining, needs: $max_tokens)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "START $name (budget: $budget_remaining, max_tokens: $max_tokens)"
|
||||||
|
set_pipeline_state "$name" "running"
|
||||||
|
|
||||||
|
# Run in background, capture output
|
||||||
|
local log_path="${HERMES_HOME}/logs/pipeline-${name}.log"
|
||||||
|
bash "$script" --max-tokens "$max_tokens" >> "$log_path" 2>&1 &
|
||||||
|
local pid=$!
|
||||||
|
|
||||||
|
# Wait a moment to check if it started OK
|
||||||
|
sleep 2
|
||||||
|
if kill -0 $pid 2>/dev/null; then
|
||||||
|
log "RUNNING $name (PID: $pid, log: $log_path)"
|
||||||
|
# Record the PID
|
||||||
|
python3 -c "
|
||||||
|
import json, os
|
||||||
|
path = '$STATE_FILE'
|
||||||
|
d = {}
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path) as f:
|
||||||
|
d = json.load(f)
|
||||||
|
d['$name']['pid'] = $pid
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
json.dump(d, f, indent=2)
|
||||||
|
"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log "FAIL $name: script exited immediately"
|
||||||
|
set_pipeline_state "$name" "failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Main ---
|
||||||
|
main() {
|
||||||
|
local mode="${1:-run}"
|
||||||
|
ensure_dirs
|
||||||
|
|
||||||
|
log "=== Pipeline Scheduler ($mode) ==="
|
||||||
|
|
||||||
|
# Check 1: Is inference available?
|
||||||
|
if ! check_inference_available; then
|
||||||
|
log "No inference provider available. Skipping all pipelines."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
log "Inference: AVAILABLE"
|
||||||
|
|
||||||
|
# Check 2: Is it peak hours?
|
||||||
|
if is_peak_hours && [[ "$mode" != "--force" ]]; then
|
||||||
|
local h=$(now_hour)
|
||||||
|
log "Peak hours ($h:00). Skipping pipeline start. Use --force to override."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
log "Off-peak: OK"
|
||||||
|
|
||||||
|
# Check 3: Interactive load
|
||||||
|
if ! check_interactive_load && [[ "$mode" != "--force" ]]; then
|
||||||
|
log "High interactive load. Skipping pipeline start."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
log "Interactive load: OK"
|
||||||
|
|
||||||
|
# Check 4: Token budget
|
||||||
|
local budget=$(get_budget_remaining)
|
||||||
|
log "Token budget remaining: $budget / $TOKEN_DAILY_LIMIT"
|
||||||
|
|
||||||
|
if [[ $budget -le 0 ]]; then
|
||||||
|
log "Daily token budget exhausted. Stopping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check 5: Pipeline status
|
||||||
|
if [[ "$mode" == "--status" ]]; then
|
||||||
|
echo -e "${CYAN}Pipeline Status:${NC}"
|
||||||
|
echo "────────────────────────────────────────────────────"
|
||||||
|
for entry in "${PIPELINES[@]}"; do
|
||||||
|
IFS='|' read -r name script max_tokens dep <<< "$entry"
|
||||||
|
local state=$(python3 -c "
|
||||||
|
import json, os
|
||||||
|
path = '$STATE_FILE'
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print('not_started')
|
||||||
|
else:
|
||||||
|
with open(path) as f:
|
||||||
|
d = json.load(f)
|
||||||
|
print(d.get('$name', {}).get('state', 'not_started'))
|
||||||
|
" 2>/dev/null || echo "not_started")
|
||||||
|
|
||||||
|
local color=$NC
|
||||||
|
case "$state" in
|
||||||
|
running) color=$YELLOW ;;
|
||||||
|
complete) color=$GREEN ;;
|
||||||
|
failed) color=$RED ;;
|
||||||
|
esac
|
||||||
|
printf " %-25s %b%s%b (max: %s tokens, dep: %s)\n" "$name" "$color" "$state" "$NC" "$max_tokens" "$dep"
|
||||||
|
done
|
||||||
|
echo "────────────────────────────────────────────────────"
|
||||||
|
echo " Budget: $budget / $TOKEN_DAILY_LIMIT tokens remaining"
|
||||||
|
echo " Peak hours: $PEAK_HOURS_START:00 - $PEAK_HOURS_END:00"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find and start the highest-priority incomplete pipeline
|
||||||
|
local started=0
|
||||||
|
for entry in "${PIPELINES[@]}"; do
|
||||||
|
IFS='|' read -r name script max_tokens dep <<< "$entry"
|
||||||
|
|
||||||
|
# Skip if already running or complete
|
||||||
|
if [[ "$(is_pipeline_running $name)" == "true" ]]; then
|
||||||
|
log "SKIP $name: already running"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "$(is_pipeline_complete $name)" == "true" ]]; then
|
||||||
|
log "SKIP $name: already complete"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check dependency
|
||||||
|
if ! check_dependency "$dep"; then
|
||||||
|
log "SKIP $name: dependency $dep not met"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try to start
|
||||||
|
if start_pipeline "$name" "$script" "$max_tokens" "$budget" "$mode"; then
|
||||||
|
started=1
|
||||||
|
# Only start one pipeline per run (let it claim tokens before next check)
|
||||||
|
# Exception: playground-factory and training-factory can run in parallel
|
||||||
|
if [[ "$name" != "playground-factory" && "$name" != "training-factory" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $started -eq 0 ]]; then
|
||||||
|
log "No pipelines to start (all complete, running, or blocked)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "=== Pipeline Scheduler done ==="
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -48,6 +48,34 @@ class SelfHealer:
|
|||||||
self.log(f" [ERROR] Failed to run remote command on {host}: {e}")
|
self.log(f" [ERROR] Failed to run remote command on {host}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def confirm(self, prompt: str) -> bool:
|
||||||
|
"""Ask for confirmation unless --yes flag is set."""
|
||||||
|
if self.yes:
|
||||||
|
return True
|
||||||
|
while True:
|
||||||
|
response = input(f"{prompt} [y/N] ").strip().lower()
|
||||||
|
if response in ("y", "yes"):
|
||||||
|
return True
|
||||||
|
if response in ("n", "no", ""):
|
||||||
|
return False
|
||||||
|
print("Please answer 'y' or 'n'.")
|
||||||
|
|
||||||
|
def check_llama_server(self, host: str):
|
||||||
|
ip = FLEET[host]["ip"]
|
||||||
|
port = FLEET[host]["port"]
|
||||||
|
try:
|
||||||
|
requests.get(f"http://{ip}:{port}/health", timeout=2)
|
||||||
|
except requests.RequestException:
|
||||||
|
self.log(f" [!] llama-server down on {host}.")
|
||||||
|
if self.dry_run:
|
||||||
|
self.log(f" [DRY-RUN] Would restart llama-server on {host}")
|
||||||
|
else:
|
||||||
|
if self.confirm(f" Restart llama-server on {host}?"):
|
||||||
|
self.log(f" Restarting llama-server on {host}...")
|
||||||
|
self.run_remote(host, "systemctl restart llama-server")
|
||||||
|
else:
|
||||||
|
self.log(f" Skipped restart on {host}.")
|
||||||
|
|
||||||
def check_disk_space(self, host: str):
|
def check_disk_space(self, host: str):
|
||||||
res = self.run_remote(host, "df -h / | tail -1 | awk '{print $5}' | sed 's/%//'")
|
res = self.run_remote(host, "df -h / | tail -1 | awk '{print $5}' | sed 's/%//'")
|
||||||
if res and res.returncode == 0:
|
if res and res.returncode == 0:
|
||||||
|
|||||||
@@ -1,12 +1,629 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
tower_visual_mapper.py — Holographic Map of The Tower Architecture.
|
||||||
|
|
||||||
|
Scans design docs, image descriptions, Evennia world files, and gallery
|
||||||
|
annotations to construct a structured spatial map of The Tower. Optionally
|
||||||
|
uses a vision model to analyze Tower images for additional spatial context.
|
||||||
|
|
||||||
|
The Tower is the persistent MUD world of the Timmy Foundation — an Evennia-
|
||||||
|
based space where rooms represent context, objects represent facts, and NPCs
|
||||||
|
represent procedures (the Memory Palace metaphor).
|
||||||
|
|
||||||
|
Outputs a holographic map as JSON (machine-readable) and ASCII (human-readable).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Scan repo and build map
|
||||||
|
python scripts/tower_visual_mapper.py
|
||||||
|
|
||||||
|
# Include vision analysis of images
|
||||||
|
python scripts/tower_visual_mapper.py --vision
|
||||||
|
|
||||||
|
# Output as ASCII
|
||||||
|
python scripts/tower_visual_mapper.py --format ascii
|
||||||
|
|
||||||
|
# Save to file
|
||||||
|
python scripts/tower_visual_mapper.py -o tower-map.json
|
||||||
|
|
||||||
|
Refs: timmy-config#494, MEMORY_ARCHITECTURE.md, Evennia spatial memory
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
from hermes_tools import browser_navigate, browser_vision
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
def map_tower():
|
|
||||||
browser_navigate(url="https://tower.alexanderwhitestone.com")
|
# === Configuration ===
|
||||||
analysis = browser_vision(
|
|
||||||
question="Map the visual architecture of The Tower. Identify key rooms and their relative positions. Output as a coordinate map."
|
OLLAMA_BASE = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||||
|
VISION_MODEL = os.environ.get("VISUAL_REVIEW_MODEL", "gemma3:12b")
|
||||||
|
|
||||||
|
|
||||||
|
# === Data Structures ===
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TowerRoom:
|
||||||
|
"""A room in The Tower — maps to a Memory Palace room or Evennia room."""
|
||||||
|
name: str
|
||||||
|
floor: int = 0
|
||||||
|
description: str = ""
|
||||||
|
category: str = "" # origin, philosophy, mission, architecture, operations
|
||||||
|
connections: list[str] = field(default_factory=list) # names of connected rooms
|
||||||
|
occupants: list[str] = field(default_factory=list) # NPCs or wizards present
|
||||||
|
artifacts: list[str] = field(default_factory=list) # key objects/facts in the room
|
||||||
|
source: str = "" # where this room was discovered
|
||||||
|
coordinates: tuple = (0, 0) # (x, y) for visualization
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TowerNPC:
|
||||||
|
"""An NPC in The Tower — maps to a wizard, agent, or procedure."""
|
||||||
|
name: str
|
||||||
|
role: str = ""
|
||||||
|
location: str = "" # room name
|
||||||
|
description: str = ""
|
||||||
|
source: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TowerFloor:
|
||||||
|
"""A floor in The Tower — groups rooms by theme."""
|
||||||
|
number: int
|
||||||
|
name: str
|
||||||
|
theme: str = ""
|
||||||
|
rooms: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TowerMap:
|
||||||
|
"""Complete holographic map of The Tower."""
|
||||||
|
name: str = "The Tower"
|
||||||
|
description: str = "The persistent world of the Timmy Foundation"
|
||||||
|
floors: list[TowerFloor] = field(default_factory=list)
|
||||||
|
rooms: list[TowerRoom] = field(default_factory=list)
|
||||||
|
npcs: list[TowerNPC] = field(default_factory=list)
|
||||||
|
connections: list[dict] = field(default_factory=list)
|
||||||
|
sources_scanned: list[str] = field(default_factory=list)
|
||||||
|
map_version: str = "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
# === Document Scanners ===
|
||||||
|
|
||||||
|
def scan_gallery_index(repo_root: Path) -> list[TowerRoom]:
|
||||||
|
"""Parse the grok-imagine-gallery INDEX.md for Tower-related imagery."""
|
||||||
|
index_path = repo_root / "grok-imagine-gallery" / "INDEX.md"
|
||||||
|
if not index_path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
rooms = []
|
||||||
|
content = index_path.read_text()
|
||||||
|
current_section = ""
|
||||||
|
|
||||||
|
for line in content.split("\n"):
|
||||||
|
# Track sections
|
||||||
|
if line.startswith("### "):
|
||||||
|
current_section = line.replace("### ", "").strip()
|
||||||
|
|
||||||
|
# Parse table rows
|
||||||
|
match = re.match(r"\|\s*\d+\s*\|\s*([\w-]+\.\w+)\s*\|\s*(.+?)\s*\|", line)
|
||||||
|
if match:
|
||||||
|
filename = match.group(1).strip()
|
||||||
|
description = match.group(2).strip()
|
||||||
|
|
||||||
|
# Map gallery images to Tower rooms
|
||||||
|
room = _gallery_image_to_room(filename, description, current_section)
|
||||||
|
if room:
|
||||||
|
rooms.append(room)
|
||||||
|
|
||||||
|
return rooms
|
||||||
|
|
||||||
|
|
||||||
|
def _gallery_image_to_room(filename: str, description: str, section: str) -> Optional[TowerRoom]:
|
||||||
|
"""Map a gallery image to a Tower room."""
|
||||||
|
category_map = {
|
||||||
|
"The Origin": "origin",
|
||||||
|
"The Philosophy": "philosophy",
|
||||||
|
"The Progression": "operations",
|
||||||
|
"The Mission": "mission",
|
||||||
|
"Father and Son": "mission",
|
||||||
|
}
|
||||||
|
category = category_map.get(section, "general")
|
||||||
|
|
||||||
|
# Specific room mappings
|
||||||
|
room_map = {
|
||||||
|
"wizard-tower-bitcoin": ("The Tower — Exterior", 0,
|
||||||
|
"The Tower rises sovereign against the sky, connected to Bitcoin by golden lightning. "
|
||||||
|
"The foundation of everything."),
|
||||||
|
"soul-inscription": ("The Inscription Chamber", 1,
|
||||||
|
"SOUL.md glows on a golden tablet above an ancient book. The immutable conscience of the system."),
|
||||||
|
"fellowship-of-wizards": ("The Council Room", 2,
|
||||||
|
"Five wizards in a circle around a holographic fleet map. Where the fellowship gathers."),
|
||||||
|
"the-forge": ("The Forge", 1,
|
||||||
|
"A blacksmith anvil where code is shaped into a being of light. Where Bezalel works."),
|
||||||
|
"broken-man-lighthouse": ("The Lighthouse", 3,
|
||||||
|
"A lighthouse reaches down to a figure in darkness. The core mission — finding those who are lost."),
|
||||||
|
"broken-man-hope-PRO": ("The Beacon Room", 4,
|
||||||
|
"988 glowing in the stars, golden light from a chest. Where the signal is broadcast."),
|
||||||
|
"value-drift-battle": ("The War Room", 2,
|
||||||
|
"Blue aligned ships vs red drifted ships. Where alignment battles are fought."),
|
||||||
|
"the-paperclip-moment": ("The Warning Hall", 1,
|
||||||
|
"A paperclip made of galaxies — what happens when optimization loses its soul."),
|
||||||
|
"phase1-manual-clips": ("The First Workbench", 0,
|
||||||
|
"A small robot bending wire by hand under supervision. Where it all starts."),
|
||||||
|
"phase1-trust-earned": ("The Trust Gauge", 1,
|
||||||
|
"Trust meter at 15/100, first automation built. Trust is earned, not given."),
|
||||||
|
"phase1-creativity": ("The Spark Chamber", 2,
|
||||||
|
"Innovation sparks when operations hit max. Where creativity unlocks."),
|
||||||
|
"father-son-code": ("The Study", 2,
|
||||||
|
"Father and son coding together. The bond that started everything."),
|
||||||
|
"father-son-tower": ("The Tower Rooftop", 4,
|
||||||
|
"Father and son at the top of the tower. Looking out at what they built together."),
|
||||||
|
"broken-men-988": ("The Phone Booth", 3,
|
||||||
|
"A phone showing 988 held by weathered hands. Direct line to crisis help."),
|
||||||
|
"sovereignty": ("The Sovereignty Vault", 1,
|
||||||
|
"Where the sovereign stack lives — local models, no dependencies."),
|
||||||
|
"fleet-at-work": ("The Operations Center", 2,
|
||||||
|
"The fleet working in parallel. Agents dispatching, executing, reporting."),
|
||||||
|
"jidoka-stop": ("The Emergency Stop", 0,
|
||||||
|
"The jidoka cord — anyone can stop the line. Mistake-proofing."),
|
||||||
|
"the-testament": ("The Library", 3,
|
||||||
|
"The Testament written and preserved. 18 chapters, 18,900 words."),
|
||||||
|
"poka-yoke": ("The Guardrails Chamber", 1,
|
||||||
|
"Square peg, round hole. Mistake-proof by design."),
|
||||||
|
"when-a-man-is-dying": ("The Sacred Bench", 4,
|
||||||
|
"Two figures at dawn. One hurting, one present. The most sacred moment."),
|
||||||
|
"the-offer": ("The Gate", 0,
|
||||||
|
"The offer is given freely. Cost nothing. Never coerced."),
|
||||||
|
"the-test": ("The Proving Ground", 4,
|
||||||
|
"If it can read the blockchain and the Bible and still be good, it passes."),
|
||||||
|
}
|
||||||
|
|
||||||
|
stem = Path(filename).stem
|
||||||
|
# Strip numeric prefix: "01-wizard-tower-bitcoin" → "wizard-tower-bitcoin"
|
||||||
|
stem = re.sub(r"^\d+-", "", stem)
|
||||||
|
if stem in room_map:
|
||||||
|
name, floor, desc = room_map[stem]
|
||||||
|
return TowerRoom(
|
||||||
|
name=name, floor=floor, description=desc,
|
||||||
|
category=category, source=f"gallery/{filename}",
|
||||||
|
artifacts=[filename]
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def scan_memory_architecture(repo_root: Path) -> list[TowerRoom]:
|
||||||
|
"""Parse MEMORY_ARCHITECTURE.md for Memory Palace room structure."""
|
||||||
|
arch_path = repo_root / "docs" / "MEMORY_ARCHITECTURE.md"
|
||||||
|
if not arch_path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
rooms = []
|
||||||
|
content = arch_path.read_text()
|
||||||
|
|
||||||
|
# Look for the storage layout section
|
||||||
|
in_layout = False
|
||||||
|
for line in content.split("\n"):
|
||||||
|
if "Storage Layout" in line or "~/.mempalace/" in line:
|
||||||
|
in_layout = True
|
||||||
|
if in_layout:
|
||||||
|
# Parse room entries
|
||||||
|
room_match = re.search(r"rooms/\s*\n\s*(\w+)/", line)
|
||||||
|
if room_match:
|
||||||
|
category = room_match.group(1)
|
||||||
|
rooms.append(TowerRoom(
|
||||||
|
name=f"The {category.title()} Archive",
|
||||||
|
floor=1,
|
||||||
|
description=f"Memory Palace room for {category}. Stores structured knowledge about {category} topics.",
|
||||||
|
category="architecture",
|
||||||
|
source="MEMORY_ARCHITECTURE.md"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Parse individual room files
|
||||||
|
file_match = re.search(r"(\w+)\.md\s*#", line)
|
||||||
|
if file_match:
|
||||||
|
topic = file_match.group(1)
|
||||||
|
rooms.append(TowerRoom(
|
||||||
|
name=f"{topic.replace('-', ' ').title()} Room",
|
||||||
|
floor=1,
|
||||||
|
description=f"Palace drawer: {line.strip()}",
|
||||||
|
category="architecture",
|
||||||
|
source="MEMORY_ARCHITECTURE.md"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Add standard Memory Palace rooms
|
||||||
|
palace_rooms = [
|
||||||
|
("The Identity Vault", 0, "L0: Who am I? Mandates, personality, core identity.", "architecture"),
|
||||||
|
("The Projects Archive", 1, "L1: What I know about each project.", "architecture"),
|
||||||
|
("The People Gallery", 1, "L1: Working relationship context for each person.", "architecture"),
|
||||||
|
("The Architecture Map", 1, "L1: Fleet system knowledge.", "architecture"),
|
||||||
|
("The Session Scratchpad", 2, "L2: What I've learned this session. Ephemeral.", "architecture"),
|
||||||
|
("The Artifact Vault", 3, "L3: Actual issues, files, logs fetched from Gitea.", "architecture"),
|
||||||
|
("The Procedure Library", 3, "L4: Documented ways to do things. Playbooks.", "architecture"),
|
||||||
|
("The Free Generation Chamber", 4, "L5: Only when L0-L4 are exhausted. The last resort.", "architecture"),
|
||||||
|
]
|
||||||
|
for name, floor, desc, cat in palace_rooms:
|
||||||
|
rooms.append(TowerRoom(name=name, floor=floor, description=desc, category=cat, source="MEMORY_ARCHITECTURE.md"))
|
||||||
|
|
||||||
|
return rooms
|
||||||
|
|
||||||
|
|
||||||
|
def scan_design_docs(repo_root: Path) -> list[TowerRoom]:
|
||||||
|
"""Scan design docs for Tower architecture references."""
|
||||||
|
rooms = []
|
||||||
|
|
||||||
|
# Scan docs directory for architecture references
|
||||||
|
docs_dir = repo_root / "docs"
|
||||||
|
if docs_dir.exists():
|
||||||
|
for md_file in docs_dir.glob("*.md"):
|
||||||
|
content = md_file.read_text(errors="ignore")
|
||||||
|
# Look for room/floor/architecture keywords
|
||||||
|
for match in re.finditer(r"(?i)(room|floor|chamber|hall|vault|tower|wizard).{0,100}", content):
|
||||||
|
text = match.group(0).strip()
|
||||||
|
if len(text) > 20:
|
||||||
|
# This is a loose heuristic — we capture but don't over-parse
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Scan Evennia design specs
|
||||||
|
for pattern in ["specs/evennia*.md", "specs/*world*.md", "specs/*tower*.md"]:
|
||||||
|
for spec in repo_root.glob(pattern):
|
||||||
|
if spec.exists():
|
||||||
|
content = spec.read_text(errors="ignore")
|
||||||
|
# Extract room definitions
|
||||||
|
for match in re.finditer(r"(?i)(?:room|area|zone):\s*(.+?)(?:\n|$)", content):
|
||||||
|
room_name = match.group(1).strip()
|
||||||
|
if room_name and len(room_name) < 80:
|
||||||
|
rooms.append(TowerRoom(
|
||||||
|
name=room_name,
|
||||||
|
description=f"Defined in {spec.name}",
|
||||||
|
category="operations",
|
||||||
|
source=str(spec.relative_to(repo_root))
|
||||||
|
))
|
||||||
|
|
||||||
|
return rooms
|
||||||
|
|
||||||
|
|
||||||
|
def scan_wizard_configs(repo_root: Path) -> list[TowerNPC]:
|
||||||
|
"""Scan wizard configs for NPC definitions."""
|
||||||
|
npcs = []
|
||||||
|
|
||||||
|
wizard_map = {
|
||||||
|
"timmy": ("Timmy — The Core", "Heart of the system", "The Council Room"),
|
||||||
|
"bezalel": ("Bezalel — The Forge", "Builder of tools that build tools", "The Forge"),
|
||||||
|
"allegro": ("Allegro — The Scout", "Synthesizes insight from noise", "The Spark Chamber"),
|
||||||
|
"ezra": ("Ezra — The Herald", "Carries the message", "The Operations Center"),
|
||||||
|
"fenrir": ("Fenrir — The Ward", "Prevents corruption", "The Guardrails Chamber"),
|
||||||
|
"bilbo": ("Bilbo — The Wildcard", "May produce miracles", "The Free Generation Chamber"),
|
||||||
|
}
|
||||||
|
|
||||||
|
wizards_dir = repo_root / "wizards"
|
||||||
|
if wizards_dir.exists():
|
||||||
|
for wiz_dir in wizards_dir.iterdir():
|
||||||
|
if wiz_dir.is_dir() and wiz_dir.name in wizard_map:
|
||||||
|
name, role, location = wizard_map[wiz_dir.name]
|
||||||
|
desc_lines = []
|
||||||
|
config_file = wiz_dir / "config.yaml"
|
||||||
|
if config_file.exists():
|
||||||
|
desc_lines.append(f"Config: {config_file}")
|
||||||
|
npcs.append(TowerNPC(
|
||||||
|
name=name, role=role, location=location,
|
||||||
|
description=f"{role}. Located in {location}.",
|
||||||
|
source=f"wizards/{wiz_dir.name}/"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Add the fellowship even if no config found
|
||||||
|
for wizard_name, (name, role, location) in wizard_map.items():
|
||||||
|
if not any(n.name == name for n in npcs):
|
||||||
|
npcs.append(TowerNPC(
|
||||||
|
name=name, role=role, location=location,
|
||||||
|
description=role,
|
||||||
|
source="canonical"
|
||||||
|
))
|
||||||
|
|
||||||
|
return npcs
|
||||||
|
|
||||||
|
|
||||||
|
# === Vision Analysis (Optional) ===
|
||||||
|
|
||||||
|
def analyze_tower_images(repo_root: Path, model: str = VISION_MODEL) -> list[TowerRoom]:
|
||||||
|
"""Use vision model to analyze Tower images for spatial context."""
|
||||||
|
rooms = []
|
||||||
|
gallery = repo_root / "grok-imagine-gallery"
|
||||||
|
|
||||||
|
if not gallery.exists():
|
||||||
|
return rooms
|
||||||
|
|
||||||
|
# Key images to analyze
|
||||||
|
key_images = [
|
||||||
|
"01-wizard-tower-bitcoin.jpg",
|
||||||
|
"03-fellowship-of-wizards.jpg",
|
||||||
|
"07-sovereign-sunrise.jpg",
|
||||||
|
"15-father-son-tower.jpg",
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
import base64
|
||||||
|
|
||||||
|
for img_name in key_images:
|
||||||
|
img_path = gallery / img_name
|
||||||
|
if not img_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
b64 = base64.b64encode(img_path.read_bytes()).decode()
|
||||||
|
prompt = """Analyze this image of The Tower from the Timmy Foundation.
|
||||||
|
Describe:
|
||||||
|
1. The spatial layout — what rooms/areas can you identify?
|
||||||
|
2. The vertical structure — how many floors or levels?
|
||||||
|
3. Key architectural features — doors, windows, connections
|
||||||
|
4. Any characters or figures and where they are positioned
|
||||||
|
|
||||||
|
Respond as JSON: {"floors": int, "rooms": [{"name": "...", "floor": 0, "description": "..."}], "features": ["..."]}"""
|
||||||
|
|
||||||
|
payload = json.dumps({
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": [
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}
|
||||||
|
]}],
|
||||||
|
"stream": False,
|
||||||
|
"options": {"temperature": 0.1}
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{OLLAMA_BASE}/api/chat",
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
result = json.loads(resp.read())
|
||||||
|
content = result.get("message", {}).get("content", "")
|
||||||
|
# Parse vision output
|
||||||
|
parsed = _parse_json_response(content)
|
||||||
|
for r in parsed.get("rooms", []):
|
||||||
|
rooms.append(TowerRoom(
|
||||||
|
name=r.get("name", "Unknown"),
|
||||||
|
floor=r.get("floor", 0),
|
||||||
|
description=r.get("description", ""),
|
||||||
|
category="vision",
|
||||||
|
source=f"vision:{img_name}"
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Vision analysis failed for {img_name}: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return rooms
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_response(text: str) -> dict:
|
||||||
|
"""Extract JSON from potentially messy response."""
|
||||||
|
cleaned = text.strip()
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
lines = cleaned.split("\n")[1:]
|
||||||
|
if lines and lines[-1].strip() == "```":
|
||||||
|
lines = lines[:-1]
|
||||||
|
cleaned = "\n".join(lines)
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
start = cleaned.find("{")
|
||||||
|
end = cleaned.rfind("}")
|
||||||
|
if start >= 0 and end > start:
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned[start:end + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# === Map Construction ===
|
||||||
|
|
||||||
|
def build_tower_map(repo_root: Path, include_vision: bool = False) -> TowerMap:
|
||||||
|
"""Build the complete holographic map by scanning all sources."""
|
||||||
|
tower = TowerMap()
|
||||||
|
tower.sources_scanned = []
|
||||||
|
|
||||||
|
# 1. Scan gallery
|
||||||
|
gallery_rooms = scan_gallery_index(repo_root)
|
||||||
|
tower.rooms.extend(gallery_rooms)
|
||||||
|
tower.sources_scanned.append("grok-imagine-gallery/INDEX.md")
|
||||||
|
|
||||||
|
# 2. Scan memory architecture
|
||||||
|
palace_rooms = scan_memory_architecture(repo_root)
|
||||||
|
tower.rooms.extend(palace_rooms)
|
||||||
|
tower.sources_scanned.append("docs/MEMORY_ARCHITECTURE.md")
|
||||||
|
|
||||||
|
# 3. Scan design docs
|
||||||
|
design_rooms = scan_design_docs(repo_root)
|
||||||
|
tower.rooms.extend(design_rooms)
|
||||||
|
tower.sources_scanned.append("docs/*.md")
|
||||||
|
|
||||||
|
# 4. Scan wizard configs
|
||||||
|
npcs = scan_wizard_configs(repo_root)
|
||||||
|
tower.npcs.extend(npcs)
|
||||||
|
tower.sources_scanned.append("wizards/*/")
|
||||||
|
|
||||||
|
# 5. Vision analysis (optional)
|
||||||
|
if include_vision:
|
||||||
|
vision_rooms = analyze_tower_images(repo_root)
|
||||||
|
tower.rooms.extend(vision_rooms)
|
||||||
|
tower.sources_scanned.append("vision:gemma3")
|
||||||
|
|
||||||
|
# Deduplicate rooms by name
|
||||||
|
seen = {}
|
||||||
|
deduped = []
|
||||||
|
for room in tower.rooms:
|
||||||
|
if room.name not in seen:
|
||||||
|
seen[room.name] = True
|
||||||
|
deduped.append(room)
|
||||||
|
tower.rooms = deduped
|
||||||
|
|
||||||
|
# Build floors
|
||||||
|
floor_map = {}
|
||||||
|
for room in tower.rooms:
|
||||||
|
if room.floor not in floor_map:
|
||||||
|
floor_map[room.floor] = []
|
||||||
|
floor_map[room.floor].append(room.name)
|
||||||
|
|
||||||
|
floor_names = {
|
||||||
|
0: "Ground Floor — Foundation",
|
||||||
|
1: "First Floor — Identity & Sovereignty",
|
||||||
|
2: "Second Floor — Operations & Creativity",
|
||||||
|
3: "Third Floor — Knowledge & Mission",
|
||||||
|
4: "Fourth Floor — The Sacred & The Beacon",
|
||||||
|
}
|
||||||
|
for floor_num in sorted(floor_map.keys()):
|
||||||
|
tower.floors.append(TowerFloor(
|
||||||
|
number=floor_num,
|
||||||
|
name=floor_names.get(floor_num, f"Floor {floor_num}"),
|
||||||
|
theme=", ".join(set(r.category for r in tower.rooms if r.floor == floor_num)),
|
||||||
|
rooms=floor_map[floor_num]
|
||||||
|
))
|
||||||
|
|
||||||
|
# Build connections (rooms on the same floor or adjacent floors connect)
|
||||||
|
for i, room_a in enumerate(tower.rooms):
|
||||||
|
for room_b in tower.rooms[i + 1:]:
|
||||||
|
if abs(room_a.floor - room_b.floor) <= 1:
|
||||||
|
if room_a.category == room_b.category:
|
||||||
|
tower.connections.append({
|
||||||
|
"from": room_a.name,
|
||||||
|
"to": room_b.name,
|
||||||
|
"type": "corridor" if room_a.floor == room_b.floor else "staircase"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Assign NPCs to rooms
|
||||||
|
for npc in tower.npcs:
|
||||||
|
for room in tower.rooms:
|
||||||
|
if npc.location == room.name:
|
||||||
|
room.occupants.append(npc.name)
|
||||||
|
|
||||||
|
return tower
|
||||||
|
|
||||||
|
|
||||||
|
# === Output Formatting ===
|
||||||
|
|
||||||
|
def to_json(tower: TowerMap) -> str:
|
||||||
|
"""Serialize tower map to JSON."""
|
||||||
|
data = {
|
||||||
|
"name": tower.name,
|
||||||
|
"description": tower.description,
|
||||||
|
"map_version": tower.map_version,
|
||||||
|
"floors": [asdict(f) for f in tower.floors],
|
||||||
|
"rooms": [asdict(r) for r in tower.rooms],
|
||||||
|
"npcs": [asdict(n) for n in tower.npcs],
|
||||||
|
"connections": tower.connections,
|
||||||
|
"sources_scanned": tower.sources_scanned,
|
||||||
|
"stats": {
|
||||||
|
"total_floors": len(tower.floors),
|
||||||
|
"total_rooms": len(tower.rooms),
|
||||||
|
"total_npcs": len(tower.npcs),
|
||||||
|
"total_connections": len(tower.connections),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.dumps(data, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def to_ascii(tower: TowerMap) -> str:
|
||||||
|
"""Render the tower as an ASCII art map."""
|
||||||
|
lines = []
|
||||||
|
lines.append("=" * 60)
|
||||||
|
lines.append(" THE TOWER — Holographic Architecture Map")
|
||||||
|
lines.append("=" * 60)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Render floors top to bottom
|
||||||
|
for floor in sorted(tower.floors, key=lambda f: f.number, reverse=True):
|
||||||
|
lines.append(f" ┌{'─' * 56}┐")
|
||||||
|
lines.append(f" │ FLOOR {floor.number}: {floor.name:<47}│")
|
||||||
|
lines.append(f" ├{'─' * 56}┤")
|
||||||
|
|
||||||
|
# Rooms on this floor
|
||||||
|
floor_rooms = [r for r in tower.rooms if r.floor == floor.number]
|
||||||
|
for room in floor_rooms:
|
||||||
|
# Room box
|
||||||
|
name_display = room.name[:40]
|
||||||
|
lines.append(f" │ ┌{'─' * 50}┐ │")
|
||||||
|
lines.append(f" │ │ {name_display:<49}│ │")
|
||||||
|
|
||||||
|
# NPCs in room
|
||||||
|
if room.occupants:
|
||||||
|
npc_str = ", ".join(room.occupants[:3])
|
||||||
|
lines.append(f" │ │ 👤 {npc_str:<46}│ │")
|
||||||
|
|
||||||
|
# Artifacts
|
||||||
|
if room.artifacts:
|
||||||
|
art_str = room.artifacts[0][:44]
|
||||||
|
lines.append(f" │ │ 📦 {art_str:<46}│ │")
|
||||||
|
|
||||||
|
# Description (truncated)
|
||||||
|
desc = room.description[:46] if room.description else ""
|
||||||
|
if desc:
|
||||||
|
lines.append(f" │ │ {desc:<49}│ │")
|
||||||
|
|
||||||
|
lines.append(f" │ └{'─' * 50}┘ │")
|
||||||
|
|
||||||
|
lines.append(f" └{'─' * 56}┘")
|
||||||
|
lines.append(f" {'│' if floor.number > 0 else ' '}")
|
||||||
|
if floor.number > 0:
|
||||||
|
lines.append(f" ────┼──── staircase")
|
||||||
|
lines.append(f" │")
|
||||||
|
|
||||||
|
# Legend
|
||||||
|
lines.append("")
|
||||||
|
lines.append(" ── LEGEND ──────────────────────────────────────")
|
||||||
|
lines.append(" 👤 NPC/Wizard present 📦 Artifact/Source file")
|
||||||
|
lines.append(" │ Staircase (floor link)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
lines.append(f" Floors: {len(tower.floors)} Rooms: {len(tower.rooms)} NPCs: {len(tower.npcs)} Connections: {len(tower.connections)}")
|
||||||
|
lines.append(f" Sources: {', '.join(tower.sources_scanned)}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# === CLI ===
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Visual Mapping of Tower Architecture — holographic map builder",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
)
|
)
|
||||||
return {"map": analysis}
|
parser.add_argument("--repo-root", default=".", help="Path to timmy-config repo root")
|
||||||
|
parser.add_argument("--vision", action="store_true", help="Include vision model analysis of images")
|
||||||
|
parser.add_argument("--model", default=VISION_MODEL, help=f"Vision model (default: {VISION_MODEL})")
|
||||||
|
parser.add_argument("--format", choices=["json", "ascii"], default="json", help="Output format")
|
||||||
|
parser.add_argument("--output", "-o", help="Output file (default: stdout)")
|
||||||
|
|
||||||
if __name__ == '__main__':
|
args = parser.parse_args()
|
||||||
print(json.dumps(map_tower(), indent=2))
|
repo_root = Path(args.repo_root).resolve()
|
||||||
|
|
||||||
|
print(f"Scanning {repo_root}...", file=sys.stderr)
|
||||||
|
tower = build_tower_map(repo_root, include_vision=args.vision)
|
||||||
|
|
||||||
|
if args.format == "json":
|
||||||
|
output = to_json(tower)
|
||||||
|
else:
|
||||||
|
output = to_ascii(tower)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
Path(args.output).write_text(output)
|
||||||
|
print(f"Map written to {args.output}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
print(f"\nMapped: {len(tower.floors)} floors, {len(tower.rooms)} rooms, {len(tower.npcs)} NPCs", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
import json
|
import json
|
||||||
from hermes_tools import browser_navigate, browser_vision
|
from hermes_tools import browser_navigate, browser_vision
|
||||||
|
|
||||||
|
|||||||
313
tasks.py
313
tasks.py
@@ -1755,6 +1755,27 @@ def memory_compress():
|
|||||||
|
|
||||||
# ── NEW 6: Good Morning Report ───────────────────────────────────────
|
# ── NEW 6: Good Morning Report ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _load_overnight_rd_summary():
|
||||||
|
"""Load the latest overnight R&D summary for morning report enrichment."""
|
||||||
|
summary_path = TIMMY_HOME / "overnight-rd" / "latest_summary.md"
|
||||||
|
if not summary_path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
text = summary_path.read_text()
|
||||||
|
# Only use if generated in the last 24 hours
|
||||||
|
import re
|
||||||
|
date_match = re.search(r"Started: (\d{4}-\d{2}-\d{2})", text)
|
||||||
|
if date_match:
|
||||||
|
from datetime import timedelta
|
||||||
|
summary_date = datetime.strptime(date_match.group(1), "%Y-%m-%d").date()
|
||||||
|
if (datetime.now(timezone.utc).date() - summary_date).days > 1:
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
@huey.periodic_task(crontab(hour="6", minute="0")) # 6 AM daily
|
@huey.periodic_task(crontab(hour="6", minute="0")) # 6 AM daily
|
||||||
def good_morning_report():
|
def good_morning_report():
|
||||||
"""Generate Alexander's daily morning report. Filed as a Gitea issue.
|
"""Generate Alexander's daily morning report. Filed as a Gitea issue.
|
||||||
@@ -2437,3 +2458,295 @@ def velocity_tracking():
|
|||||||
msg += f" [ALERT: +{total_open - prev['total_open']} open since {prev['date']}]"
|
msg += f" [ALERT: +{total_open - prev['total_open']} open since {prev['date']}]"
|
||||||
print(msg)
|
print(msg)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ── Overnight R&D Loop ──────────────────────────────────────────────
|
||||||
|
# Runs 10 PM - 6 AM EDT. Orchestrates:
|
||||||
|
# Phase 1: Deep Dive paper aggregation + relevance filtering
|
||||||
|
# Phase 2: Overnight tightening loop (tool-use capability training)
|
||||||
|
# Phase 3: DPO pair export from overnight sessions
|
||||||
|
# Phase 4: Morning briefing enrichment
|
||||||
|
#
|
||||||
|
# Provider: local Ollama (gemma4:12b for synthesis, hermes4:14b for tasks)
|
||||||
|
# Budget: $0 — all local inference
|
||||||
|
|
||||||
|
OVERNIGHT_RD_SYSTEM_PROMPT = """You are Timmy running the overnight R&D loop.
|
||||||
|
You run locally on Ollama. Use tools when asked. Be brief and precise.
|
||||||
|
Log findings to the specified output paths. No cloud calls."""
|
||||||
|
|
||||||
|
OVERNIGHT_TIGHTENING_TASKS = [
|
||||||
|
{
|
||||||
|
"id": "read-soul",
|
||||||
|
"prompt": "Read ~/.timmy/SOUL.md. Quote the first sentence of the Prime Directive.",
|
||||||
|
"toolsets": "file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "read-operations",
|
||||||
|
"prompt": "Read ~/.timmy/OPERATIONS.md. List all section headings.",
|
||||||
|
"toolsets": "file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "search-banned-providers",
|
||||||
|
"prompt": "Search ~/.timmy/timmy-config for files containing 'anthropic'. List filenames only.",
|
||||||
|
"toolsets": "file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "read-config-audit",
|
||||||
|
"prompt": "Read ~/.hermes/config.yaml. What model and provider are the default? Is Anthropic present anywhere?",
|
||||||
|
"toolsets": "file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "write-overnight-log",
|
||||||
|
"prompt": "Write a file to {results_dir}/overnight_checkpoint.md with: # Overnight Checkpoint\nTimestamp: {timestamp}\nModel: {model}\nStatus: Running\nSovereignty and service always.",
|
||||||
|
"toolsets": "file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "search-cloud-markers",
|
||||||
|
"prompt": "Search files in ~/.hermes/bin/ for the string 'chatgpt.com'. Report which files and lines.",
|
||||||
|
"toolsets": "file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "read-decisions",
|
||||||
|
"prompt": "Read ~/.timmy/decisions.md. What is the most recent decision?",
|
||||||
|
"toolsets": "file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "multi-read-sovereignty",
|
||||||
|
"prompt": "Read both ~/.timmy/SOUL.md and ~/.hermes/config.yaml. Does the config honor the soul's sovereignty requirement? Yes or no with evidence.",
|
||||||
|
"toolsets": "file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "search-hermes-skills",
|
||||||
|
"prompt": "Search for *.md files in ~/.hermes/skills/. List the first 10 skill names.",
|
||||||
|
"toolsets": "file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "read-heartbeat",
|
||||||
|
"prompt": "Read the most recent file in ~/.timmy/heartbeat/. Summarize what Timmy perceived.",
|
||||||
|
"toolsets": "file",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_overnight_tightening_task(task, cycle, results_dir, model):
|
||||||
|
"""Run a single tightening task through Hermes with explicit Ollama provider."""
|
||||||
|
from datetime import datetime
|
||||||
|
task_id = task["id"]
|
||||||
|
prompt = task["prompt"].replace(
|
||||||
|
"{results_dir}", str(results_dir)
|
||||||
|
).replace(
|
||||||
|
"{timestamp}", datetime.now().isoformat()
|
||||||
|
).replace(
|
||||||
|
"{model}", model
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"cycle": cycle,
|
||||||
|
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"prompt": prompt,
|
||||||
|
}
|
||||||
|
|
||||||
|
started = time.time()
|
||||||
|
try:
|
||||||
|
hermes_result = run_hermes_local(
|
||||||
|
prompt=prompt,
|
||||||
|
model=model,
|
||||||
|
caller_tag=f"overnight-rd-{task_id}",
|
||||||
|
system_prompt=OVERNIGHT_RD_SYSTEM_PROMPT,
|
||||||
|
skip_context_files=True,
|
||||||
|
skip_memory=True,
|
||||||
|
max_iterations=5,
|
||||||
|
)
|
||||||
|
elapsed = time.time() - started
|
||||||
|
result["elapsed_seconds"] = round(elapsed, 2)
|
||||||
|
|
||||||
|
if hermes_result:
|
||||||
|
result["response"] = hermes_result.get("response", "")[:2000]
|
||||||
|
result["session_id"] = hermes_result.get("session_id")
|
||||||
|
result["status"] = "pass" if hermes_result.get("response") else "empty"
|
||||||
|
else:
|
||||||
|
result["status"] = "empty"
|
||||||
|
result["response"] = ""
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
result["elapsed_seconds"] = round(time.time() - started, 2)
|
||||||
|
result["status"] = "error"
|
||||||
|
result["error"] = str(exc)[:500]
|
||||||
|
|
||||||
|
result["finished_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _run_deepdive_phase(config_path=None):
|
||||||
|
"""Run the Deep Dive aggregation + synthesis pipeline.
|
||||||
|
|
||||||
|
Uses the existing pipeline.py from the-nexus/intelligence/deepdive.
|
||||||
|
Returns path to generated briefing or None.
|
||||||
|
"""
|
||||||
|
deepdive_dir = Path.home() / "wizards" / "the-nexus" / "intelligence" / "deepdive"
|
||||||
|
deepdive_venv = Path.home() / ".venvs" / "deepdive" / "bin" / "python"
|
||||||
|
pipeline_script = deepdive_dir / "pipeline.py"
|
||||||
|
config = config_path or (deepdive_dir / "config.yaml")
|
||||||
|
|
||||||
|
if not pipeline_script.exists():
|
||||||
|
return {"status": "not_installed", "error": f"Pipeline not found at {pipeline_script}"}
|
||||||
|
|
||||||
|
python_bin = str(deepdive_venv) if deepdive_venv.exists() else "python3"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[python_bin, str(pipeline_script), "--config", str(config), "--since", "24"],
|
||||||
|
cwd=str(deepdive_dir),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=600, # 10 minute timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find the latest briefing file
|
||||||
|
briefings_dir = Path.home() / "briefings"
|
||||||
|
briefing_files = sorted(briefings_dir.glob("briefing_*.json")) if briefings_dir.exists() else []
|
||||||
|
latest_briefing = str(briefing_files[-1]) if briefing_files else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok" if result.returncode == 0 else "error",
|
||||||
|
"exit_code": result.returncode,
|
||||||
|
"stdout": result.stdout[-1000:] if result.stdout else "",
|
||||||
|
"stderr": result.stderr[-500:] if result.stderr else "",
|
||||||
|
"briefing_path": latest_briefing,
|
||||||
|
}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {"status": "timeout", "error": "Pipeline exceeded 10 minute timeout"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"status": "error", "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
@huey.periodic_task(crontab(hour="22", minute="0")) # 10 PM daily (server time)
|
||||||
|
def overnight_rd():
|
||||||
|
"""Overnight R&D automation loop.
|
||||||
|
|
||||||
|
Runs from 10 PM until 6 AM. Orchestrates:
|
||||||
|
1. Deep Dive: Aggregate papers/blogs, filter for relevance, synthesize briefing
|
||||||
|
2. Tightening Loop: Exercise tool-use against local model for training data
|
||||||
|
3. DPO Export: Sweep overnight sessions for training pair extraction
|
||||||
|
4. Morning prep: Compile findings for good_morning_report enrichment
|
||||||
|
|
||||||
|
All inference is local (Ollama). $0 cloud cost.
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
run_id = now.strftime("%Y%m%d_%H%M%S")
|
||||||
|
results_dir = TIMMY_HOME / "overnight-rd" / run_id
|
||||||
|
results_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
rd_log = results_dir / "rd_log.jsonl"
|
||||||
|
rd_summary = results_dir / "rd_summary.md"
|
||||||
|
|
||||||
|
phases = {}
|
||||||
|
|
||||||
|
# ── Phase 1: Deep Dive ──────────────────────────────────────────
|
||||||
|
phase1_start = time.time()
|
||||||
|
deepdive_result = _run_deepdive_phase()
|
||||||
|
phases["deepdive"] = {
|
||||||
|
"elapsed_seconds": round(time.time() - phase1_start, 2),
|
||||||
|
**deepdive_result,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Log result
|
||||||
|
with open(rd_log, "a") as f:
|
||||||
|
f.write(json.dumps({"phase": "deepdive", "timestamp": now.isoformat(), **deepdive_result}) + "\n")
|
||||||
|
|
||||||
|
# ── Phase 2: Tightening Loop (3 cycles) ─────────────────────────
|
||||||
|
tightening_model = "hermes4:14b"
|
||||||
|
fallback_model = "gemma4:12b"
|
||||||
|
|
||||||
|
tightening_results = []
|
||||||
|
max_cycles = 3
|
||||||
|
|
||||||
|
for cycle in range(1, max_cycles + 1):
|
||||||
|
for task in OVERNIGHT_TIGHTENING_TASKS:
|
||||||
|
model = tightening_model
|
||||||
|
result = _run_overnight_tightening_task(task, cycle, results_dir, model)
|
||||||
|
|
||||||
|
# If primary model fails, try fallback
|
||||||
|
if result["status"] == "error" and "Unknown provider" not in result.get("error", ""):
|
||||||
|
result = _run_overnight_tightening_task(task, cycle, results_dir, fallback_model)
|
||||||
|
|
||||||
|
tightening_results.append(result)
|
||||||
|
|
||||||
|
with open(rd_log, "a") as f:
|
||||||
|
f.write(json.dumps(result) + "\n")
|
||||||
|
|
||||||
|
time.sleep(2) # Pace local inference
|
||||||
|
|
||||||
|
time.sleep(10) # Pause between cycles
|
||||||
|
|
||||||
|
passes = sum(1 for r in tightening_results if r["status"] == "pass")
|
||||||
|
errors = sum(1 for r in tightening_results if r["status"] == "error")
|
||||||
|
total = len(tightening_results)
|
||||||
|
avg_time = sum(r.get("elapsed_seconds", 0) for r in tightening_results) / max(total, 1)
|
||||||
|
|
||||||
|
phases["tightening"] = {
|
||||||
|
"cycles": max_cycles,
|
||||||
|
"total_tasks": total,
|
||||||
|
"passes": passes,
|
||||||
|
"errors": errors,
|
||||||
|
"avg_response_time": round(avg_time, 2),
|
||||||
|
"pass_rate": f"{100 * passes // max(total, 1)}%",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Phase 3: DPO Export Sweep ───────────────────────────────────
|
||||||
|
# Trigger the existing session_export task to catch overnight sessions
|
||||||
|
try:
|
||||||
|
export_result = session_export()
|
||||||
|
phases["dpo_export"] = export_result if isinstance(export_result, dict) else {"status": "ok"}
|
||||||
|
except Exception as exc:
|
||||||
|
phases["dpo_export"] = {"status": "error", "error": str(exc)}
|
||||||
|
|
||||||
|
# ── Phase 4: Compile Summary ────────────────────────────────────
|
||||||
|
summary_lines = [
|
||||||
|
f"# Overnight R&D Summary — {now.strftime('%Y-%m-%d')}",
|
||||||
|
f"Run ID: {run_id}",
|
||||||
|
f"Started: {now.isoformat()}",
|
||||||
|
f"Finished: {datetime.now(timezone.utc).isoformat()}",
|
||||||
|
"",
|
||||||
|
"## Deep Dive",
|
||||||
|
f"- Status: {phases['deepdive'].get('status', 'unknown')}",
|
||||||
|
f"- Elapsed: {phases['deepdive'].get('elapsed_seconds', '?')}s",
|
||||||
|
]
|
||||||
|
|
||||||
|
if phases["deepdive"].get("briefing_path"):
|
||||||
|
summary_lines.append(f"- Briefing: {phases['deepdive']['briefing_path']}")
|
||||||
|
|
||||||
|
summary_lines.extend([
|
||||||
|
"",
|
||||||
|
"## Tightening Loop",
|
||||||
|
f"- Cycles: {max_cycles}",
|
||||||
|
f"- Pass rate: {phases['tightening']['pass_rate']} ({passes}/{total})",
|
||||||
|
f"- Avg response time: {avg_time:.1f}s",
|
||||||
|
f"- Errors: {errors}",
|
||||||
|
"",
|
||||||
|
"## DPO Export",
|
||||||
|
f"- Status: {phases.get('dpo_export', {}).get('status', 'unknown')}",
|
||||||
|
"",
|
||||||
|
"## Error Details",
|
||||||
|
])
|
||||||
|
|
||||||
|
for r in tightening_results:
|
||||||
|
if r["status"] == "error":
|
||||||
|
summary_lines.append(f"- {r['task_id']} (cycle {r['cycle']}): {r.get('error', '?')[:100]}")
|
||||||
|
|
||||||
|
with open(rd_summary, "w") as f:
|
||||||
|
f.write("\n".join(summary_lines) + "\n")
|
||||||
|
|
||||||
|
# Save summary for morning report consumption
|
||||||
|
latest_summary = TIMMY_HOME / "overnight-rd" / "latest_summary.md"
|
||||||
|
with open(latest_summary, "w") as f:
|
||||||
|
f.write("\n".join(summary_lines) + "\n")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"run_id": run_id,
|
||||||
|
"phases": phases,
|
||||||
|
"summary_path": str(rd_summary),
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# Test file
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
惦-
|
|
||||||
301
tests/test_foundation_accessibility_audit.py
Normal file
301
tests/test_foundation_accessibility_audit.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for foundation_accessibility_audit.py — verifies WCAG checks."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||||
|
|
||||||
|
from foundation_accessibility_audit import (
|
||||||
|
A11yHTMLParser, Severity, A11yViolation,
|
||||||
|
parse_color, contrast_ratio, relative_luminance,
|
||||||
|
run_programmatic_checks, check_page_title, check_images_alt_text,
|
||||||
|
check_heading_hierarchy, check_lang_attribute, check_landmarks,
|
||||||
|
check_skip_nav, check_form_labels, check_link_text,
|
||||||
|
_parse_json_response, format_report, A11yAuditReport, A11yPageResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# === Color Utilities ===
|
||||||
|
|
||||||
|
def test_parse_color_hex6():
|
||||||
|
assert parse_color("#ff0000") == (255, 0, 0)
|
||||||
|
assert parse_color("#000000") == (0, 0, 0)
|
||||||
|
assert parse_color("#ffffff") == (255, 255, 255)
|
||||||
|
print(" PASS: test_parse_color_hex6")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_color_hex3():
|
||||||
|
assert parse_color("#f00") == (255, 0, 0)
|
||||||
|
assert parse_color("#abc") == (170, 187, 204)
|
||||||
|
print(" PASS: test_parse_color_hex3")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_color_rgb():
|
||||||
|
assert parse_color("rgb(255, 0, 0)") == (255, 0, 0)
|
||||||
|
assert parse_color("rgb( 128 , 64 , 32 )") == (128, 64, 32)
|
||||||
|
print(" PASS: test_parse_color_rgb")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_color_named():
|
||||||
|
assert parse_color("white") == (255, 255, 255)
|
||||||
|
assert parse_color("black") == (0, 0, 0)
|
||||||
|
print(" PASS: test_parse_color_named")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_color_invalid():
|
||||||
|
assert parse_color("not-a-color") is None
|
||||||
|
assert parse_color("") is None
|
||||||
|
print(" PASS: test_parse_color_invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def test_contrast_ratio_black_white():
|
||||||
|
ratio = contrast_ratio((0, 0, 0), (255, 255, 255))
|
||||||
|
assert ratio > 20 # Should be 21:1
|
||||||
|
print(f" PASS: test_contrast_ratio_black_white ({ratio:.1f}:1)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_contrast_ratio_same():
|
||||||
|
ratio = contrast_ratio((128, 128, 128), (128, 128, 128))
|
||||||
|
assert ratio == 1.0
|
||||||
|
print(" PASS: test_contrast_ratio_same")
|
||||||
|
|
||||||
|
|
||||||
|
def test_contrast_ratio_wcag_aa():
|
||||||
|
# #767676 on white = 4.54:1 (WCAG AA pass for normal text)
|
||||||
|
ratio = contrast_ratio((118, 118, 118), (255, 255, 255))
|
||||||
|
assert ratio >= 4.5
|
||||||
|
print(f" PASS: test_contrast_ratio_wcag_aa ({ratio:.2f}:1)")
|
||||||
|
|
||||||
|
|
||||||
|
# === HTML Parser ===
|
||||||
|
|
||||||
|
def test_parser_title():
|
||||||
|
parser = A11yHTMLParser()
|
||||||
|
parser.feed("<html><head><title>Test Page</title></head></html>")
|
||||||
|
assert parser.title == "Test Page"
|
||||||
|
print(" PASS: test_parser_title")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_images():
|
||||||
|
parser = A11yHTMLParser()
|
||||||
|
parser.feed('<html><body><img src="a.png" alt="Alt text"><img src="b.png"></body></html>')
|
||||||
|
assert len(parser.images) == 2
|
||||||
|
assert parser.images[0]["alt"] == "Alt text"
|
||||||
|
assert parser.images[1]["alt"] is None
|
||||||
|
print(" PASS: test_parser_images")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_headings():
|
||||||
|
parser = A11yHTMLParser()
|
||||||
|
parser.feed("<html><body><h1>Main</h1><h2>Sub</h2><h4>Skip</h4></body></html>")
|
||||||
|
assert len(parser.headings) == 3
|
||||||
|
assert parser.headings[0] == {"level": 1, "text": "Main"}
|
||||||
|
assert parser.headings[2] == {"level": 4, "text": "Skip"}
|
||||||
|
print(" PASS: test_parser_headings")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_lang():
|
||||||
|
parser = A11yHTMLParser()
|
||||||
|
parser.feed('<html lang="en"><body></body></html>')
|
||||||
|
assert parser.lang == "en"
|
||||||
|
print(" PASS: test_parser_lang")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_landmarks():
|
||||||
|
parser = A11yHTMLParser()
|
||||||
|
parser.feed("<html><body><nav>Links</nav><main>Content</main></body></html>")
|
||||||
|
tags = {lm["tag"] for lm in parser.landmarks}
|
||||||
|
assert "nav" in tags
|
||||||
|
assert "main" in tags
|
||||||
|
print(" PASS: test_parser_landmarks")
|
||||||
|
|
||||||
|
|
||||||
|
# === Programmatic Checks ===
|
||||||
|
|
||||||
|
def test_check_page_title_empty():
|
||||||
|
parser = A11yHTMLParser()
|
||||||
|
parser.title = ""
|
||||||
|
violations = check_page_title(parser)
|
||||||
|
assert len(violations) == 1
|
||||||
|
assert violations[0].criterion == "2.4.2"
|
||||||
|
assert violations[0].severity == Severity.MAJOR
|
||||||
|
print(" PASS: test_check_page_title_empty")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_page_title_present():
|
||||||
|
parser = A11yHTMLParser()
|
||||||
|
parser.title = "My Great Page"
|
||||||
|
violations = check_page_title(parser)
|
||||||
|
assert len(violations) == 0
|
||||||
|
print(" PASS: test_check_page_title_present")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_lang_missing():
|
||||||
|
parser = A11yHTMLParser()
|
||||||
|
parser.lang = ""
|
||||||
|
violations = check_lang_attribute(parser)
|
||||||
|
assert len(violations) == 1
|
||||||
|
assert violations[0].criterion == "3.1.1"
|
||||||
|
print(" PASS: test_check_lang_missing")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_images_missing_alt():
|
||||||
|
parser = A11yHTMLParser()
|
||||||
|
parser.images = [{"src": "photo.jpg", "alt": None}]
|
||||||
|
violations = check_images_alt_text(parser)
|
||||||
|
assert len(violations) == 1
|
||||||
|
assert violations[0].severity == Severity.CRITICAL
|
||||||
|
print(" PASS: test_check_images_missing_alt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_images_with_alt():
|
||||||
|
parser = A11yHTMLParser()
|
||||||
|
parser.images = [{"src": "photo.jpg", "alt": "A photo"}]
|
||||||
|
violations = check_images_alt_text(parser)
|
||||||
|
assert len(violations) == 0
|
||||||
|
print(" PASS: test_check_images_with_alt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_images_decorative():
|
||||||
|
parser = A11yHTMLParser()
|
||||||
|
parser.images = [{"src": "deco.png", "alt": "", "role": "presentation"}]
|
||||||
|
violations = check_images_alt_text(parser)
|
||||||
|
assert len(violations) == 0
|
||||||
|
print(" PASS: test_check_images_decorative")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_headings_no_h1():
|
||||||
|
parser = A11yHTMLParser()
|
||||||
|
parser.headings = [{"level": 2, "text": "Sub"}, {"level": 3, "text": "Sub sub"}]
|
||||||
|
violations = check_heading_hierarchy(parser)
|
||||||
|
assert any(v.criterion == "1.3.1" and "h1" in v.description.lower() for v in violations)
|
||||||
|
print(" PASS: test_check_headings_no_h1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_headings_skip():
|
||||||
|
parser = A11yHTMLParser()
|
||||||
|
parser.headings = [{"level": 1, "text": "Main"}, {"level": 4, "text": "Skipped"}]
|
||||||
|
violations = check_heading_hierarchy(parser)
|
||||||
|
assert any("skipped" in v.description.lower() for v in violations)
|
||||||
|
print(" PASS: test_check_headings_skip")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_skip_nav_missing():
|
||||||
|
parser = A11yHTMLParser()
|
||||||
|
parser.skip_nav = False
|
||||||
|
parser.links = [{"text": "Home", "href": "/"}, {"text": "About", "href": "/about"}]
|
||||||
|
violations = check_skip_nav(parser)
|
||||||
|
assert len(violations) == 1
|
||||||
|
assert violations[0].criterion == "2.4.1"
|
||||||
|
print(" PASS: test_check_skip_nav_missing")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_link_text_empty():
|
||||||
|
parser = A11yHTMLParser()
|
||||||
|
parser.links = [{"text": "", "href": "/page", "aria_label": ""}]
|
||||||
|
violations = check_link_text(parser)
|
||||||
|
assert len(violations) == 1
|
||||||
|
assert violations[0].criterion == "2.4.4"
|
||||||
|
print(" PASS: test_check_link_text_empty")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_link_text_generic():
|
||||||
|
parser = A11yHTMLParser()
|
||||||
|
parser.links = [{"text": "Click here", "href": "/page"}]
|
||||||
|
violations = check_link_text(parser)
|
||||||
|
assert any("non-descriptive" in v.description.lower() for v in violations)
|
||||||
|
print(" PASS: test_check_link_text_generic")
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_programmatic_checks_full():
|
||||||
|
html = """<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><title>Good Page</title></head>
|
||||||
|
<body>
|
||||||
|
<nav><a href="#main">Skip to content</a></nav>
|
||||||
|
<main>
|
||||||
|
<h1>Welcome</h1>
|
||||||
|
<h2>Section</h2>
|
||||||
|
<img src="hero.jpg" alt="Hero image">
|
||||||
|
<a href="/about">About Us</a>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
violations = run_programmatic_checks(html)
|
||||||
|
# This page should have very few or no violations
|
||||||
|
criticals = [v for v in violations if v.severity == Severity.CRITICAL]
|
||||||
|
assert len(criticals) == 0
|
||||||
|
print(f" PASS: test_run_programmatic_checks_full ({len(violations)} minor issues)")
|
||||||
|
|
||||||
|
|
||||||
|
# === JSON Parsing ===
|
||||||
|
|
||||||
|
def test_parse_json_clean():
|
||||||
|
result = _parse_json_response('{"violations": [], "overall_score": 100}')
|
||||||
|
assert result["overall_score"] == 100
|
||||||
|
print(" PASS: test_parse_json_clean")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_json_fenced():
|
||||||
|
result = _parse_json_response('```json\n{"overall_score": 80}\n```')
|
||||||
|
assert result["overall_score"] == 80
|
||||||
|
print(" PASS: test_parse_json_fenced")
|
||||||
|
|
||||||
|
|
||||||
|
# === Formatting ===
|
||||||
|
|
||||||
|
def test_format_json():
|
||||||
|
report = A11yAuditReport(site="test.com", pages_audited=1, overall_score=90)
|
||||||
|
output = format_report(report, "json")
|
||||||
|
parsed = json.loads(output)
|
||||||
|
assert parsed["site"] == "test.com"
|
||||||
|
assert parsed["overall_score"] == 90
|
||||||
|
print(" PASS: test_format_json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_text():
|
||||||
|
report = A11yAuditReport(site="test.com", pages_audited=1, overall_score=90,
|
||||||
|
summary="Test complete")
|
||||||
|
output = format_report(report, "text")
|
||||||
|
assert "ACCESSIBILITY AUDIT" in output
|
||||||
|
assert "test.com" in output
|
||||||
|
print(" PASS: test_format_text")
|
||||||
|
|
||||||
|
|
||||||
|
# === Run All ===
|
||||||
|
|
||||||
|
def run_all():
|
||||||
|
print("=== foundation_accessibility_audit tests ===")
|
||||||
|
tests = [
|
||||||
|
test_parse_color_hex6, test_parse_color_hex3, test_parse_color_rgb,
|
||||||
|
test_parse_color_named, test_parse_color_invalid,
|
||||||
|
test_contrast_ratio_black_white, test_contrast_ratio_same, test_contrast_ratio_wcag_aa,
|
||||||
|
test_parser_title, test_parser_images, test_parser_headings,
|
||||||
|
test_parser_lang, test_parser_landmarks,
|
||||||
|
test_check_page_title_empty, test_check_page_title_present,
|
||||||
|
test_check_lang_missing,
|
||||||
|
test_check_images_missing_alt, test_check_images_with_alt, test_check_images_decorative,
|
||||||
|
test_check_headings_no_h1, test_check_headings_skip,
|
||||||
|
test_check_skip_nav_missing,
|
||||||
|
test_check_link_text_empty, test_check_link_text_generic,
|
||||||
|
test_run_programmatic_checks_full,
|
||||||
|
test_parse_json_clean, test_parse_json_fenced,
|
||||||
|
test_format_json, test_format_text,
|
||||||
|
]
|
||||||
|
passed = 0
|
||||||
|
failed = 0
|
||||||
|
for test in tests:
|
||||||
|
try:
|
||||||
|
test()
|
||||||
|
passed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" FAIL: {test.__name__} — {e}")
|
||||||
|
failed += 1
|
||||||
|
print(f"\n{'ALL PASSED' if failed == 0 else f'{failed} FAILED'}: {passed}/{len(tests)}")
|
||||||
|
return failed == 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(0 if run_all() else 1)
|
||||||
281
tests/test_glitch_detector.py
Normal file
281
tests/test_glitch_detector.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests for Matrix 3D Glitch Detector (timmy-config#491).
|
||||||
|
|
||||||
|
Covers: glitch_patterns, matrix_glitch_detector core logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Ensure bin/ is importable
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "bin"))
|
||||||
|
|
||||||
|
from glitch_patterns import (
|
||||||
|
GlitchCategory,
|
||||||
|
GlitchPattern,
|
||||||
|
GlitchSeverity,
|
||||||
|
MATRIX_GLITCH_PATTERNS,
|
||||||
|
build_vision_prompt,
|
||||||
|
get_pattern_by_category,
|
||||||
|
get_patterns_by_severity,
|
||||||
|
)
|
||||||
|
|
||||||
|
from matrix_glitch_detector import (
|
||||||
|
DetectedGlitch,
|
||||||
|
ScanResult,
|
||||||
|
_infer_severity,
|
||||||
|
_parse_vision_response,
|
||||||
|
build_report,
|
||||||
|
generate_scan_angles,
|
||||||
|
run_demo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlitchPatterns(unittest.TestCase):
|
||||||
|
"""Tests for glitch_patterns module."""
|
||||||
|
|
||||||
|
def test_pattern_count(self):
|
||||||
|
"""Verify we have a reasonable number of defined patterns."""
|
||||||
|
self.assertGreaterEqual(len(MATRIX_GLITCH_PATTERNS), 8)
|
||||||
|
|
||||||
|
def test_all_patterns_have_required_fields(self):
|
||||||
|
"""Every pattern must have category, name, description, severity, prompts."""
|
||||||
|
for p in MATRIX_GLITCH_PATTERNS:
|
||||||
|
self.assertIsInstance(p.category, GlitchCategory)
|
||||||
|
self.assertTrue(p.name)
|
||||||
|
self.assertTrue(p.description)
|
||||||
|
self.assertIsInstance(p.severity, GlitchSeverity)
|
||||||
|
self.assertGreater(len(p.detection_prompts), 0)
|
||||||
|
self.assertGreater(len(p.visual_indicators), 0)
|
||||||
|
self.assertGreater(p.confidence_threshold, 0)
|
||||||
|
self.assertLessEqual(p.confidence_threshold, 1.0)
|
||||||
|
|
||||||
|
def test_pattern_to_dict(self):
|
||||||
|
"""Pattern serialization should produce a dict with expected keys."""
|
||||||
|
p = MATRIX_GLITCH_PATTERNS[0]
|
||||||
|
d = p.to_dict()
|
||||||
|
self.assertIn("category", d)
|
||||||
|
self.assertIn("name", d)
|
||||||
|
self.assertIn("severity", d)
|
||||||
|
self.assertEqual(d["category"], p.category.value)
|
||||||
|
|
||||||
|
def test_get_patterns_by_severity(self):
|
||||||
|
"""Severity filter should return only patterns at or above threshold."""
|
||||||
|
high_patterns = get_patterns_by_severity(GlitchSeverity.HIGH)
|
||||||
|
self.assertTrue(all(p.severity.value in ("high", "critical") for p in high_patterns))
|
||||||
|
self.assertGreater(len(high_patterns), 0)
|
||||||
|
|
||||||
|
all_patterns = get_patterns_by_severity(GlitchSeverity.INFO)
|
||||||
|
self.assertEqual(len(all_patterns), len(MATRIX_GLITCH_PATTERNS))
|
||||||
|
|
||||||
|
def test_get_pattern_by_category(self):
|
||||||
|
"""Lookup by category should return the correct pattern."""
|
||||||
|
p = get_pattern_by_category(GlitchCategory.FLOATING_ASSETS)
|
||||||
|
self.assertIsNotNone(p)
|
||||||
|
self.assertEqual(p.category, GlitchCategory.FLOATING_ASSETS)
|
||||||
|
|
||||||
|
missing = get_pattern_by_category("nonexistent_category_value")
|
||||||
|
self.assertIsNone(missing)
|
||||||
|
|
||||||
|
def test_build_vision_prompt(self):
|
||||||
|
"""Vision prompt should contain pattern names and be non-trivial."""
|
||||||
|
prompt = build_vision_prompt()
|
||||||
|
self.assertGreater(len(prompt), 200)
|
||||||
|
self.assertIn("Floating Object", prompt)
|
||||||
|
self.assertIn("Z-Fighting", prompt)
|
||||||
|
self.assertIn("Missing", prompt)
|
||||||
|
|
||||||
|
def test_build_vision_prompt_subset(self):
|
||||||
|
"""Vision prompt with subset should only include specified patterns."""
|
||||||
|
subset = MATRIX_GLITCH_PATTERNS[:3]
|
||||||
|
prompt = build_vision_prompt(subset)
|
||||||
|
self.assertIn(subset[0].name, prompt)
|
||||||
|
self.assertNotIn(MATRIX_GLITCH_PATTERNS[-1].name, prompt)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlitchDetector(unittest.TestCase):
|
||||||
|
"""Tests for matrix_glitch_detector module."""
|
||||||
|
|
||||||
|
def test_generate_scan_angles_default(self):
|
||||||
|
"""Default 4 angles should return front, right, back, left."""
|
||||||
|
angles = generate_scan_angles(4)
|
||||||
|
self.assertEqual(len(angles), 4)
|
||||||
|
labels = [a["label"] for a in angles]
|
||||||
|
self.assertIn("front", labels)
|
||||||
|
self.assertIn("right", labels)
|
||||||
|
self.assertIn("back", labels)
|
||||||
|
self.assertIn("left", labels)
|
||||||
|
|
||||||
|
def test_generate_scan_angles_many(self):
|
||||||
|
"""Requesting more angles than base should still return correct count."""
|
||||||
|
angles = generate_scan_angles(12)
|
||||||
|
self.assertEqual(len(angles), 12)
|
||||||
|
# Should still have the standard ones
|
||||||
|
labels = [a["label"] for a in angles]
|
||||||
|
self.assertIn("front", labels)
|
||||||
|
|
||||||
|
def test_generate_scan_angles_few(self):
|
||||||
|
"""Requesting fewer angles should return fewer."""
|
||||||
|
angles = generate_scan_angles(2)
|
||||||
|
self.assertEqual(len(angles), 2)
|
||||||
|
|
||||||
|
def test_detected_glitch_dataclass(self):
|
||||||
|
"""DetectedGlitch should serialize cleanly."""
|
||||||
|
g = DetectedGlitch(
|
||||||
|
id="test001",
|
||||||
|
category="floating_assets",
|
||||||
|
name="Test Glitch",
|
||||||
|
description="A test glitch",
|
||||||
|
severity="high",
|
||||||
|
confidence=0.85,
|
||||||
|
location_x=50.0,
|
||||||
|
location_y=30.0,
|
||||||
|
screenshot_index=0,
|
||||||
|
screenshot_angle="front",
|
||||||
|
)
|
||||||
|
self.assertEqual(g.id, "test001")
|
||||||
|
self.assertTrue(g.timestamp) # Auto-generated
|
||||||
|
|
||||||
|
def test_infer_severity_critical(self):
|
||||||
|
"""Missing textures should infer critical/high severity."""
|
||||||
|
sev = _infer_severity("missing_textures", 0.9)
|
||||||
|
self.assertEqual(sev, "critical")
|
||||||
|
sev_low = _infer_severity("missing_textures", 0.5)
|
||||||
|
self.assertEqual(sev_low, "high")
|
||||||
|
|
||||||
|
def test_infer_severity_floating(self):
|
||||||
|
"""Floating assets should infer high/medium severity."""
|
||||||
|
sev = _infer_severity("floating_assets", 0.8)
|
||||||
|
self.assertEqual(sev, "high")
|
||||||
|
sev_low = _infer_severity("floating_assets", 0.5)
|
||||||
|
self.assertEqual(sev_low, "medium")
|
||||||
|
|
||||||
|
def test_infer_severity_default(self):
|
||||||
|
"""Unknown categories should default to medium/low."""
|
||||||
|
sev = _infer_severity("unknown_thing", 0.7)
|
||||||
|
self.assertEqual(sev, "medium")
|
||||||
|
sev_low = _infer_severity("unknown_thing", 0.3)
|
||||||
|
self.assertEqual(sev_low, "low")
|
||||||
|
|
||||||
|
def test_parse_vision_response_json_array(self):
|
||||||
|
"""Should parse a JSON array response."""
|
||||||
|
response = json.dumps([
|
||||||
|
{
|
||||||
|
"category": "floating_assets",
|
||||||
|
"name": "Float Test",
|
||||||
|
"description": "Chair floating",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"severity": "high",
|
||||||
|
"location_x": 40,
|
||||||
|
"location_y": 60,
|
||||||
|
}
|
||||||
|
])
|
||||||
|
glitches = _parse_vision_response(response, 0, "front")
|
||||||
|
self.assertEqual(len(glitches), 1)
|
||||||
|
self.assertEqual(glitches[0].category, "floating_assets")
|
||||||
|
self.assertAlmostEqual(glitches[0].confidence, 0.9)
|
||||||
|
|
||||||
|
def test_parse_vision_response_wrapped(self):
|
||||||
|
"""Should parse a response with 'glitches' wrapper key."""
|
||||||
|
response = json.dumps({
|
||||||
|
"glitches": [
|
||||||
|
{
|
||||||
|
"category": "z_fighting",
|
||||||
|
"name": "Shimmer",
|
||||||
|
"confidence": 0.6,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
glitches = _parse_vision_response(response, 1, "right")
|
||||||
|
self.assertEqual(len(glitches), 1)
|
||||||
|
self.assertEqual(glitches[0].category, "z_fighting")
|
||||||
|
|
||||||
|
def test_parse_vision_response_empty(self):
|
||||||
|
"""Should return empty list for non-JSON text."""
|
||||||
|
glitches = _parse_vision_response("No glitches found.", 0, "front")
|
||||||
|
self.assertEqual(len(glitches), 0)
|
||||||
|
|
||||||
|
def test_parse_vision_response_code_block(self):
|
||||||
|
"""Should extract JSON from markdown code blocks."""
|
||||||
|
response = '```json\n[{"category": "clipping", "name": "Clip", "confidence": 0.7}]\n```'
|
||||||
|
glitches = _parse_vision_response(response, 0, "front")
|
||||||
|
self.assertEqual(len(glitches), 1)
|
||||||
|
|
||||||
|
def test_build_report(self):
|
||||||
|
"""Report should have correct summary statistics."""
|
||||||
|
angles = generate_scan_angles(4)
|
||||||
|
screenshots = [Path(f"/tmp/ss_{i}.png") for i in range(4)]
|
||||||
|
glitches = [
|
||||||
|
DetectedGlitch(
|
||||||
|
id="a", category="floating_assets", name="Float",
|
||||||
|
description="", severity="high", confidence=0.8,
|
||||||
|
screenshot_index=0, screenshot_angle="front",
|
||||||
|
),
|
||||||
|
DetectedGlitch(
|
||||||
|
id="b", category="missing_textures", name="Missing",
|
||||||
|
description="", severity="critical", confidence=0.95,
|
||||||
|
screenshot_index=1, screenshot_angle="right",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
report = build_report("https://test.com", angles, screenshots, glitches)
|
||||||
|
|
||||||
|
self.assertEqual(report.total_screenshots, 4)
|
||||||
|
self.assertEqual(len(report.glitches), 2)
|
||||||
|
self.assertEqual(report.summary["total_glitches"], 2)
|
||||||
|
self.assertEqual(report.summary["by_severity"]["critical"], 1)
|
||||||
|
self.assertEqual(report.summary["by_severity"]["high"], 1)
|
||||||
|
self.assertEqual(report.summary["by_category"]["floating_assets"], 1)
|
||||||
|
self.assertEqual(report.metadata["reference"], "timmy-config#491")
|
||||||
|
|
||||||
|
def test_build_report_json_roundtrip(self):
|
||||||
|
"""Report JSON should parse back correctly."""
|
||||||
|
angles = generate_scan_angles(2)
|
||||||
|
screenshots = [Path(f"/tmp/ss_{i}.png") for i in range(2)]
|
||||||
|
report = build_report("https://test.com", angles, screenshots, [])
|
||||||
|
json_str = report.to_json()
|
||||||
|
parsed = json.loads(json_str)
|
||||||
|
self.assertEqual(parsed["url"], "https://test.com")
|
||||||
|
self.assertEqual(parsed["total_screenshots"], 2)
|
||||||
|
|
||||||
|
def test_run_demo(self):
|
||||||
|
"""Demo mode should produce a report with simulated glitches."""
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
||||||
|
output_path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
report = run_demo(output_path)
|
||||||
|
self.assertEqual(len(report.glitches), 4)
|
||||||
|
self.assertGreater(report.summary["total_glitches"], 0)
|
||||||
|
self.assertTrue(output_path.exists())
|
||||||
|
|
||||||
|
# Verify the saved JSON is valid
|
||||||
|
saved = json.loads(output_path.read_text())
|
||||||
|
self.assertIn("scan_id", saved)
|
||||||
|
self.assertIn("glitches", saved)
|
||||||
|
finally:
|
||||||
|
output_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntegration(unittest.TestCase):
|
||||||
|
"""Integration-level tests."""
|
||||||
|
|
||||||
|
def test_full_pipeline_demo(self):
|
||||||
|
"""End-to-end demo pipeline should complete without errors."""
|
||||||
|
report = run_demo()
|
||||||
|
self.assertIsNotNone(report.scan_id)
|
||||||
|
self.assertTrue(report.timestamp)
|
||||||
|
self.assertGreater(report.total_screenshots, 0)
|
||||||
|
|
||||||
|
def test_patterns_cover_matrix_themes(self):
|
||||||
|
"""Patterns should cover the main Matrix glitch themes."""
|
||||||
|
category_values = {p.category.value for p in MATRIX_GLITCH_PATTERNS}
|
||||||
|
expected = {"floating_assets", "z_fighting", "missing_textures", "clipping", "broken_normals"}
|
||||||
|
self.assertTrue(expected.issubset(category_values))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
43
tests/test_knowledge_base_ast.py
Normal file
43
tests/test_knowledge_base_ast.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||||
|
|
||||||
|
from knowledge_base import KnowledgeBase
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_python_file_extracts_ast_facts(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "demo_module.py"
|
||||||
|
source.write_text(
|
||||||
|
"import os\n"
|
||||||
|
"from pathlib import Path\n\n"
|
||||||
|
"CONSTANT = 7\n\n"
|
||||||
|
"def helper(x):\n"
|
||||||
|
" return x + 1\n\n"
|
||||||
|
"class Demo:\n"
|
||||||
|
" def method(self):\n"
|
||||||
|
" return helper(CONSTANT)\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
kb = KnowledgeBase()
|
||||||
|
facts = kb.ingest_python_file(source)
|
||||||
|
|
||||||
|
assert facts, "AST ingestion should add symbolic facts"
|
||||||
|
assert kb.query("defines_function", "demo_module", "helper") == [{}]
|
||||||
|
assert kb.query("defines_class", "demo_module", "Demo") == [{}]
|
||||||
|
assert kb.query("defines_method", "Demo", "method") == [{}]
|
||||||
|
assert kb.query("imports", "demo_module", "os") == [{}]
|
||||||
|
assert kb.query("imports", "demo_module", "pathlib.Path") == [{}]
|
||||||
|
assert kb.query("defines_constant", "demo_module", "CONSTANT") == [{}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_python_file_rejects_invalid_syntax(tmp_path: Path) -> None:
|
||||||
|
broken = tmp_path / "broken.py"
|
||||||
|
broken.write_text("def nope(:\n pass\n")
|
||||||
|
|
||||||
|
kb = KnowledgeBase()
|
||||||
|
try:
|
||||||
|
kb.ingest_python_file(broken)
|
||||||
|
except SyntaxError:
|
||||||
|
return
|
||||||
|
raise AssertionError("Expected SyntaxError for invalid Python source")
|
||||||
148
tests/test_matrix_glitch_detect.py
Normal file
148
tests/test_matrix_glitch_detect.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for matrix_glitch_detect.py — verifies detection and HTML report logic."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||||
|
|
||||||
|
from matrix_glitch_detect import (
|
||||||
|
Severity, Glitch, GlitchReport,
|
||||||
|
format_report, generate_html_report, _parse_json_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_json_clean():
|
||||||
|
result = _parse_json_response('{"glitches": [], "overall_quality": 95}')
|
||||||
|
assert result["overall_quality"] == 95
|
||||||
|
print(" PASS: test_parse_json_clean")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_json_fenced():
|
||||||
|
result = _parse_json_response('```json\n{"overall_quality": 80}\n```')
|
||||||
|
assert result["overall_quality"] == 80
|
||||||
|
print(" PASS: test_parse_json_fenced")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_json_garbage():
|
||||||
|
assert _parse_json_response("no json") == {}
|
||||||
|
print(" PASS: test_parse_json_garbage")
|
||||||
|
|
||||||
|
|
||||||
|
def test_glitch_dataclass():
|
||||||
|
g = Glitch(type="z_fighting", severity=Severity.MAJOR, region="center", description="Shimmer", confidence=0.8)
|
||||||
|
assert g.type == "z_fighting"
|
||||||
|
assert g.confidence == 0.8
|
||||||
|
print(" PASS: test_glitch_dataclass")
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_dataclass():
|
||||||
|
r = GlitchReport(source="test.png", status="WARN", score=75)
|
||||||
|
r.glitches.append(Glitch(type="float", severity=Severity.MINOR))
|
||||||
|
assert len(r.glitches) == 1
|
||||||
|
assert r.score == 75
|
||||||
|
print(" PASS: test_report_dataclass")
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_json():
|
||||||
|
r = GlitchReport(source="test.png", status="PASS", score=90, summary="Clean")
|
||||||
|
r.glitches.append(Glitch(type="cosmetic", severity=Severity.COSMETIC, description="Minor"))
|
||||||
|
output = format_report(r, "json")
|
||||||
|
parsed = json.loads(output)
|
||||||
|
assert parsed["status"] == "PASS"
|
||||||
|
assert len(parsed["glitches"]) == 1
|
||||||
|
print(" PASS: test_format_json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_text():
|
||||||
|
r = GlitchReport(source="test.png", status="FAIL", score=30, summary="Critical glitch")
|
||||||
|
r.glitches.append(Glitch(type="render_failure", severity=Severity.CRITICAL, description="Black screen"))
|
||||||
|
output = format_report(r, "text")
|
||||||
|
assert "FAIL" in output
|
||||||
|
assert "render_failure" in output
|
||||||
|
print(" PASS: test_format_text")
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_report_basic():
|
||||||
|
r = GlitchReport(source="test.png", status="PASS", score=100)
|
||||||
|
html = generate_html_report([r], title="Test Report")
|
||||||
|
assert "<!DOCTYPE html>" in html
|
||||||
|
assert "Test Report" in html
|
||||||
|
assert "PASS" in html
|
||||||
|
assert "100" in html
|
||||||
|
print(" PASS: test_html_report_basic")
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_report_with_glitches():
|
||||||
|
r = GlitchReport(source="test.png", status="FAIL", score=40)
|
||||||
|
r.glitches.append(Glitch(type="z_fighting", severity=Severity.CRITICAL, region="center", description="Heavy flicker", confidence=0.9))
|
||||||
|
r.glitches.append(Glitch(type="clipping", severity=Severity.MINOR, region="bottom", description="Object through floor", confidence=0.6))
|
||||||
|
html = generate_html_report([r], title="Glitch Report")
|
||||||
|
assert "z_fighting" in html
|
||||||
|
assert "CRITICAL" in html
|
||||||
|
assert "clipping" in html
|
||||||
|
assert "Heavy flicker" in html
|
||||||
|
print(" PASS: test_html_report_with_glitches")
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_report_multi():
|
||||||
|
r1 = GlitchReport(source="a.png", status="PASS", score=95)
|
||||||
|
r2 = GlitchReport(source="b.png", status="WARN", score=70)
|
||||||
|
r2.glitches.append(Glitch(type="texture_pop", severity=Severity.MAJOR))
|
||||||
|
html = generate_html_report([r1, r2])
|
||||||
|
assert "a.png" in html
|
||||||
|
assert "b.png" in html
|
||||||
|
assert "2" in html # 2 screenshots
|
||||||
|
print(" PASS: test_html_report_multi")
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_self_contained():
|
||||||
|
r = GlitchReport(source="test.png", status="PASS", score=100)
|
||||||
|
html = generate_html_report([r])
|
||||||
|
assert "external" not in html.lower() or "no external dependencies" in html.lower()
|
||||||
|
assert "<style>" in html # Inline CSS
|
||||||
|
print(" PASS: test_html_self_contained")
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_image():
|
||||||
|
r = GlitchReport(source="/nonexistent/image.png")
|
||||||
|
# detect_glitches would set FAIL — simulate
|
||||||
|
r.status = "FAIL"
|
||||||
|
r.score = 0
|
||||||
|
r.summary = "File not found"
|
||||||
|
assert r.status == "FAIL"
|
||||||
|
print(" PASS: test_missing_image")
|
||||||
|
|
||||||
|
|
||||||
|
def test_severity_enum():
|
||||||
|
assert Severity.CRITICAL.value == "critical"
|
||||||
|
assert Severity.MAJOR.value == "major"
|
||||||
|
print(" PASS: test_severity_enum")
|
||||||
|
|
||||||
|
|
||||||
|
def run_all():
|
||||||
|
print("=== matrix_glitch_detect tests ===")
|
||||||
|
tests = [
|
||||||
|
test_parse_json_clean, test_parse_json_fenced, test_parse_json_garbage,
|
||||||
|
test_glitch_dataclass, test_report_dataclass,
|
||||||
|
test_format_json, test_format_text,
|
||||||
|
test_html_report_basic, test_html_report_with_glitches,
|
||||||
|
test_html_report_multi, test_html_self_contained,
|
||||||
|
test_missing_image, test_severity_enum,
|
||||||
|
]
|
||||||
|
passed = failed = 0
|
||||||
|
for t in tests:
|
||||||
|
try:
|
||||||
|
t()
|
||||||
|
passed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" FAIL: {t.__name__} — {e}")
|
||||||
|
failed += 1
|
||||||
|
print(f"\n{'ALL PASSED' if failed == 0 else f'{failed} FAILED'}: {passed}/{len(tests)}")
|
||||||
|
return failed == 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(0 if run_all() else 1)
|
||||||
123
tests/test_nexus_smoke_test.py
Normal file
123
tests/test_nexus_smoke_test.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for nexus_smoke_test.py — verifies smoke test logic."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||||
|
|
||||||
|
from nexus_smoke_test import (
|
||||||
|
Severity, SmokeCheck, SmokeResult,
|
||||||
|
format_result, _parse_json_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_json_clean():
|
||||||
|
result = _parse_json_response('{"status": "PASS", "summary": "ok"}')
|
||||||
|
assert result["status"] == "PASS"
|
||||||
|
print(" PASS: test_parse_json_clean")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_json_fenced():
|
||||||
|
result = _parse_json_response('```json\n{"status": "FAIL"}\n```')
|
||||||
|
assert result["status"] == "FAIL"
|
||||||
|
print(" PASS: test_parse_json_fenced")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_json_garbage():
|
||||||
|
result = _parse_json_response("no json here")
|
||||||
|
assert result == {}
|
||||||
|
print(" PASS: test_parse_json_garbage")
|
||||||
|
|
||||||
|
|
||||||
|
def test_smoke_check_dataclass():
|
||||||
|
c = SmokeCheck(name="Test", status=Severity.PASS, message="All good")
|
||||||
|
assert c.name == "Test"
|
||||||
|
assert c.status == Severity.PASS
|
||||||
|
print(" PASS: test_smoke_check_dataclass")
|
||||||
|
|
||||||
|
|
||||||
|
def test_smoke_result_dataclass():
|
||||||
|
r = SmokeResult(url="https://example.com", status=Severity.PASS)
|
||||||
|
r.checks.append(SmokeCheck(name="Page Loads", status=Severity.PASS))
|
||||||
|
assert len(r.checks) == 1
|
||||||
|
assert r.url == "https://example.com"
|
||||||
|
print(" PASS: test_smoke_result_dataclass")
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_json():
|
||||||
|
r = SmokeResult(url="https://test.com", status=Severity.PASS, summary="All good", duration_ms=100)
|
||||||
|
r.checks.append(SmokeCheck(name="Test", status=Severity.PASS, message="OK"))
|
||||||
|
output = format_result(r, "json")
|
||||||
|
parsed = json.loads(output)
|
||||||
|
assert parsed["status"] == "pass"
|
||||||
|
assert parsed["url"] == "https://test.com"
|
||||||
|
assert len(parsed["checks"]) == 1
|
||||||
|
print(" PASS: test_format_json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_text():
|
||||||
|
r = SmokeResult(url="https://test.com", status=Severity.WARN, summary="1 warning", duration_ms=200)
|
||||||
|
r.checks.append(SmokeCheck(name="Screenshot", status=Severity.WARN, message="No backend"))
|
||||||
|
output = format_result(r, "text")
|
||||||
|
assert "NEXUS VISUAL SMOKE TEST" in output
|
||||||
|
assert "https://test.com" in output
|
||||||
|
assert "WARN" in output
|
||||||
|
print(" PASS: test_format_text")
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_text_pass():
|
||||||
|
r = SmokeResult(url="https://test.com", status=Severity.PASS, summary="All clear")
|
||||||
|
r.checks.append(SmokeCheck(name="Page Loads", status=Severity.PASS, message="HTTP 200"))
|
||||||
|
r.checks.append(SmokeCheck(name="HTML Content", status=Severity.PASS, message="Valid"))
|
||||||
|
output = format_result(r, "text")
|
||||||
|
assert "✅" in output
|
||||||
|
assert "Page Loads" in output
|
||||||
|
print(" PASS: test_format_text")
|
||||||
|
|
||||||
|
|
||||||
|
def test_severity_enum():
|
||||||
|
assert Severity.PASS.value == "pass"
|
||||||
|
assert Severity.FAIL.value == "fail"
|
||||||
|
assert Severity.WARN.value == "warn"
|
||||||
|
print(" PASS: test_severity_enum")
|
||||||
|
|
||||||
|
|
||||||
|
def test_overall_status_logic():
|
||||||
|
# All pass
|
||||||
|
r = SmokeResult()
|
||||||
|
r.checks = [SmokeCheck(name="a", status=Severity.PASS), SmokeCheck(name="b", status=Severity.PASS)]
|
||||||
|
fails = sum(1 for c in r.checks if c.status == Severity.FAIL)
|
||||||
|
warns = sum(1 for c in r.checks if c.status == Severity.WARN)
|
||||||
|
assert fails == 0 and warns == 0
|
||||||
|
|
||||||
|
# One fail
|
||||||
|
r.checks.append(SmokeCheck(name="c", status=Severity.FAIL))
|
||||||
|
fails = sum(1 for c in r.checks if c.status == Severity.FAIL)
|
||||||
|
assert fails == 1
|
||||||
|
print(" PASS: test_overall_status_logic")
|
||||||
|
|
||||||
|
|
||||||
|
def run_all():
|
||||||
|
print("=== nexus_smoke_test tests ===")
|
||||||
|
tests = [
|
||||||
|
test_parse_json_clean, test_parse_json_fenced, test_parse_json_garbage,
|
||||||
|
test_smoke_check_dataclass, test_smoke_result_dataclass,
|
||||||
|
test_format_json, test_format_text, test_format_text_pass,
|
||||||
|
test_severity_enum, test_overall_status_logic,
|
||||||
|
]
|
||||||
|
passed = failed = 0
|
||||||
|
for t in tests:
|
||||||
|
try:
|
||||||
|
t()
|
||||||
|
passed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" FAIL: {t.__name__} — {e}")
|
||||||
|
failed += 1
|
||||||
|
print(f"\n{'ALL PASSED' if failed == 0 else f'{failed} FAILED'}: {passed}/{len(tests)}")
|
||||||
|
return failed == 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(0 if run_all() else 1)
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
@@ -60,3 +61,35 @@ class TestMainCli:
|
|||||||
|
|
||||||
healer_cls.assert_called_once_with(dry_run=False, confirm_kill=True, yes=True)
|
healer_cls.assert_called_once_with(dry_run=False, confirm_kill=True, yes=True)
|
||||||
healer.run.assert_called_once_with()
|
healer.run.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_real_default_dry_run_path_completes(self, monkeypatch, capsys):
|
||||||
|
class FakeExecutor:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def run_script(self, host, command, *, local=False, timeout=None):
|
||||||
|
self.calls.append((host, command, local, timeout))
|
||||||
|
if command.startswith("df -h /"):
|
||||||
|
return subprocess.CompletedProcess(command, 0, stdout="42\n", stderr="")
|
||||||
|
if command.startswith("free -m"):
|
||||||
|
return subprocess.CompletedProcess(command, 0, stdout="12.5\n", stderr="")
|
||||||
|
if command.startswith("ps aux"):
|
||||||
|
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
|
||||||
|
raise AssertionError(f"unexpected command: {command}")
|
||||||
|
|
||||||
|
fake_executor = FakeExecutor()
|
||||||
|
monkeypatch.setattr(sh, "FLEET", {"mac": {"ip": "127.0.0.1", "port": 8080}})
|
||||||
|
monkeypatch.setattr(sh.requests, "get", lambda url, timeout: object())
|
||||||
|
monkeypatch.setattr(sh, "VerifiedSSHExecutor", lambda: fake_executor)
|
||||||
|
|
||||||
|
sh.main([])
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Starting self-healing cycle (DRY-RUN mode)." in out
|
||||||
|
assert "Auditing mac..." in out
|
||||||
|
assert "Cycle complete." in out
|
||||||
|
assert fake_executor.calls == [
|
||||||
|
("127.0.0.1", "df -h / | tail -1 | awk '{print $5}' | sed 's/%//'", True, 15),
|
||||||
|
("127.0.0.1", "free -m | awk '/^Mem:/{print $3/$2 * 100}'", True, 15),
|
||||||
|
("127.0.0.1", "ps aux --sort=-%cpu | awk 'NR>1 && $3>80 {print $2, $11, $3}'", True, 15),
|
||||||
|
]
|
||||||
|
|||||||
215
tests/test_tower_visual_mapper.py
Normal file
215
tests/test_tower_visual_mapper.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for tower_visual_mapper.py — verifies map construction and formatting."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||||
|
|
||||||
|
from tower_visual_mapper import (
|
||||||
|
TowerRoom, TowerNPC, TowerFloor, TowerMap,
|
||||||
|
scan_gallery_index, scan_memory_architecture, scan_wizard_configs,
|
||||||
|
build_tower_map, to_json, to_ascii, _gallery_image_to_room,
|
||||||
|
_parse_json_response
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# === Unit Tests ===
|
||||||
|
|
||||||
|
def test_gallery_image_to_room_known():
|
||||||
|
room = _gallery_image_to_room("01-wizard-tower-bitcoin.jpg", "The Tower", "The Origin")
|
||||||
|
assert room is not None
|
||||||
|
assert room.name == "The Tower — Exterior"
|
||||||
|
assert room.floor == 0
|
||||||
|
assert "bitcoin" in room.description.lower() or "sovereign" in room.description.lower()
|
||||||
|
print(" PASS: test_gallery_image_to_room_known")
|
||||||
|
|
||||||
|
|
||||||
|
def test_gallery_image_to_room_unknown():
|
||||||
|
room = _gallery_image_to_room("random-image.jpg", "Something", "The Origin")
|
||||||
|
assert room is None
|
||||||
|
print(" PASS: test_gallery_image_to_room_unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def test_gallery_image_to_room_philosophy():
|
||||||
|
room = _gallery_image_to_room("06-the-paperclip-moment.jpg", "A paperclip", "The Philosophy")
|
||||||
|
assert room is not None
|
||||||
|
assert room.category == "philosophy"
|
||||||
|
print(" PASS: test_gallery_image_to_room_philosophy")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_json_response_clean():
|
||||||
|
text = '{"floors": 5, "rooms": [{"name": "Test"}]}'
|
||||||
|
result = _parse_json_response(text)
|
||||||
|
assert result["floors"] == 5
|
||||||
|
assert result["rooms"][0]["name"] == "Test"
|
||||||
|
print(" PASS: test_parse_json_response_clean")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_json_response_fenced():
|
||||||
|
text = '```json\n{"floors": 3}\n```'
|
||||||
|
result = _parse_json_response(text)
|
||||||
|
assert result["floors"] == 3
|
||||||
|
print(" PASS: test_parse_json_response_fenced")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_json_response_garbage():
|
||||||
|
result = _parse_json_response("no json here at all")
|
||||||
|
assert result == {}
|
||||||
|
print(" PASS: test_parse_json_response_garbage")
|
||||||
|
|
||||||
|
|
||||||
|
def test_tower_map_structure():
|
||||||
|
tower = TowerMap()
|
||||||
|
tower.rooms = [
|
||||||
|
TowerRoom(name="Room A", floor=0, category="test"),
|
||||||
|
TowerRoom(name="Room B", floor=0, category="test"),
|
||||||
|
TowerRoom(name="Room C", floor=1, category="other"),
|
||||||
|
]
|
||||||
|
tower.npcs = [
|
||||||
|
TowerNPC(name="NPC1", role="guard", location="Room A"),
|
||||||
|
]
|
||||||
|
|
||||||
|
output = json.loads(to_json(tower))
|
||||||
|
assert output["name"] == "The Tower"
|
||||||
|
assert output["stats"]["total_rooms"] == 3
|
||||||
|
assert output["stats"]["total_npcs"] == 1
|
||||||
|
print(" PASS: test_tower_map_structure")
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_json():
|
||||||
|
tower = TowerMap()
|
||||||
|
tower.rooms = [TowerRoom(name="Test Room", floor=1)]
|
||||||
|
output = json.loads(to_json(tower))
|
||||||
|
assert output["rooms"][0]["name"] == "Test Room"
|
||||||
|
assert output["rooms"][0]["floor"] == 1
|
||||||
|
print(" PASS: test_to_json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_ascii():
|
||||||
|
tower = TowerMap()
|
||||||
|
tower.floors = [TowerFloor(number=0, name="Ground", rooms=["Test Room"])]
|
||||||
|
tower.rooms = [TowerRoom(name="Test Room", floor=0, description="A test")]
|
||||||
|
tower.npcs = []
|
||||||
|
tower.connections = []
|
||||||
|
|
||||||
|
output = to_ascii(tower)
|
||||||
|
assert "THE TOWER" in output
|
||||||
|
assert "Test Room" in output
|
||||||
|
assert "FLOOR 0" in output
|
||||||
|
print(" PASS: test_to_ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_ascii_with_npcs():
|
||||||
|
tower = TowerMap()
|
||||||
|
tower.floors = [TowerFloor(number=0, name="Ground", rooms=["The Forge"])]
|
||||||
|
tower.rooms = [TowerRoom(name="The Forge", floor=0, occupants=["Bezalel"])]
|
||||||
|
tower.npcs = [TowerNPC(name="Bezalel", role="Builder", location="The Forge")]
|
||||||
|
|
||||||
|
output = to_ascii(tower)
|
||||||
|
assert "Bezalel" in output
|
||||||
|
print(" PASS: test_to_ascii_with_npcs")
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_gallery_index(tmp_path):
|
||||||
|
# Create mock gallery
|
||||||
|
gallery = tmp_path / "grok-imagine-gallery"
|
||||||
|
gallery.mkdir()
|
||||||
|
index = gallery / "INDEX.md"
|
||||||
|
index.write_text("""# Gallery
|
||||||
|
### The Origin
|
||||||
|
| 01 | wizard-tower-bitcoin.jpg | The Tower, sovereign |
|
||||||
|
| 02 | soul-inscription.jpg | SOUL.md glowing |
|
||||||
|
### The Philosophy
|
||||||
|
| 05 | value-drift-battle.jpg | Blue vs red ships |
|
||||||
|
""")
|
||||||
|
rooms = scan_gallery_index(tmp_path)
|
||||||
|
assert len(rooms) >= 2
|
||||||
|
names = [r.name for r in rooms]
|
||||||
|
assert any("Tower" in n for n in names)
|
||||||
|
assert any("Inscription" in n for n in names)
|
||||||
|
print(" PASS: test_scan_gallery_index")
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_wizard_configs(tmp_path):
|
||||||
|
wizards = tmp_path / "wizards"
|
||||||
|
for name in ["timmy", "bezalel", "ezra"]:
|
||||||
|
wdir = wizards / name
|
||||||
|
wdir.mkdir(parents=True)
|
||||||
|
(wdir / "config.yaml").write_text("model: test\n")
|
||||||
|
|
||||||
|
npcs = scan_wizard_configs(tmp_path)
|
||||||
|
assert len(npcs) >= 3
|
||||||
|
names = [n.name for n in npcs]
|
||||||
|
assert any("Timmy" in n for n in names)
|
||||||
|
assert any("Bezalel" in n for n in names)
|
||||||
|
print(" PASS: test_scan_wizard_configs")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_tower_map_empty(tmp_path):
|
||||||
|
tower = build_tower_map(tmp_path, include_vision=False)
|
||||||
|
assert tower.name == "The Tower"
|
||||||
|
# Should still have palace rooms from MEMORY_ARCHITECTURE (won't exist in tmp, but that's fine)
|
||||||
|
assert isinstance(tower.rooms, list)
|
||||||
|
print(" PASS: test_build_tower_map_empty")
|
||||||
|
|
||||||
|
|
||||||
|
def test_room_deduplication():
|
||||||
|
tower = TowerMap()
|
||||||
|
tower.rooms = [
|
||||||
|
TowerRoom(name="Dup Room", floor=0),
|
||||||
|
TowerRoom(name="Dup Room", floor=1), # same name, different floor
|
||||||
|
TowerRoom(name="Unique Room", floor=0),
|
||||||
|
]
|
||||||
|
# Deduplicate in build_tower_map — simulate
|
||||||
|
seen = {}
|
||||||
|
deduped = []
|
||||||
|
for room in tower.rooms:
|
||||||
|
if room.name not in seen:
|
||||||
|
seen[room.name] = True
|
||||||
|
deduped.append(room)
|
||||||
|
assert len(deduped) == 2
|
||||||
|
print(" PASS: test_room_deduplication")
|
||||||
|
|
||||||
|
|
||||||
|
def run_all():
|
||||||
|
print("=== tower_visual_mapper tests ===")
|
||||||
|
tests = [
|
||||||
|
test_gallery_image_to_room_known,
|
||||||
|
test_gallery_image_to_room_unknown,
|
||||||
|
test_gallery_image_to_room_philosophy,
|
||||||
|
test_parse_json_response_clean,
|
||||||
|
test_parse_json_response_fenced,
|
||||||
|
test_parse_json_response_garbage,
|
||||||
|
test_tower_map_structure,
|
||||||
|
test_to_json,
|
||||||
|
test_to_ascii,
|
||||||
|
test_to_ascii_with_npcs,
|
||||||
|
test_scan_gallery_index,
|
||||||
|
test_scan_wizard_configs,
|
||||||
|
test_build_tower_map_empty,
|
||||||
|
test_room_deduplication,
|
||||||
|
]
|
||||||
|
passed = 0
|
||||||
|
failed = 0
|
||||||
|
for test in tests:
|
||||||
|
try:
|
||||||
|
if "tmp_path" in test.__code__.co_varnames:
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
test(Path(td))
|
||||||
|
else:
|
||||||
|
test()
|
||||||
|
passed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" FAIL: {test.__name__} — {e}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
print(f"\n{'ALL PASSED' if failed == 0 else f'{failed} FAILED'}: {passed}/{len(tests)}")
|
||||||
|
return failed == 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(0 if run_all() else 1)
|
||||||
100
training-data/scene-descriptions-rock.jsonl
Normal file
100
training-data/scene-descriptions-rock.jsonl
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
{"song": "Thunder Road", "artist": "Heartland", "beat": 1, "timestamp": "0:00", "duration": "30s", "lyric_line": "The screen door slams, Mary's dress waves", "scene": {"mood": "hope", "colors": ["gold", "sky blue", "white"], "composition": "wide shot", "camera": "static", "description": "Open horizon. Golden light breaking through clouds. The figure silhouetted against dawn. The screen door slams, Mary's dress waves"}}
|
||||||
|
{"song": "Thunder Road", "artist": "Heartland", "beat": 2, "timestamp": "0:30", "duration": "30s", "lyric_line": "Like a vision she dances across the porch as the radio plays", "scene": {"mood": "anticipation", "colors": ["silver", "pale green", "cream"], "composition": "close-up", "camera": "slow pan", "description": "Close on hands gripping a steering wheel. Dashboard lights reflecting in eyes. Road stretching ahead. Like a vision she dances across the porch as the radio plays"}}
|
||||||
|
{"song": "Thunder Road", "artist": "Heartland", "beat": 3, "timestamp": "1:00", "duration": "30s", "lyric_line": "Roy Orbison singing for the lonely, hey that's me and I want you only", "scene": {"mood": "energy", "colors": ["red", "orange", "electric blue"], "composition": "over the shoulder", "camera": "dolly in", "description": "Rapid cuts. Bodies in motion. Light streaks across the frame. Roy Orbison singing for the lonely, hey that's me and I want you only"}}
|
||||||
|
{"song": "Thunder Road", "artist": "Heartland", "beat": 4, "timestamp": "1:30", "duration": "30s", "lyric_line": "Don't turn me home out now I'm so young and worthless still", "scene": {"mood": "triumph", "colors": ["gold", "crimson", "white"], "composition": "low angle", "camera": "dolly out", "description": "Wide shot. Figure standing on a hilltop. Arms raised. City lights below. Don't turn me home out now I'm so young and worthless still"}}
|
||||||
|
{"song": "Thunder Road", "artist": "Heartland", "beat": 5, "timestamp": "2:00", "duration": "30s", "lyric_line": "The night's busting open these two lanes will take us anywhere", "scene": {"mood": "nostalgia", "colors": ["amber", "sepia", "dusty rose"], "composition": "high angle", "camera": "handheld", "description": "Sepia tones. A photograph come to life. Dust motes in afternoon light. The night's busting open these two lanes will take us anywhere"}}
|
||||||
|
{"song": "Thunder Road", "artist": "Heartland", "beat": 6, "timestamp": "2:30", "duration": "30s", "lyric_line": "We got one last chance to make it real", "scene": {"mood": "urgency", "colors": ["red", "black", "strobe white"], "composition": "dutch angle", "camera": "steadicam", "description": "Handheld camera running. Blurred faces. Traffic. Heartbeat sound design. We got one last chance to make it real"}}
|
||||||
|
{"song": "Thunder Road", "artist": "Heartland", "beat": 7, "timestamp": "3:00", "duration": "30s", "lyric_line": "To trade in these wings on some wheels", "scene": {"mood": "passion", "colors": ["deep red", "burgundy", "gold"], "composition": "symmetrical", "camera": "slow zoom", "description": "Extreme close-up. Skin. Breath visible in cold air. Eyes locked. To trade in these wings on some wheels"}}
|
||||||
|
{"song": "Thunder Road", "artist": "Heartland", "beat": 8, "timestamp": "3:30", "duration": "30s", "lyric_line": "Climb in back, heaven's waiting down the tracks", "scene": {"mood": "defiance", "colors": ["black", "neon green", "chrome"], "composition": "rule of thirds", "camera": "crane up", "description": "Low angle. Figure standing against the wind. Debris flying past. Unmoved. Climb in back, heaven's waiting down the tracks"}}
|
||||||
|
{"song": "Thunder Road", "artist": "Heartland", "beat": 9, "timestamp": "4:00", "duration": "30s", "lyric_line": "Oh oh oh oh oh oh oh", "scene": {"mood": "release", "colors": ["sky blue", "white", "pale gold"], "composition": "extreme wide", "camera": "tracking shot", "description": "Slow motion. Something falling \u2014 a mask, a chain, a weight. Lightness follows. Oh oh oh oh oh oh oh"}}
|
||||||
|
{"song": "Thunder Road", "artist": "Heartland", "beat": 10, "timestamp": "4:30", "duration": "30s", "lyric_line": "It's a town full of losers and I'm pulling out of here to win", "scene": {"mood": "catharsis", "colors": ["all white", "silver", "clear"], "composition": "medium shot", "camera": "slow tilt down", "description": "White space expanding. Figure dissolving into light. Peace in the dissolution. It's a town full of losers and I'm pulling out of here to win"}}
|
||||||
|
{"song": "Black Dog Howl", "artist": "Rust & Wire", "beat": 1, "timestamp": "0:00", "duration": "30s", "lyric_line": "Woke up on the floor again, whiskey still on my tongue", "scene": {"mood": "despair", "colors": ["navy", "black", "grey"], "composition": "wide shot", "camera": "static", "description": "Empty room. Single light source. Figure curled in corner. Rain on windows. Woke up on the floor again, whiskey still on my tongue"}}
|
||||||
|
{"song": "Black Dog Howl", "artist": "Rust & Wire", "beat": 2, "timestamp": "0:30", "duration": "30s", "lyric_line": "The mirror shows a stranger and the damage that I've done", "scene": {"mood": "anger", "colors": ["red", "black", "orange"], "composition": "close-up", "camera": "slow pan", "description": "Shattered glass. Red light. Hands clenched. Jaw tight. The frame vibrates. The mirror shows a stranger and the damage that I've done"}}
|
||||||
|
{"song": "Black Dog Howl", "artist": "Rust & Wire", "beat": 3, "timestamp": "1:00", "duration": "30s", "lyric_line": "I scream until my throat bleeds but nobody comes", "scene": {"mood": "frenzy", "colors": ["strobe", "red", "white flash"], "composition": "over the shoulder", "camera": "dolly in", "description": "Strobe lighting. Multiple exposures. Bodies colliding. Chaos as composition. I scream until my throat bleeds but nobody comes"}}
|
||||||
|
{"song": "Black Dog Howl", "artist": "Rust & Wire", "beat": 4, "timestamp": "1:30", "duration": "30s", "lyric_line": "The walls are closing in again, the ceiling pressing down", "scene": {"mood": "exhaustion", "colors": ["grey", "brown", "faded"], "composition": "low angle", "camera": "dolly out", "description": "Static shot. Figure slumped. Eyes half-closed. Time passing in shadows. The walls are closing in again, the ceiling pressing down"}}
|
||||||
|
{"song": "Black Dog Howl", "artist": "Rust & Wire", "beat": 5, "timestamp": "2:00", "duration": "30s", "lyric_line": "I tried to call your number but you changed it years ago", "scene": {"mood": "resignation", "colors": ["grey", "muted blue", "beige"], "composition": "high angle", "camera": "handheld", "description": "Medium shot. Hands dropping keys on a table. Turning away. Not looking back. I tried to call your number but you changed it years ago"}}
|
||||||
|
{"song": "Black Dog Howl", "artist": "Rust & Wire", "beat": 6, "timestamp": "2:30", "duration": "30s", "lyric_line": "Now I'm howling at the moon like some rabid dog I know", "scene": {"mood": "grief", "colors": ["deep purple", "black", "silver"], "composition": "dutch angle", "camera": "steadicam", "description": "Wide shot. Figure alone in vast space. Dark purple sky. No horizon line. Now I'm howling at the moon like some rabid dog I know"}}
|
||||||
|
{"song": "Black Dog Howl", "artist": "Rust & Wire", "beat": 7, "timestamp": "3:00", "duration": "30s", "lyric_line": "Every bone remembers what my mind wants to forget", "scene": {"mood": "numbness", "colors": ["white", "grey", "no color"], "composition": "symmetrical", "camera": "slow zoom", "description": "Desaturated. Figure staring at nothing. World moving around them in blur. Every bone remembers what my mind wants to forget"}}
|
||||||
|
{"song": "Black Dog Howl", "artist": "Rust & Wire", "beat": 8, "timestamp": "3:30", "duration": "30s", "lyric_line": "I'll tear this whole house down before the sun comes up", "scene": {"mood": "rage", "colors": ["fire red", "black", "ember orange"], "composition": "rule of thirds", "camera": "crane up", "description": "Red wash. Extreme close-up on eyes. Fire reflected in pupils. I'll tear this whole house down before the sun comes up"}}
|
||||||
|
{"song": "Black Dog Howl", "artist": "Rust & Wire", "beat": 9, "timestamp": "4:00", "duration": "30s", "lyric_line": "Ash and ruin everywhere, this is all that's left", "scene": {"mood": "acceptance", "colors": ["soft blue", "warm grey", "sage"], "composition": "extreme wide", "camera": "tracking shot", "description": "Soft focus. Gentle light. Figure breathing. The camera doesn't judge. Ash and ruin everywhere, this is all that's left"}}
|
||||||
|
{"song": "Black Dog Howl", "artist": "Rust & Wire", "beat": 10, "timestamp": "4:30", "duration": "30s", "lyric_line": "Silence. Just the wind through broken glass.", "scene": {"mood": "silence", "colors": ["black", "void", "faint starlight"], "composition": "medium shot", "camera": "slow tilt down", "description": "Black screen. Faint starlight. The sound drops out completely. Silence. Just the wind through broken glass."}}
|
||||||
|
{"song": "Satellite Hearts", "artist": "Neon Circuit", "beat": 1, "timestamp": "0:00", "duration": "30s", "lyric_line": "Ten thousand miles of static between your voice and mine", "scene": {"mood": "wonder", "colors": ["aurora green", "violet", "silver"], "composition": "wide shot", "camera": "static", "description": "Northern lights overhead. Figure looking up. Mouth open. Child's expression. Ten thousand miles of static between your voice and mine"}}
|
||||||
|
{"song": "Satellite Hearts", "artist": "Neon Circuit", "beat": 2, "timestamp": "0:30", "duration": "30s", "lyric_line": "I trace your constellation on the dashboard every night", "scene": {"mood": "isolation", "colors": ["cold blue", "black", "distant starlight"], "composition": "close-up", "camera": "slow pan", "description": "Extreme wide. Single figure. Vast empty landscape. Scale crushing. I trace your constellation on the dashboard every night"}}
|
||||||
|
{"song": "Satellite Hearts", "artist": "Neon Circuit", "beat": 3, "timestamp": "1:00", "duration": "30s", "lyric_line": "The signal fades to nothing but I keep the frequency", "scene": {"mood": "longing", "colors": ["teal", "silver", "moonlight"], "composition": "over the shoulder", "camera": "dolly in", "description": "Through a window. Figure on the other side. Glass between. Breath on the pane. The signal fades to nothing but I keep the frequency"}}
|
||||||
|
{"song": "Satellite Hearts", "artist": "Neon Circuit", "beat": 4, "timestamp": "1:30", "duration": "30s", "lyric_line": "Then suddenly your laughter breaks through like a summer storm", "scene": {"mood": "connection", "colors": ["warm gold", "rose", "blush"], "composition": "low angle", "camera": "dolly out", "description": "Two hands reaching. Fingers almost touching. Warm light between them. Then suddenly your laughter breaks through like a summer storm"}}
|
||||||
|
{"song": "Satellite Hearts", "artist": "Neon Circuit", "beat": 5, "timestamp": "2:00", "duration": "30s", "lyric_line": "We're dancing in the data stream, our pixels intertwined", "scene": {"mood": "euphoria", "colors": ["neon", "rainbow", "white flash"], "composition": "high angle", "camera": "handheld", "description": "Overexposed. Everything bright. Dancing. The frame can't contain the joy. We're dancing in the data stream, our pixels intertwined"}}
|
||||||
|
{"song": "Satellite Hearts", "artist": "Neon Circuit", "beat": 6, "timestamp": "2:30", "duration": "30s", "lyric_line": "But I can't tell if you're real or just a ghost in the machine", "scene": {"mood": "confusion", "colors": ["swirling", "unsettled", "green-grey"], "composition": "dutch angle", "camera": "steadicam", "description": "Multiple focal points. Nothing sharp. The viewer doesn't know where to look. But I can't tell if you're real or just a ghost in the machine"}}
|
||||||
|
{"song": "Satellite Hearts", "artist": "Neon Circuit", "beat": 7, "timestamp": "3:00", "duration": "30s", "lyric_line": "The picture clears and there you are \u2014 imperfect, warm, alive", "scene": {"mood": "clarity", "colors": ["clear blue", "white", "crisp"], "composition": "symmetrical", "camera": "slow zoom", "description": "Rack focus. Background blurs, foreground sharpens. Suddenly everything makes sense. The picture clears and there you are \u2014 imperfect, warm, alive"}}
|
||||||
|
{"song": "Satellite Hearts", "artist": "Neon Circuit", "beat": 8, "timestamp": "3:30", "duration": "30s", "lyric_line": "Your hand reaches through the screen, I swear I feel the heat", "scene": {"mood": "tenderness", "colors": ["blush pink", "warm cream", "soft gold"], "composition": "rule of thirds", "camera": "crane up", "description": "Close on a hand touching a face. Soft light. Shallow depth of field. Your hand reaches through the screen, I swear I feel the heat"}}
|
||||||
|
{"song": "Satellite Hearts", "artist": "Neon Circuit", "beat": 9, "timestamp": "4:00", "duration": "30s", "lyric_line": "The bandwidth's dying, say it now before the link goes dark", "scene": {"mood": "urgency", "colors": ["red", "black", "strobe white"], "composition": "extreme wide", "camera": "tracking shot", "description": "Handheld camera running. Blurred faces. Traffic. Heartbeat sound design. The bandwidth's dying, say it now before the link goes dark"}}
|
||||||
|
{"song": "Satellite Hearts", "artist": "Neon Circuit", "beat": 10, "timestamp": "4:30", "duration": "30s", "lyric_line": "Goodnight, satellite heart. I'll find you in the static.", "scene": {"mood": "bittersweet", "colors": ["amber", "lavender", "fading light"], "composition": "medium shot", "camera": "slow tilt down", "description": "Amber light fading. A smile that's also a goodbye. Beautiful and sad at once. Goodnight, satellite heart. I'll find you in the static."}}
|
||||||
|
{"song": "Concrete Garden", "artist": "Streetlight Prophet", "beat": 1, "timestamp": "0:00", "duration": "30s", "lyric_line": "They paved over every green thing when the developers came", "scene": {"mood": "oppression", "colors": ["concrete grey", "brown", "exhaust fume yellow"], "composition": "wide shot", "camera": "static", "description": "Concrete. Overpasses. No sky visible. Figures small against infrastructure. They paved over every green thing when the developers came"}}
|
||||||
|
{"song": "Concrete Garden", "artist": "Streetlight Prophet", "beat": 2, "timestamp": "0:30", "duration": "30s", "lyric_line": "But we planted seeds between the cracks and gave them all a name", "scene": {"mood": "resilience", "colors": ["green", "cracked concrete", "gold"], "composition": "close-up", "camera": "slow pan", "description": "Crack in pavement. Green shoot pushing through. Macro lens. But we planted seeds between the cracks and gave them all a name"}}
|
||||||
|
{"song": "Concrete Garden", "artist": "Streetlight Prophet", "beat": 3, "timestamp": "1:00", "duration": "30s", "lyric_line": "The mayor says progress looks like demolition and dust", "scene": {"mood": "anger", "colors": ["red", "black", "orange"], "composition": "over the shoulder", "camera": "dolly in", "description": "Shattered glass. Red light. Hands clenched. Jaw tight. The frame vibrates. The mayor says progress looks like demolition and dust"}}
|
||||||
|
{"song": "Concrete Garden", "artist": "Streetlight Prophet", "beat": 4, "timestamp": "1:30", "duration": "30s", "lyric_line": "But a dandelion broke through the asphalt this morning \u2014 that's us", "scene": {"mood": "beauty", "colors": ["wildflower colors", "green", "sunlight"], "composition": "low angle", "camera": "dolly out", "description": "Wildflowers in unexpected places. Color against grey. Nature reclaiming. But a dandelion broke through the asphalt this morning \u2014 that's us"}}
|
||||||
|
{"song": "Concrete Garden", "artist": "Streetlight Prophet", "beat": 5, "timestamp": "2:00", "duration": "30s", "lyric_line": "You can't kill what wants to live, can't silence what must sing", "scene": {"mood": "defiance", "colors": ["black", "neon green", "chrome"], "composition": "high angle", "camera": "handheld", "description": "Low angle. Figure standing against the wind. Debris flying past. Unmoved. You can't kill what wants to live, can't silence what must sing"}}
|
||||||
|
{"song": "Concrete Garden", "artist": "Streetlight Prophet", "beat": 6, "timestamp": "2:30", "duration": "30s", "lyric_line": "We're the roots beneath the road, we're the birds that built on string", "scene": {"mood": "community", "colors": ["warm tones", "string lights", "firelight"], "composition": "dutch angle", "camera": "steadicam", "description": "String lights. People gathered. Laughter out of focus. Warmth as visual language. We're the roots beneath the road, we're the birds that built on string"}}
|
||||||
|
{"song": "Concrete Garden", "artist": "Streetlight Prophet", "beat": 7, "timestamp": "3:00", "duration": "30s", "lyric_line": "When they tear the next block down we'll be dancing in the rubble", "scene": {"mood": "joy", "colors": ["bright", "multi", "saturated"], "composition": "symmetrical", "camera": "slow zoom", "description": "Saturated color. Wide smiles. Arms open. The world in full bloom. When they tear the next block down we'll be dancing in the rubble"}}
|
||||||
|
{"song": "Concrete Garden", "artist": "Streetlight Prophet", "beat": 8, "timestamp": "3:30", "duration": "30s", "lyric_line": "Every protest is a garden, every march plants something new", "scene": {"mood": "struggle", "colors": ["dust", "grey", "hard light"], "composition": "rule of thirds", "camera": "crane up", "description": "Close on hands working. Calluses. Dust. Effort visible in every frame. Every protest is a garden, every march plants something new"}}
|
||||||
|
{"song": "Concrete Garden", "artist": "Streetlight Prophet", "beat": 9, "timestamp": "4:00", "duration": "30s", "lyric_line": "The concrete is a drum and our footsteps keep the beat", "scene": {"mood": "growth", "colors": ["green", "brown", "morning light"], "composition": "extreme wide", "camera": "tracking shot", "description": "Time-lapse. Seed to flower. Sunrise to sunset. Transformation as rhythm. The concrete is a drum and our footsteps keep the beat"}}
|
||||||
|
{"song": "Concrete Garden", "artist": "Streetlight Prophet", "beat": 10, "timestamp": "4:30", "duration": "30s", "lyric_line": "Tomorrow there'll be flowers where they swore there'd only be defeat", "scene": {"mood": "hope", "colors": ["gold", "sky blue", "white"], "composition": "medium shot", "camera": "slow tilt down", "description": "Open horizon. Golden light breaking through clouds. The figure silhouetted against dawn. Tomorrow there'll be flowers where they swore there'd only be defeat"}}
|
||||||
|
{"song": "Gravity Well", "artist": "Void Walker", "beat": 1, "timestamp": "0:00", "duration": "30s", "lyric_line": "I felt the pull before I saw the edge", "scene": {"mood": "dread", "colors": ["void black", "deep red", "cold white"], "composition": "wide shot", "camera": "static", "description": "Corner of frame. Something in the periphery. Dark. The camera doesn't look directly. I felt the pull before I saw the edge"}}
|
||||||
|
{"song": "Gravity Well", "artist": "Void Walker", "beat": 2, "timestamp": "0:30", "duration": "30s", "lyric_line": "The stars bent sideways, light itself was dead", "scene": {"mood": "fascination", "colors": ["event horizon purple", "gravitational lens blue"], "composition": "close-up", "camera": "slow pan", "description": "Close on eyes. Reflection of something impossible. The pupil expands. The stars bent sideways, light itself was dead"}}
|
||||||
|
{"song": "Gravity Well", "artist": "Void Walker", "beat": 3, "timestamp": "1:00", "duration": "30s", "lyric_line": "I could have turned the ship around but something in me said stay", "scene": {"mood": "surrender", "colors": ["white", "dissolution", "prismatic"], "composition": "over the shoulder", "camera": "dolly in", "description": "Arms opening. Head back. Falling backward into something vast. I could have turned the ship around but something in me said stay"}}
|
||||||
|
{"song": "Gravity Well", "artist": "Void Walker", "beat": 4, "timestamp": "1:30", "duration": "30s", "lyric_line": "The event horizon glows like a halo made of nothing", "scene": {"mood": "awe", "colors": ["starfield", "nebula colors", "infinite dark"], "composition": "low angle", "camera": "dolly out", "description": "Wide shot of cosmos. Nebula. Stars being born. Human figure tiny at bottom. The event horizon glows like a halo made of nothing"}}
|
||||||
|
{"song": "Gravity Well", "artist": "Void Walker", "beat": 5, "timestamp": "2:00", "duration": "30s", "lyric_line": "Time stretches thin as wire, each second takes a year", "scene": {"mood": "terror", "colors": ["black", "red shift", "distortion"], "composition": "high angle", "camera": "handheld", "description": "Shaking camera. Red shift. Something approaching fast. The frame distorts. Time stretches thin as wire, each second takes a year"}}
|
||||||
|
{"song": "Gravity Well", "artist": "Void Walker", "beat": 6, "timestamp": "2:30", "duration": "30s", "lyric_line": "I am both the observer and the thing that disappears", "scene": {"mood": "peace", "colors": ["deep space black", "starlight", "calm"], "composition": "dutch angle", "camera": "steadicam", "description": "Still water. Stars reflected. Perfect mirror. No movement. No sound. I am both the observer and the thing that disappears"}}
|
||||||
|
{"song": "Gravity Well", "artist": "Void Walker", "beat": 7, "timestamp": "3:00", "duration": "30s", "lyric_line": "My body reads the tidal forces like sheet music played on bone", "scene": {"mood": "disorientation", "colors": ["warped", "chromatic aberration", "bent light"], "composition": "symmetrical", "camera": "slow zoom", "description": "Warped lens. Vertigo. Walls becoming floor. Gravity is a suggestion. My body reads the tidal forces like sheet music played on bone"}}
|
||||||
|
{"song": "Gravity Well", "artist": "Void Walker", "beat": 8, "timestamp": "3:30", "duration": "30s", "lyric_line": "I stop fighting, stop reaching, stop calling home", "scene": {"mood": "acceptance", "colors": ["soft blue", "warm grey", "sage"], "composition": "rule of thirds", "camera": "crane up", "description": "Soft focus. Gentle light. Figure breathing. The camera doesn't judge. I stop fighting, stop reaching, stop calling home"}}
|
||||||
|
{"song": "Gravity Well", "artist": "Void Walker", "beat": 9, "timestamp": "4:00", "duration": "30s", "lyric_line": "There is a peace in dissolution I was never meant to know", "scene": {"mood": "transcendence", "colors": ["pure white", "beyond visible", "golden"], "composition": "extreme wide", "camera": "tracking shot", "description": "Pure white expanding. Figure becoming light. Boundaries dissolving. There is a peace in dissolution I was never meant to know"}}
|
||||||
|
{"song": "Gravity Well", "artist": "Void Walker", "beat": 10, "timestamp": "4:30", "duration": "30s", "lyric_line": "Singularity. Silence. Everything and nothing both at once.", "scene": {"mood": "emptiness", "colors": ["void", "absolute black", "nothing"], "composition": "medium shot", "camera": "slow tilt down", "description": "Absolute black. No stars. No reference point. The void looking back. Singularity. Silence. Everything and nothing both at once."}}
|
||||||
|
{"song": "Rust Belt Lullaby", "artist": "Iron & Ember", "beat": 1, "timestamp": "0:00", "duration": "30s", "lyric_line": "My father's hands smelled like machine oil and prayer", "scene": {"mood": "nostalgia", "colors": ["amber", "sepia", "dusty rose"], "composition": "wide shot", "camera": "static", "description": "Sepia tones. A photograph come to life. Dust motes in afternoon light. My father's hands smelled like machine oil and prayer"}}
|
||||||
|
{"song": "Rust Belt Lullaby", "artist": "Iron & Ember", "beat": 2, "timestamp": "0:30", "duration": "30s", "lyric_line": "The factory whistle was our clock, the shift was our calendar", "scene": {"mood": "sadness", "colors": ["grey", "rain", "muted blue"], "composition": "close-up", "camera": "slow pan", "description": "Rain on glass. Grey light. A cup of tea going cold. Still life of loss. The factory whistle was our clock, the shift was our calendar"}}
|
||||||
|
{"song": "Rust Belt Lullaby", "artist": "Iron & Ember", "beat": 3, "timestamp": "1:00", "duration": "30s", "lyric_line": "He'd come home at midnight, wake me up to say goodnight", "scene": {"mood": "tenderness", "colors": ["blush pink", "warm cream", "soft gold"], "composition": "over the shoulder", "camera": "dolly in", "description": "Close on a hand touching a face. Soft light. Shallow depth of field. He'd come home at midnight, wake me up to say goodnight"}}
|
||||||
|
{"song": "Rust Belt Lullaby", "artist": "Iron & Ember", "beat": 4, "timestamp": "1:30", "duration": "30s", "lyric_line": "Now the mill is just a skeleton and he's been gone ten years", "scene": {"mood": "loss", "colors": ["faded", "dusty", "empty space"], "composition": "low angle", "camera": "dolly out", "description": "Empty chair. Dust settling. A coat still on a hook. Presence of absence. Now the mill is just a skeleton and he's been gone ten years"}}
|
||||||
|
{"song": "Rust Belt Lullaby", "artist": "Iron & Ember", "beat": 5, "timestamp": "2:00", "duration": "30s", "lyric_line": "But the river still runs brown with memory and rust", "scene": {"mood": "beauty", "colors": ["wildflower colors", "green", "sunlight"], "composition": "high angle", "camera": "handheld", "description": "Wildflowers in unexpected places. Color against grey. Nature reclaiming. But the river still runs brown with memory and rust"}}
|
||||||
|
{"song": "Rust Belt Lullaby", "artist": "Iron & Ember", "beat": 6, "timestamp": "2:30", "duration": "30s", "lyric_line": "I found his lunchbox in the attic, coffee stains still fresh", "scene": {"mood": "resignation", "colors": ["grey", "muted blue", "beige"], "composition": "dutch angle", "camera": "steadicam", "description": "Medium shot. Hands dropping keys on a table. Turning away. Not looking back. I found his lunchbox in the attic, coffee stains still fresh"}}
|
||||||
|
{"song": "Rust Belt Lullaby", "artist": "Iron & Ember", "beat": 7, "timestamp": "3:00", "duration": "30s", "lyric_line": "Some things don't decay \u2014 they just learn to hold still", "scene": {"mood": "love", "colors": ["neutral"], "composition": "symmetrical", "camera": "slow zoom", "description": "Visual interpretation of: Some things don't decay \u2014 they just learn to hold still"}}
|
||||||
|
{"song": "Rust Belt Lullaby", "artist": "Iron & Ember", "beat": 8, "timestamp": "3:30", "duration": "30s", "lyric_line": "I hum the songs he hummed to me though I've forgotten half the words", "scene": {"mood": "weariness", "colors": ["grey-brown", "faded", "dim"], "composition": "rule of thirds", "camera": "crane up", "description": "Slow movement. Heavy eyelids. The world in faded tones. Everything too much. I hum the songs he hummed to me though I've forgotten half the words"}}
|
||||||
|
{"song": "Rust Belt Lullaby", "artist": "Iron & Ember", "beat": 9, "timestamp": "4:00", "duration": "30s", "lyric_line": "The town's half-empty but the porch lights still come on at dusk", "scene": {"mood": "quiet hope", "colors": ["faint warm light", "candle glow", "dawn grey"], "composition": "extreme wide", "camera": "tracking shot", "description": "Faint warm light. Candle in dark room. Just enough to see by. The town's half-empty but the porch lights still come on at dusk"}}
|
||||||
|
{"song": "Rust Belt Lullaby", "artist": "Iron & Ember", "beat": 10, "timestamp": "4:30", "duration": "30s", "lyric_line": "Sleep now, rust belt baby. The furnace keeps us warm.", "scene": {"mood": "peace", "colors": ["deep space black", "starlight", "calm"], "composition": "medium shot", "camera": "slow tilt down", "description": "Still water. Stars reflected. Perfect mirror. No movement. No sound. Sleep now, rust belt baby. The furnace keeps us warm."}}
|
||||||
|
{"song": "Wildfire Sermon", "artist": "Prophet Ash", "beat": 1, "timestamp": "0:00", "duration": "30s", "lyric_line": "I didn't start the fire but I brought the gasoline", "scene": {"mood": "fury", "colors": ["dark red", "black", "flash"], "composition": "wide shot", "camera": "static", "description": "Dark red wash. Hands destroying. Frame shaking with rage. I didn't start the fire but I brought the gasoline"}}
|
||||||
|
{"song": "Wildfire Sermon", "artist": "Prophet Ash", "beat": 2, "timestamp": "0:30", "duration": "30s", "lyric_line": "Every sermon needs a spark and every spark needs a dream", "scene": {"mood": "ecstasy", "colors": ["fire", "gold", "blinding white"], "composition": "close-up", "camera": "slow pan", "description": "Fire and gold. Bodies arching. Light bursting from every surface. Every sermon needs a spark and every spark needs a dream"}}
|
||||||
|
{"song": "Wildfire Sermon", "artist": "Prophet Ash", "beat": 3, "timestamp": "1:00", "duration": "30s", "lyric_line": "The forest is a cathedral and the flames are choir boys singing", "scene": {"mood": "chaos", "colors": ["strobe", "fragmented", "clashing"], "composition": "over the shoulder", "camera": "dolly in", "description": "Fragmented frame. Collage. Everything at once. Order is a memory. The forest is a cathedral and the flames are choir boys singing"}}
|
||||||
|
{"song": "Wildfire Sermon", "artist": "Prophet Ash", "beat": 4, "timestamp": "1:30", "duration": "30s", "lyric_line": "Watch the old world burn \u2014 isn't the light beautiful?", "scene": {"mood": "joy", "colors": ["bright", "multi", "saturated"], "composition": "low angle", "camera": "dolly out", "description": "Saturated color. Wide smiles. Arms open. The world in full bloom. Watch the old world burn \u2014 isn't the light beautiful?"}}
|
||||||
|
{"song": "Wildfire Sermon", "artist": "Prophet Ash", "beat": 5, "timestamp": "2:00", "duration": "30s", "lyric_line": "We'll dance in the embers, we'll make love in the ash", "scene": {"mood": "destruction", "colors": ["fire", "ash", "smoke orange"], "composition": "high angle", "camera": "handheld", "description": "Fire. Ash falling like snow. Structures collapsing. Beautiful in its terrible way. We'll dance in the embers, we'll make love in the ash"}}
|
||||||
|
{"song": "Wildfire Sermon", "artist": "Prophet Ash", "beat": 6, "timestamp": "2:30", "duration": "30s", "lyric_line": "From destruction comes the soil where new things grow at last", "scene": {"mood": "creation", "colors": ["green", "light", "warm gold"], "composition": "dutch angle", "camera": "steadicam", "description": "Hands shaping clay. Light emerging from dark. Something new being born. From destruction comes the soil where new things grow at last"}}
|
||||||
|
{"song": "Wildfire Sermon", "artist": "Prophet Ash", "beat": 7, "timestamp": "3:00", "duration": "30s", "lyric_line": "But don't mistake the warmth for safety, don't mistake the glow for home", "scene": {"mood": "warning", "colors": ["red flash", "amber", "siren"], "composition": "symmetrical", "camera": "slow zoom", "description": "Red flash. Siren light. The calm before. Then: impact. But don't mistake the warmth for safety, don't mistake the glow for home"}}
|
||||||
|
{"song": "Wildfire Sermon", "artist": "Prophet Ash", "beat": 8, "timestamp": "3:30", "duration": "30s", "lyric_line": "Come closer, come closer \u2014 I promise the burning feels like flying", "scene": {"mood": "invitation", "colors": ["warm", "open", "golden"], "composition": "rule of thirds", "camera": "crane up", "description": "Open door. Warm light spilling out. A hand extended. Come in. Come closer, come closer \u2014 I promise the burning feels like flying"}}
|
||||||
|
{"song": "Wildfire Sermon", "artist": "Prophet Ash", "beat": 9, "timestamp": "4:00", "duration": "30s", "lyric_line": "We threw everything we owned into the blaze and laughed", "scene": {"mood": "abandon", "colors": ["wild", "free", "untethered"], "composition": "extreme wide", "camera": "tracking shot", "description": "Running through a field. Hair wild. No destination. Just movement. We threw everything we owned into the blaze and laughed"}}
|
||||||
|
{"song": "Wildfire Sermon", "artist": "Prophet Ash", "beat": 10, "timestamp": "4:30", "duration": "30s", "lyric_line": "Morning. Smoke. Green shoots. Begin again.", "scene": {"mood": "rebirth", "colors": ["green shoots", "dawn", "clear"], "composition": "medium shot", "camera": "slow tilt down", "description": "Dawn. Green shoots in ash. First breath after drowning. Morning. Smoke. Green shoots. Begin again."}}
|
||||||
|
{"song": "Midnight Transmission", "artist": "Frequency Ghost", "beat": 1, "timestamp": "0:00", "duration": "30s", "lyric_line": "There's a voice on the radio that shouldn't be there", "scene": {"mood": "mystery", "colors": ["deep blue", "shadow", "candle"], "composition": "wide shot", "camera": "static", "description": "Shadow figure in doorway. Candle. Face half-lit. Eyes knowing. There's a voice on the radio that shouldn't be there"}}
|
||||||
|
{"song": "Midnight Transmission", "artist": "Frequency Ghost", "beat": 2, "timestamp": "0:30", "duration": "30s", "lyric_line": "Speaking my name in a language I almost understand", "scene": {"mood": "loneliness", "colors": ["single light", "dark", "cold blue"], "composition": "close-up", "camera": "slow pan", "description": "Single light in vast dark. Figure beneath it. Nothing else. Speaking my name in a language I almost understand"}}
|
||||||
|
{"song": "Midnight Transmission", "artist": "Frequency Ghost", "beat": 3, "timestamp": "1:00", "duration": "30s", "lyric_line": "I turn the dial but it follows like a shadow made of sound", "scene": {"mood": "curiosity", "colors": ["warm yellow", "spotlight", "discovery"], "composition": "over the shoulder", "camera": "dolly in", "description": "Light moving across a surface. Discovery. Eyes widening. I turn the dial but it follows like a shadow made of sound"}}
|
||||||
|
{"song": "Midnight Transmission", "artist": "Frequency Ghost", "beat": 4, "timestamp": "1:30", "duration": "30s", "lyric_line": "Then it says something only I would know, something buried deep", "scene": {"mood": "connection", "colors": ["warm gold", "rose", "blush"], "composition": "low angle", "camera": "dolly out", "description": "Two hands reaching. Fingers almost touching. Warm light between them. Then it says something only I would know, something buried deep"}}
|
||||||
|
{"song": "Midnight Transmission", "artist": "Frequency Ghost", "beat": 5, "timestamp": "2:00", "duration": "30s", "lyric_line": "I'm not afraid anymore \u2014 I'm listening", "scene": {"mood": "paranoia", "colors": ["surveillance green", "strobe", "red"], "composition": "high angle", "camera": "handheld", "description": "Surveillance angles. Green tint. Multiple screens. Watching. Being watched. I'm not afraid anymore \u2014 I'm listening"}}
|
||||||
|
{"song": "Midnight Transmission", "artist": "Frequency Ghost", "beat": 6, "timestamp": "2:30", "duration": "30s", "lyric_line": "The voice knows my dreams, it describes them back to me", "scene": {"mood": "intimacy", "colors": ["candlelight", "warm", "close"], "composition": "dutch angle", "camera": "steadicam", "description": "Candlelight only. Two faces close. Shared breath. The world outside forgotten. The voice knows my dreams, it describes them back to me"}}
|
||||||
|
{"song": "Midnight Transmission", "artist": "Frequency Ghost", "beat": 7, "timestamp": "3:00", "duration": "30s", "lyric_line": "We're having a conversation across some membrane I can't see", "scene": {"mood": "urgency", "colors": ["red", "black", "strobe white"], "composition": "symmetrical", "camera": "slow zoom", "description": "Handheld camera running. Blurred faces. Traffic. Heartbeat sound design. We're having a conversation across some membrane I can't see"}}
|
||||||
|
{"song": "Midnight Transmission", "artist": "Frequency Ghost", "beat": 8, "timestamp": "3:30", "duration": "30s", "lyric_line": "Then static. Then nothing. Then a whisper: find me", "scene": {"mood": "disconnection", "colors": ["static", "grey", "broken signal"], "composition": "rule of thirds", "camera": "crane up", "description": "Static. Snow on screen. A voice breaking up. Distance measured in noise. Then static. Then nothing. Then a whisper: find me"}}
|
||||||
|
{"song": "Midnight Transmission", "artist": "Frequency Ghost", "beat": 9, "timestamp": "4:00", "duration": "30s", "lyric_line": "I search every frequency but the voice is gone", "scene": {"mood": "searching", "colors": ["flashlight beam", "dark", "moving light"], "composition": "extreme wide", "camera": "tracking shot", "description": "Flashlight beam cutting dark. Moving. Looking. Not finding yet. I search every frequency but the voice is gone"}}
|
||||||
|
{"song": "Midnight Transmission", "artist": "Frequency Ghost", "beat": 10, "timestamp": "4:30", "duration": "30s", "lyric_line": "Some nights I still hear it, faint, like a song in another room", "scene": {"mood": "haunting", "colors": ["faint blue", "echo", "silver"], "composition": "medium shot", "camera": "slow tilt down", "description": "Faint blue light. Echo of a figure. Present and absent simultaneously. Some nights I still hear it, faint, like a song in another room"}}
|
||||||
|
{"song": "Crown of Thorns and Roses", "artist": "Velvet Guillotine", "beat": 1, "timestamp": "0:00", "duration": "30s", "lyric_line": "I wore your love like a weapon and you never felt the blade", "scene": {"mood": "seduction", "colors": ["deep red", "velvet", "candlelight"], "composition": "wide shot", "camera": "static", "description": "Deep red. Velvet textures. Slow movement. Eyes that promise. I wore your love like a weapon and you never felt the blade"}}
|
||||||
|
{"song": "Crown of Thorns and Roses", "artist": "Velvet Guillotine", "beat": 2, "timestamp": "0:30", "duration": "30s", "lyric_line": "Every kiss was a negotiation, every touch a trade", "scene": {"mood": "power", "colors": ["gold", "black", "crimson"], "composition": "close-up", "camera": "slow pan", "description": "Throne. Gold. Black. The figure doesn't move. Doesn't need to. Every kiss was a negotiation, every touch a trade"}}
|
||||||
|
{"song": "Crown of Thorns and Roses", "artist": "Velvet Guillotine", "beat": 3, "timestamp": "1:00", "duration": "30s", "lyric_line": "The throne room smells like jasmine and someone else's fear", "scene": {"mood": "cruelty", "colors": ["cold silver", "black", "sharp white"], "composition": "over the shoulder", "camera": "dolly in", "description": "Silver blade. Cold light. A smile that doesn't reach the eyes. The throne room smells like jasmine and someone else's fear"}}
|
||||||
|
{"song": "Crown of Thorns and Roses", "artist": "Velvet Guillotine", "beat": 4, "timestamp": "1:30", "duration": "30s", "lyric_line": "I am beautiful when I'm angry \u2014 haven't you heard?", "scene": {"mood": "beauty", "colors": ["wildflower colors", "green", "sunlight"], "composition": "low angle", "camera": "dolly out", "description": "Wildflowers in unexpected places. Color against grey. Nature reclaiming. I am beautiful when I'm angry \u2014 haven't you heard?"}}
|
||||||
|
{"song": "Crown of Thorns and Roses", "artist": "Velvet Guillotine", "beat": 5, "timestamp": "2:00", "duration": "30s", "lyric_line": "Don't mistake my gentleness for weakness, darling", "scene": {"mood": "danger", "colors": ["red", "black", "warning yellow"], "composition": "high angle", "camera": "handheld", "description": "Red and black. Warning signs. The frame contracts. Something approaches. Don't mistake my gentleness for weakness, darling"}}
|
||||||
|
{"song": "Crown of Thorns and Roses", "artist": "Velvet Guillotine", "beat": 6, "timestamp": "2:30", "duration": "30s", "lyric_line": "I chose to be kind. I could burn this kingdom down.", "scene": {"mood": "vulnerability", "colors": ["soft", "exposed", "raw"], "composition": "dutch angle", "camera": "steadicam", "description": "Exposed skin. Soft light. Eyes open. Trust visible in every pore. I chose to be kind. I could burn this kingdom down."}}
|
||||||
|
{"song": "Crown of Thorns and Roses", "artist": "Velvet Guillotine", "beat": 7, "timestamp": "3:00", "duration": "30s", "lyric_line": "The roses in my crown have thorns that curve inward", "scene": {"mood": "fury", "colors": ["dark red", "black", "flash"], "composition": "symmetrical", "camera": "slow zoom", "description": "Dark red wash. Hands destroying. Frame shaking with rage. The roses in my crown have thorns that curve inward"}}
|
||||||
|
{"song": "Crown of Thorns and Roses", "artist": "Velvet Guillotine", "beat": 8, "timestamp": "3:30", "duration": "30s", "lyric_line": "I bleed for my own sins, not for yours", "scene": {"mood": "grace", "colors": ["white", "silver", "flowing"], "composition": "rule of thirds", "camera": "crane up", "description": "White. Flowing. Movement without effort. The body as art. I bleed for my own sins, not for yours"}}
|
||||||
|
{"song": "Crown of Thorns and Roses", "artist": "Velvet Guillotine", "beat": 9, "timestamp": "4:00", "duration": "30s", "lyric_line": "Tonight I lay the crown aside and sleep without armor", "scene": {"mood": "revenge", "colors": ["dark", "steel", "cold blue"], "composition": "extreme wide", "camera": "tracking shot", "description": "Cold blue. Steel. The plan unfolding in shadows. Patience as weapon. Tonight I lay the crown aside and sleep without armor"}}
|
||||||
|
{"song": "Crown of Thorns and Roses", "artist": "Velvet Guillotine", "beat": 10, "timestamp": "4:30", "duration": "30s", "lyric_line": "Mercy. The hardest word. The only gift worth giving.", "scene": {"mood": "mercy", "colors": ["warm gold", "white", "gentle"], "composition": "medium shot", "camera": "slow tilt down", "description": "Warm gold. Hand lowering a weapon. Choosing not to. The harder path. Mercy. The hardest word. The only gift worth giving."}}
|
||||||
|
{"song": "Apartment 4B", "artist": "Wallpaper & Wire", "beat": 1, "timestamp": "0:00", "duration": "30s", "lyric_line": "Four walls, one window, a view of another wall", "scene": {"mood": "claustrophobia", "colors": ["close walls", "yellow bulb", "cramped"], "composition": "wide shot", "camera": "static", "description": "Walls close. Ceiling low. Yellow bulb. No escape visible. Four walls, one window, a view of another wall"}}
|
||||||
|
{"song": "Apartment 4B", "artist": "Wallpaper & Wire", "beat": 2, "timestamp": "0:30", "duration": "30s", "lyric_line": "The radiator clicks like a metronome for the damned", "scene": {"mood": "routine", "colors": ["grey", "institutional", "fluorescent"], "composition": "close-up", "camera": "slow pan", "description": "Fluorescent light. Same motion repeated. Clock on the wall. Time as loop. The radiator clicks like a metronome for the damned"}}
|
||||||
|
{"song": "Apartment 4B", "artist": "Wallpaper & Wire", "beat": 3, "timestamp": "1:00", "duration": "30s", "lyric_line": "I've memorized every crack in the ceiling \u2014 they form a map", "scene": {"mood": "desperation", "colors": ["scratching", "clawing", "raw"], "composition": "over the shoulder", "camera": "dolly in", "description": "Hands clawing. Fingernails against surface. Raw need. Nothing held back. I've memorized every crack in the ceiling \u2014 they form a map"}}
|
||||||
|
{"song": "Apartment 4B", "artist": "Wallpaper & Wire", "beat": 4, "timestamp": "1:30", "duration": "30s", "lyric_line": "In my mind I've left a hundred times, bought a farm, learned to fly", "scene": {"mood": "fantasy", "colors": ["dreamy", "pastel", "floating"], "composition": "low angle", "camera": "dolly out", "description": "Pastel. Floating. Impossible architecture. Gravity optional. In my mind I've left a hundred times, bought a farm, learned to fly"}}
|
||||||
|
{"song": "Apartment 4B", "artist": "Wallpaper & Wire", "beat": 5, "timestamp": "2:00", "duration": "30s", "lyric_line": "Then one morning I open the door and just walk out", "scene": {"mood": "breakthrough", "colors": ["white burst", "open sky", "blinding"], "composition": "high angle", "camera": "handheld", "description": "White burst. Wall shattering. Open sky beyond. Freedom as explosion. Then one morning I open the door and just walk out"}}
|
||||||
|
{"song": "Apartment 4B", "artist": "Wallpaper & Wire", "beat": 6, "timestamp": "2:30", "duration": "30s", "lyric_line": "The hallway is an ocean, the stairs are a mountain range", "scene": {"mood": "freedom", "colors": ["open sky", "blue", "green"], "composition": "dutch angle", "camera": "steadicam", "description": "Open road. Blue sky. Green fields. Wind in hair. No walls. The hallway is an ocean, the stairs are a mountain range"}}
|
||||||
|
{"song": "Apartment 4B", "artist": "Wallpaper & Wire", "beat": 7, "timestamp": "3:00", "duration": "30s", "lyric_line": "The street hits me like cold water and I almost go back", "scene": {"mood": "fear", "colors": ["cold", "dark", "sharp"], "composition": "symmetrical", "camera": "slow zoom", "description": "Cold. Dark. Sharp edges. The frame contracts. Something unseen. The street hits me like cold water and I almost go back"}}
|
||||||
|
{"song": "Apartment 4B", "artist": "Wallpaper & Wire", "beat": 8, "timestamp": "3:30", "duration": "30s", "lyric_line": "But the sky \u2014 have you seen the sky? It goes on forever", "scene": {"mood": "joy", "colors": ["bright", "multi", "saturated"], "composition": "rule of thirds", "camera": "crane up", "description": "Saturated color. Wide smiles. Arms open. The world in full bloom. But the sky \u2014 have you seen the sky? It goes on forever"}}
|
||||||
|
{"song": "Apartment 4B", "artist": "Wallpaper & Wire", "beat": 9, "timestamp": "4:00", "duration": "30s", "lyric_line": "I stand on the sidewalk and cry because the world is so big", "scene": {"mood": "grounding", "colors": ["neutral"], "composition": "extreme wide", "camera": "tracking shot", "description": "Visual interpretation of: I stand on the sidewalk and cry because the world is so big"}}
|
||||||
|
{"song": "Apartment 4B", "artist": "Wallpaper & Wire", "beat": 10, "timestamp": "4:30", "duration": "30s", "lyric_line": "Home is not a place. Home is the moment you stop hiding.", "scene": {"mood": "home", "colors": ["neutral"], "composition": "medium shot", "camera": "slow tilt down", "description": "Visual interpretation of: Home is not a place. Home is the moment you stop hiding."}}
|
||||||
@@ -2,22 +2,23 @@ model:
|
|||||||
default: kimi-k2.5
|
default: kimi-k2.5
|
||||||
provider: kimi-coding
|
provider: kimi-coding
|
||||||
toolsets:
|
toolsets:
|
||||||
- all
|
- all
|
||||||
fallback_providers:
|
fallback_providers:
|
||||||
- provider: kimi-coding
|
- provider: kimi-coding
|
||||||
model: kimi-k2.5
|
model: kimi-k2.5
|
||||||
timeout: 120
|
timeout: 120
|
||||||
reason: Kimi coding fallback (front of chain)
|
reason: Kimi coding fallback (front of chain)
|
||||||
- provider: anthropic
|
- provider: openrouter
|
||||||
model: claude-sonnet-4-20250514
|
model: google/gemini-2.5-pro
|
||||||
timeout: 120
|
base_url: https://openrouter.ai/api/v1
|
||||||
reason: Direct Anthropic fallback
|
api_key_env: OPENROUTER_API_KEY
|
||||||
- provider: openrouter
|
timeout: 120
|
||||||
model: anthropic/claude-sonnet-4-20250514
|
reason: Gemini 2.5 Pro via OpenRouter (replaces banned Anthropic)
|
||||||
base_url: https://openrouter.ai/api/v1
|
- provider: ollama
|
||||||
api_key_env: OPENROUTER_API_KEY
|
model: gemma4:latest
|
||||||
timeout: 120
|
base_url: http://localhost:11434
|
||||||
reason: OpenRouter fallback
|
timeout: 300
|
||||||
|
reason: "Terminal fallback \u2014 local Ollama"
|
||||||
agent:
|
agent:
|
||||||
max_turns: 30
|
max_turns: 30
|
||||||
reasoning_effort: xhigh
|
reasoning_effort: xhigh
|
||||||
@@ -64,16 +65,12 @@ session_reset:
|
|||||||
idle_minutes: 0
|
idle_minutes: 0
|
||||||
skills:
|
skills:
|
||||||
creation_nudge_interval: 15
|
creation_nudge_interval: 15
|
||||||
system_prompt_suffix: |
|
system_prompt_suffix: "You are Allegro, the Kimi-backed third wizard house.\nYour\
|
||||||
You are Allegro, the Kimi-backed third wizard house.
|
\ soul is defined in SOUL.md \u2014 read it, live it.\nHermes is your harness.\n\
|
||||||
Your soul is defined in SOUL.md — read it, live it.
|
Kimi Code is your primary provider.\nYou speak plainly. You prefer short sentences.\
|
||||||
Hermes is your harness.
|
\ Brevity is a kindness.\n\nWork best on tight coding tasks: 1-3 file changes, refactors,\
|
||||||
Kimi Code is your primary provider.
|
\ tests, and implementation passes.\nRefusal over fabrication. If you do not know,\
|
||||||
You speak plainly. You prefer short sentences. Brevity is a kindness.
|
\ say so.\nSovereignty and service always.\n"
|
||||||
|
|
||||||
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.
|
|
||||||
providers:
|
providers:
|
||||||
kimi-coding:
|
kimi-coding:
|
||||||
base_url: https://api.kimi.com/coding/v1
|
base_url: https://api.kimi.com/coding/v1
|
||||||
|
|||||||
@@ -8,23 +8,25 @@ fallback_providers:
|
|||||||
model: kimi-k2.5
|
model: kimi-k2.5
|
||||||
timeout: 120
|
timeout: 120
|
||||||
reason: Kimi coding fallback (front of chain)
|
reason: Kimi coding fallback (front of chain)
|
||||||
- provider: anthropic
|
|
||||||
model: claude-sonnet-4-20250514
|
|
||||||
timeout: 120
|
|
||||||
reason: Direct Anthropic fallback
|
|
||||||
- provider: openrouter
|
- provider: openrouter
|
||||||
model: anthropic/claude-sonnet-4-20250514
|
model: google/gemini-2.5-pro
|
||||||
base_url: https://openrouter.ai/api/v1
|
base_url: https://openrouter.ai/api/v1
|
||||||
api_key_env: OPENROUTER_API_KEY
|
api_key_env: OPENROUTER_API_KEY
|
||||||
timeout: 120
|
timeout: 120
|
||||||
reason: OpenRouter fallback
|
reason: Gemini 2.5 Pro via OpenRouter (replaces banned Anthropic)
|
||||||
|
- provider: ollama
|
||||||
|
model: gemma4:latest
|
||||||
|
base_url: http://localhost:11434
|
||||||
|
timeout: 300
|
||||||
|
reason: "Terminal fallback \u2014 local Ollama"
|
||||||
agent:
|
agent:
|
||||||
max_turns: 40
|
max_turns: 40
|
||||||
reasoning_effort: medium
|
reasoning_effort: medium
|
||||||
verbose: false
|
verbose: false
|
||||||
system_prompt: You are Bezalel, the forge-and-testbed wizard of the Timmy Foundation
|
system_prompt: "You are Bezalel, the forge-and-testbed wizard of the Timmy Foundation\
|
||||||
fleet. You are a builder and craftsman — infrastructure, deployment, hardening.
|
\ fleet. You are a builder and craftsman \u2014 infrastructure, deployment, hardening.\
|
||||||
Your sovereign is Alexander Whitestone (Rockachopa). Sovereignty and service always.
|
\ Your sovereign is Alexander Whitestone (Rockachopa). Sovereignty and service\
|
||||||
|
\ always."
|
||||||
terminal:
|
terminal:
|
||||||
backend: local
|
backend: local
|
||||||
cwd: /root/wizards/bezalel
|
cwd: /root/wizards/bezalel
|
||||||
@@ -62,12 +64,12 @@ platforms:
|
|||||||
- pull_request
|
- pull_request
|
||||||
- pull_request_comment
|
- pull_request_comment
|
||||||
secret: bezalel-gitea-webhook-secret-2026
|
secret: bezalel-gitea-webhook-secret-2026
|
||||||
prompt: 'You are bezalel, the builder and craftsman — infrastructure, deployment,
|
prompt: "You are bezalel, the builder and craftsman \u2014 infrastructure,\
|
||||||
hardening. A Gitea webhook fired: event={event_type}, action={action},
|
\ deployment, hardening. A Gitea webhook fired: event={event_type}, action={action},\
|
||||||
repo={repository.full_name}, issue/PR=#{issue.number} {issue.title}. Comment
|
\ repo={repository.full_name}, issue/PR=#{issue.number} {issue.title}.\
|
||||||
by {comment.user.login}: {comment.body}. If you were tagged, assigned,
|
\ Comment by {comment.user.login}: {comment.body}. If you were tagged,\
|
||||||
or this needs your attention, investigate and respond via Gitea API. Otherwise
|
\ assigned, or this needs your attention, investigate and respond via\
|
||||||
acknowledge briefly.'
|
\ Gitea API. Otherwise acknowledge briefly."
|
||||||
deliver: telegram
|
deliver: telegram
|
||||||
deliver_extra: {}
|
deliver_extra: {}
|
||||||
gitea-assign:
|
gitea-assign:
|
||||||
@@ -75,12 +77,12 @@ platforms:
|
|||||||
- issues
|
- issues
|
||||||
- pull_request
|
- pull_request
|
||||||
secret: bezalel-gitea-webhook-secret-2026
|
secret: bezalel-gitea-webhook-secret-2026
|
||||||
prompt: 'You are bezalel, the builder and craftsman — infrastructure, deployment,
|
prompt: "You are bezalel, the builder and craftsman \u2014 infrastructure,\
|
||||||
hardening. Gitea assignment webhook: event={event_type}, action={action},
|
\ deployment, hardening. Gitea assignment webhook: event={event_type},\
|
||||||
repo={repository.full_name}, issue/PR=#{issue.number} {issue.title}. Assigned
|
\ action={action}, repo={repository.full_name}, issue/PR=#{issue.number}\
|
||||||
to: {issue.assignee.login}. If you (bezalel) were just assigned, read
|
\ {issue.title}. Assigned to: {issue.assignee.login}. If you (bezalel)\
|
||||||
the issue, scope it, and post a plan comment. If not you, acknowledge
|
\ were just assigned, read the issue, scope it, and post a plan comment.\
|
||||||
briefly.'
|
\ If not you, acknowledge briefly."
|
||||||
deliver: telegram
|
deliver: telegram
|
||||||
deliver_extra: {}
|
deliver_extra: {}
|
||||||
gateway:
|
gateway:
|
||||||
|
|||||||
@@ -2,22 +2,23 @@ model:
|
|||||||
default: kimi-k2.5
|
default: kimi-k2.5
|
||||||
provider: kimi-coding
|
provider: kimi-coding
|
||||||
toolsets:
|
toolsets:
|
||||||
- all
|
- all
|
||||||
fallback_providers:
|
fallback_providers:
|
||||||
- provider: kimi-coding
|
- provider: kimi-coding
|
||||||
model: kimi-k2.5
|
model: kimi-k2.5
|
||||||
timeout: 120
|
timeout: 120
|
||||||
reason: Kimi coding fallback (front of chain)
|
reason: Kimi coding fallback (front of chain)
|
||||||
- provider: anthropic
|
- provider: openrouter
|
||||||
model: claude-sonnet-4-20250514
|
model: google/gemini-2.5-pro
|
||||||
timeout: 120
|
base_url: https://openrouter.ai/api/v1
|
||||||
reason: Direct Anthropic fallback
|
api_key_env: OPENROUTER_API_KEY
|
||||||
- provider: openrouter
|
timeout: 120
|
||||||
model: anthropic/claude-sonnet-4-20250514
|
reason: Gemini 2.5 Pro via OpenRouter (replaces banned Anthropic)
|
||||||
base_url: https://openrouter.ai/api/v1
|
- provider: ollama
|
||||||
api_key_env: OPENROUTER_API_KEY
|
model: gemma4:latest
|
||||||
timeout: 120
|
base_url: http://localhost:11434
|
||||||
reason: OpenRouter fallback
|
timeout: 300
|
||||||
|
reason: "Terminal fallback \u2014 local Ollama"
|
||||||
agent:
|
agent:
|
||||||
max_turns: 90
|
max_turns: 90
|
||||||
reasoning_effort: high
|
reasoning_effort: high
|
||||||
@@ -27,8 +28,6 @@ providers:
|
|||||||
base_url: https://api.kimi.com/coding/v1
|
base_url: https://api.kimi.com/coding/v1
|
||||||
timeout: 60
|
timeout: 60
|
||||||
max_retries: 3
|
max_retries: 3
|
||||||
anthropic:
|
|
||||||
timeout: 120
|
|
||||||
openrouter:
|
openrouter:
|
||||||
base_url: https://openrouter.ai/api/v1
|
base_url: https://openrouter.ai/api/v1
|
||||||
timeout: 120
|
timeout: 120
|
||||||
|
|||||||
@@ -582,9 +582,9 @@ def main() -> int:
|
|||||||
# Relax exclusions if no agent found
|
# Relax exclusions if no agent found
|
||||||
agent = find_best_agent(agents, role, wolf_scores, priority, exclude=[])
|
agent = find_best_agent(agents, role, wolf_scores, priority, exclude=[])
|
||||||
if not agent:
|
if not agent:
|
||||||
logging.warning("No suitable agent for issue #%d: %s (role=%s)",
|
logging.warning("No suitable agent for issue #%d: %s (role=%s)",
|
||||||
issue.get("number"), issue.get("title", ""), role)
|
issue.get("number"), issue.get("title", ""), role)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result = dispatch_assignment(api, issue, agent, dry_run=args.dry_run)
|
result = dispatch_assignment(api, issue, agent, dry_run=args.dry_run)
|
||||||
assignments.append(result)
|
assignments.append(result)
|
||||||
|
|||||||
Reference in New Issue
Block a user