Compare commits
20 Commits
feat/20260
...
whip/491-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3278f6f1ed | ||
| e3a40be627 | |||
| efb2df8940 | |||
| cf687a5bfa | |||
|
|
c09e54de72 | ||
| 3214437652 | |||
| 95cd259867 | |||
| 5e7bef1807 | |||
| 3d84dd5c27 | |||
| e38e80661c | |||
|
|
b71e365ed6 | ||
| c0c34cbae5 | |||
|
|
8483a6602a | ||
| af9850080a | |||
|
|
d50296e76b | ||
| 34460cc97b | |||
| 9fdb8552e1 | |||
| 79f33e2867 | |||
| 28680b4f19 | |||
|
|
7630806f13 |
@@ -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
|
||||||
|
|||||||
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
|
|
||||||
|
|||||||
@@ -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 (Kimi) 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_kimi():
|
def check_kimi():
|
||||||
65| """Can we reach Kimi Coding API?"""
|
"""Can we reach Kimi Coding API?"""
|
||||||
66| key = os.environ.get("KIMI_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("KIMI_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 "x-api-provider: kimi-coding" '
|
f'-H "x-api-provider: kimi-coding" '
|
||||||
83| f'https://api.kimi.com/coding/v1/models -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":"kimi-k2.5","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 Kimi 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| kimi_ok, kimi_msg = check_kimi()
|
kimi_ok, kimi_msg = check_kimi()
|
||||||
180| results["checks"]["kimi-coding"] = {"ok": kimi_ok, "msg": kimi_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", "kimi-coding")
|
current_provider = cfg.get("model", {}).get("provider", "kimi-coding")
|
||||||
202|
|
|
||||||
203| # ─── FALLBACK LOGIC ───
|
# ─── FALLBACK LOGIC ───
|
||||||
204|
|
|
||||||
205| # Case 1: Primary (Kimi) down, local available
|
# Case 1: Primary (Kimi) down, local available
|
||||||
206| if not kimi_ok and current_provider == "kimi-coding":
|
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("kimi->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("kimi->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 kimi_ok and "kimi->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("kimi->local-llama")
|
state["active_fallbacks"].remove("kimi->local-llama")
|
||||||
227| results["status"] = "recovered"
|
results["status"] = "recovered"
|
||||||
228| elif kimi_ok and "kimi->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("kimi->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|
|
|
||||||
|
|||||||
297
bin/glitch_patterns.py
Normal file
297
bin/glitch_patterns.py
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
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
|
||||||
|
|||||||
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
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 +0,0 @@
|
|||||||
# Test file
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
惦-
|
|
||||||
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()
|
||||||
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)
|
||||||
@@ -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