diff --git a/bin/timmy-dashboard b/bin/timmy-dashboard old mode 100755 new mode 100644 index ef543490..7def9f77 --- a/bin/timmy-dashboard +++ b/bin/timmy-dashboard @@ -1,20 +1,19 @@ #!/usr/bin/env python3 -"""Timmy Model Dashboard — where are my models, what are they doing. +"""Timmy workflow dashboard. -Usage: - timmy-dashboard # one-shot - timmy-dashboard --watch # live refresh every 30s - timmy-dashboard --hours=48 # look back 48h +Shows current workflow state from the active local surfaces instead of the +archived dashboard/loop era, while preserving useful local/session metrics. """ +from __future__ import annotations + import json import os import sqlite3 -import subprocess import sys import time import urllib.request -from datetime import datetime, timezone, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent @@ -26,37 +25,97 @@ from metrics_helpers import summarize_local_metrics, summarize_session_rows HERMES_HOME = Path.home() / ".hermes" TIMMY_HOME = Path.home() / ".timmy" METRICS_DIR = TIMMY_HOME / "metrics" +CORE_REPOS = [ + "Timmy_Foundation/the-nexus", + "Timmy_Foundation/timmy-home", + "Timmy_Foundation/timmy-config", + "Timmy_Foundation/hermes-agent", +] +def resolve_gitea_url() -> str: + env = os.environ.get("GITEA_URL") + if env: + return env.rstrip("/") + api_hint = HERMES_HOME / "gitea_api" + if api_hint.exists(): + raw = api_hint.read_text().strip().rstrip("/") + return raw[:-7] if raw.endswith("/api/v1") else raw + base_url = Path.home() / ".config" / "gitea" / "base-url" + if base_url.exists(): + return base_url.read_text().strip().rstrip("/") + raise FileNotFoundError("Set GITEA_URL or create ~/.hermes/gitea_api") -# ── Data Sources ────────────────────────────────────────────────────── -def get_ollama_models(): +GITEA_URL = resolve_gitea_url() + + +def read_token() -> str | None: + for path in [ + Path.home() / ".config" / "gitea" / "timmy-token", + Path.home() / ".hermes" / "gitea_token_vps", + Path.home() / ".hermes" / "gitea_token_timmy", + ]: + if path.exists(): + return path.read_text().strip() + return None + + +def gitea_get(path: str, token: str | None) -> list | dict: + headers = {"Authorization": f"token {token}"} if token else {} + req = urllib.request.Request(f"{GITEA_URL}/api/v1{path}", headers=headers) + with urllib.request.urlopen(req, timeout=5) as resp: + return json.loads(resp.read().decode()) + + +def get_model_health() -> dict: + path = HERMES_HOME / "model_health.json" + if not path.exists(): + return {} try: - req = urllib.request.Request("http://localhost:11434/api/tags") - with urllib.request.urlopen(req, timeout=5) as resp: - return json.loads(resp.read()).get("models", []) + return json.loads(path.read_text()) except Exception: - return [] + return {} -def get_loaded_models(): +def get_last_tick() -> dict: + path = TIMMY_HOME / "heartbeat" / "last_tick.json" + if not path.exists(): + return {} try: - req = urllib.request.Request("http://localhost:11434/api/ps") - with urllib.request.urlopen(req, timeout=5) as resp: - return json.loads(resp.read()).get("models", []) + return json.loads(path.read_text()) except Exception: - return [] + return {} -def get_huey_pid(): +def get_archive_checkpoint() -> dict: + path = TIMMY_HOME / "twitter-archive" / "checkpoint.json" + if not path.exists(): + return {} try: - r = subprocess.run(["pgrep", "-f", "huey_consumer"], - capture_output=True, text=True, timeout=5) - return r.stdout.strip().split("\n")[0] if r.returncode == 0 else None + return json.loads(path.read_text()) except Exception: - return None + return {} -def get_hermes_sessions(): +def get_local_metrics(hours: int = 24) -> list[dict]: + records = [] + cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) + if not METRICS_DIR.exists(): + return records + for path in sorted(METRICS_DIR.glob("local_*.jsonl")): + for line in path.read_text().splitlines(): + if not line.strip(): + continue + try: + record = json.loads(line) + ts = datetime.fromisoformat(record["timestamp"]) + if ts >= cutoff: + records.append(record) + except Exception: + continue + return records + + +def get_hermes_sessions() -> list[dict]: sessions_file = HERMES_HOME / "sessions" / "sessions.json" if not sessions_file.exists(): return [] @@ -67,7 +126,7 @@ def get_hermes_sessions(): return [] -def get_session_rows(hours=24): +def get_session_rows(hours: int = 24): state_db = HERMES_HOME / "state.db" if not state_db.exists(): return [] @@ -91,14 +150,14 @@ def get_session_rows(hours=24): return [] -def get_heartbeat_ticks(date_str=None): +def get_heartbeat_ticks(date_str: str | None = None) -> list[dict]: if not date_str: date_str = datetime.now().strftime("%Y%m%d") tick_file = TIMMY_HOME / "heartbeat" / f"ticks_{date_str}.jsonl" if not tick_file.exists(): return [] ticks = [] - for line in tick_file.read_text().strip().split("\n"): + for line in tick_file.read_text().splitlines(): if not line.strip(): continue try: @@ -108,42 +167,33 @@ def get_heartbeat_ticks(date_str=None): return ticks -def get_local_metrics(hours=24): - """Read local inference metrics from jsonl files.""" - records = [] - cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) - if not METRICS_DIR.exists(): - return records - for f in sorted(METRICS_DIR.glob("local_*.jsonl")): - for line in f.read_text().strip().split("\n"): - if not line.strip(): - continue - try: - r = json.loads(line) - ts = datetime.fromisoformat(r["timestamp"]) - if ts >= cutoff: - records.append(r) - except Exception: - continue - return records +def get_review_and_issue_state(token: str | None) -> dict: + state = {"prs": [], "review_queue": [], "unassigned": 0} + for repo in CORE_REPOS: + try: + prs = gitea_get(f"/repos/{repo}/pulls?state=open&limit=20", token) + for pr in prs: + pr["_repo"] = repo + state["prs"].append(pr) + except Exception: + continue + try: + issue_prs = gitea_get(f"/repos/{repo}/issues?state=open&limit=50&type=pulls", token) + for item in issue_prs: + assignees = [a.get("login", "") for a in (item.get("assignees") or [])] + if any(name in assignees for name in ("Timmy", "allegro")): + item["_repo"] = repo + state["review_queue"].append(item) + except Exception: + continue + try: + issues = gitea_get(f"/repos/{repo}/issues?state=open&limit=50&type=issues", token) + state["unassigned"] += sum(1 for issue in issues if not issue.get("assignees")) + except Exception: + continue + return state -def get_cron_jobs(): - """Get Hermes cron job status.""" - try: - r = subprocess.run( - ["hermes", "cron", "list", "--json"], - capture_output=True, text=True, timeout=10 - ) - if r.returncode == 0: - return json.loads(r.stdout).get("jobs", []) - except Exception: - pass - return [] - - -# ── Rendering ───────────────────────────────────────────────────────── - DIM = "\033[2m" BOLD = "\033[1m" GREEN = "\033[32m" @@ -154,119 +204,133 @@ RST = "\033[0m" CLR = "\033[2J\033[H" -def render(hours=24): - models = get_ollama_models() - loaded = get_loaded_models() - huey_pid = get_huey_pid() - ticks = get_heartbeat_ticks() +def render(hours: int = 24) -> None: + token = read_token() metrics = get_local_metrics(hours) + local_summary = summarize_local_metrics(metrics) + ticks = get_heartbeat_ticks() + health = get_model_health() + last_tick = get_last_tick() + checkpoint = get_archive_checkpoint() sessions = get_hermes_sessions() session_rows = get_session_rows(hours) - local_summary = summarize_local_metrics(metrics) session_summary = summarize_session_rows(session_rows) - - loaded_names = {m.get("name", "") for m in loaded} - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + gitea = get_review_and_issue_state(token) print(CLR, end="") - print(f"{BOLD}{'=' * 70}") - print(f" TIMMY MODEL DASHBOARD") - print(f" {now} | Huey: {GREEN}PID {huey_pid}{RST if huey_pid else f'{RED}DOWN{RST}'}") - print(f"{'=' * 70}{RST}") + print(f"{BOLD}{'=' * 72}") + print(" TIMMY WORKFLOW DASHBOARD") + print(f" {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"{'=' * 72}{RST}") - # ── LOCAL MODELS ── - print(f"\n {BOLD}LOCAL MODELS (Ollama){RST}") - print(f" {DIM}{'-' * 55}{RST}") - if models: - for m in models: - name = m.get("name", "?") - size_gb = m.get("size", 0) / 1e9 - if name in loaded_names: - status = f"{GREEN}IN VRAM{RST}" - else: - status = f"{DIM}on disk{RST}" - print(f" {name:35s} {size_gb:5.1f}GB {status}") + print(f"\n {BOLD}HEARTBEAT{RST}") + print(f" {DIM}{'-' * 58}{RST}") + if last_tick: + sev = last_tick.get("decision", {}).get("severity", "?") + tick_id = last_tick.get("tick_id", "?") + model_decisions = sum( + 1 + for tick in ticks + if isinstance(tick.get("decision"), dict) + and tick["decision"].get("severity") != "fallback" + ) + print(f" last tick: {tick_id}") + print(f" severity: {sev}") + print(f" ticks today: {len(ticks)} | model decisions: {model_decisions}") else: - print(f" {RED}(Ollama not responding){RST}") + print(f" {DIM}(no heartbeat data){RST}") - # ── LOCAL INFERENCE ACTIVITY ── - print(f"\n {BOLD}LOCAL INFERENCE ({len(metrics)} calls, last {hours}h){RST}") - print(f" {DIM}{'-' * 55}{RST}") + print(f"\n {BOLD}MODEL HEALTH{RST}") + print(f" {DIM}{'-' * 58}{RST}") + if health: + provider = GREEN if health.get("api_responding") else RED + inference = GREEN if health.get("inference_ok") else YELLOW + print(f" provider: {provider}{health.get('api_responding')}{RST}") + print(f" inference: {inference}{health.get('inference_ok')}{RST}") + print(f" models: {', '.join(health.get('models_loaded', [])[:4]) or '(none reported)'}") + else: + print(f" {DIM}(no model_health.json){RST}") + + print(f"\n {BOLD}ARCHIVE PIPELINE{RST}") + print(f" {DIM}{'-' * 58}{RST}") + if checkpoint: + print(f" batches completed: {checkpoint.get('batches_completed', '?')}") + print(f" next offset: {checkpoint.get('next_offset', '?')}") + print(f" phase: {checkpoint.get('phase', '?')}") + else: + print(f" {DIM}(no archive checkpoint yet){RST}") + + print(f"\n {BOLD}LOCAL METRICS ({len(metrics)} calls, last {hours}h){RST}") + print(f" {DIM}{'-' * 58}{RST}") if metrics: - print(f" Tokens: {local_summary['input_tokens']} in | {local_summary['output_tokens']} out | {local_summary['total_tokens']} total") - if local_summary.get('avg_latency_s') is not None: + print( + f" Tokens: {local_summary['input_tokens']} in | " + f"{local_summary['output_tokens']} out | " + f"{local_summary['total_tokens']} total" + ) + if local_summary.get("avg_latency_s") is not None: print(f" Avg latency: {local_summary['avg_latency_s']:.2f}s") - if local_summary.get('avg_tokens_per_second') is not None: + if local_summary.get("avg_tokens_per_second") is not None: print(f" Avg throughput: {GREEN}{local_summary['avg_tokens_per_second']:.2f} tok/s{RST}") - for caller, stats in sorted(local_summary['by_caller'].items()): - err = f" {RED}err:{stats['failed_calls']}{RST}" if stats['failed_calls'] else "" - print(f" {caller:25s} calls:{stats['calls']:4d} tokens:{stats['total_tokens']:5d} {GREEN}ok:{stats['successful_calls']}{RST}{err}") - - print(f"\n {DIM}Models used:{RST}") - for model, stats in sorted(local_summary['by_model'].items(), key=lambda x: -x[1]['calls']): - print(f" {model:30s} {stats['calls']} calls {stats['total_tokens']} tok") + for caller, stats in sorted(local_summary["by_caller"].items()): + err = f" {RED}err:{stats['failed_calls']}{RST}" if stats["failed_calls"] else "" + print( + f" {caller:24s} calls={stats['calls']:3d} " + f"tok={stats['total_tokens']:5d} {GREEN}ok:{stats['successful_calls']}{RST}{err}" + ) else: - print(f" {DIM}(no local calls recorded yet){RST}") + print(f" {DIM}(no local metrics yet){RST}") - # ── HEARTBEAT STATUS ── - print(f"\n {BOLD}HEARTBEAT ({len(ticks)} ticks today){RST}") - print(f" {DIM}{'-' * 55}{RST}") - if ticks: - last = ticks[-1] - decision = last.get("decision", last.get("actions", {})) - if isinstance(decision, dict): - severity = decision.get("severity", "unknown") - reasoning = decision.get("reasoning", "") - sev_color = GREEN if severity == "ok" else YELLOW if severity == "warning" else RED - print(f" Last tick: {last.get('tick_id', '?')}") - print(f" Severity: {sev_color}{severity}{RST}") - if reasoning: - print(f" Reasoning: {reasoning[:65]}") - else: - print(f" Last tick: {last.get('tick_id', '?')}") - actions = last.get("actions", []) - print(f" Actions: {actions if actions else 'none'}") - - model_decisions = sum(1 for t in ticks - if isinstance(t.get("decision"), dict) - and t["decision"].get("severity") != "fallback") - fallback = len(ticks) - model_decisions - print(f" {CYAN}Model: {model_decisions}{RST} | {DIM}Fallback: {fallback}{RST}") - else: - print(f" {DIM}(no ticks today){RST}") - - # ── HERMES SESSIONS / SOVEREIGNTY LOAD ── - local_sessions = [s for s in sessions if "localhost:11434" in str(s.get("base_url", ""))] + print(f"\n {BOLD}SESSION LOAD{RST}") + print(f" {DIM}{'-' * 58}{RST}") + local_sessions = [s for s in sessions if "localhost" in str(s.get("base_url", ""))] cloud_sessions = [s for s in sessions if s not in local_sessions] - print(f"\n {BOLD}HERMES SESSIONS / SOVEREIGNTY LOAD{RST}") - print(f" {DIM}{'-' * 55}{RST}") - print(f" Session cache: {len(sessions)} total | {GREEN}{len(local_sessions)} local{RST} | {YELLOW}{len(cloud_sessions)} cloud{RST}") + print( + f" Session cache: {len(sessions)} total | " + f"{GREEN}{len(local_sessions)} local{RST} | " + f"{YELLOW}{len(cloud_sessions)} remote{RST}" + ) if session_rows: - print(f" Session DB: {session_summary['total_sessions']} total | {GREEN}{session_summary['local_sessions']} local{RST} | {YELLOW}{session_summary['cloud_sessions']} cloud{RST}") - print(f" Token est: {GREEN}{session_summary['local_est_tokens']} local{RST} | {YELLOW}{session_summary['cloud_est_tokens']} cloud{RST}") - print(f" Est cloud cost: ${session_summary['cloud_est_cost_usd']:.4f}") + print( + f" Session DB: {session_summary['total_sessions']} total | " + f"{GREEN}{session_summary['local_sessions']} local{RST} | " + f"{YELLOW}{session_summary['cloud_sessions']} remote{RST}" + ) + print( + f" Token est: {GREEN}{session_summary['local_est_tokens']} local{RST} | " + f"{YELLOW}{session_summary['cloud_est_tokens']} remote{RST}" + ) + print(f" Est remote cost: ${session_summary['cloud_est_cost_usd']:.4f}") else: print(f" {DIM}(no session-db stats available){RST}") - # ── ACTIVE LOOPS ── - print(f"\n {BOLD}ACTIVE LOOPS{RST}") - print(f" {DIM}{'-' * 55}{RST}") - print(f" {CYAN}heartbeat_tick{RST} 10m hermes4:14b DECIDE phase") - print(f" {DIM}model_health{RST} 5m (local check) Ollama ping") - print(f" {DIM}gemini_worker{RST} 20m gemini-2.5-pro aider") - print(f" {DIM}grok_worker{RST} 20m grok-3-fast opencode") - print(f" {DIM}cross_review{RST} 30m gemini+grok PR review") + print(f"\n {BOLD}REVIEW QUEUE{RST}") + print(f" {DIM}{'-' * 58}{RST}") + if gitea["review_queue"]: + for item in gitea["review_queue"][:8]: + repo = item["_repo"].split("/", 1)[1] + print(f" {repo:12s} #{item['number']:<4d} {item['title'][:42]}") + else: + print(f" {DIM}(clear){RST}") - print(f"\n{BOLD}{'=' * 70}{RST}") + print(f"\n {BOLD}OPEN PRS / UNASSIGNED{RST}") + print(f" {DIM}{'-' * 58}{RST}") + print(f" open PRs: {len(gitea['prs'])}") + print(f" unassigned issues: {gitea['unassigned']}") + for pr in gitea["prs"][:6]: + repo = pr["_repo"].split("/", 1)[1] + print(f" PR {repo:10s} #{pr['number']:<4d} {pr['title'][:40]}") + + print(f"\n{BOLD}{'=' * 72}{RST}") print(f" {DIM}Refresh: timmy-dashboard --watch | History: --hours=N{RST}") if __name__ == "__main__": watch = "--watch" in sys.argv hours = 24 - for a in sys.argv[1:]: - if a.startswith("--hours="): - hours = int(a.split("=")[1]) + for arg in sys.argv[1:]: + if arg.startswith("--hours="): + hours = int(arg.split("=", 1)[1]) if watch: try: diff --git a/bin/timmy-status.sh b/bin/timmy-status.sh index fb7cca35..ed569cef 100755 --- a/bin/timmy-status.sh +++ b/bin/timmy-status.sh @@ -1,284 +1,182 @@ #!/usr/bin/env bash -# ── Timmy Loop Status Panel ──────────────────────────────────────────── -# Compact, info-dense sidebar for the tmux development loop. -# Refreshes every 10s. Designed for ~40-col wide pane. +# ── Timmy Status Sidebar ─────────────────────────────────────────────── +# Compact current-state view for the local Hermes + Timmy workflow. # ─────────────────────────────────────────────────────────────────────── -STATE="$HOME/Timmy-Time-dashboard/.loop/state.json" -REPO="$HOME/Timmy-Time-dashboard" -TOKEN=$(cat ~/.hermes/gitea_token 2>/dev/null) -API="http://143.198.27.163:3000/api/v1/repos/rockachopa/Timmy-time-dashboard" +set -euo pipefail -# ── Colors ── -B='\033[1m' # bold -D='\033[2m' # dim -R='\033[0m' # reset -G='\033[32m' # green -Y='\033[33m' # yellow -RD='\033[31m' # red -C='\033[36m' # cyan -M='\033[35m' # magenta -W='\033[37m' # white -BG='\033[42;30m' # green bg -BY='\033[43;30m' # yellow bg -BR='\033[41;37m' # red bg +resolve_gitea_url() { + if [ -n "${GITEA_URL:-}" ]; then + printf '%s\n' "${GITEA_URL%/}" + return 0 + fi + if [ -f "$HOME/.hermes/gitea_api" ]; then + python3 - "$HOME/.hermes/gitea_api" <<'PY' +from pathlib import Path +import sys -# How wide is our pane? -COLS=$(tput cols 2>/dev/null || echo 40) +raw = Path(sys.argv[1]).read_text().strip().rstrip("/") +print(raw[:-7] if raw.endswith("/api/v1") else raw) +PY + return 0 + fi + if [ -f "$HOME/.config/gitea/base-url" ]; then + tr -d '[:space:]' < "$HOME/.config/gitea/base-url" + return 0 + fi + echo "ERROR: set GITEA_URL or create ~/.hermes/gitea_api" >&2 + return 1 +} +resolve_ops_token() { + local token_file + for token_file in \ + "$HOME/.config/gitea/timmy-token" \ + "$HOME/.hermes/gitea_token_vps" \ + "$HOME/.hermes/gitea_token_timmy"; do + if [ -f "$token_file" ]; then + tr -d '[:space:]' < "$token_file" + return 0 + fi + done + return 1 +} + +GITEA_URL="$(resolve_gitea_url)" +CORE_REPOS="${CORE_REPOS:-Timmy_Foundation/the-nexus Timmy_Foundation/timmy-home Timmy_Foundation/timmy-config Timmy_Foundation/hermes-agent}" +TOKEN="$(resolve_ops_token || true)" +[ -z "$TOKEN" ] && echo "WARN: no approved Timmy Gitea token found; status sidebar will use unauthenticated API calls" >&2 + +B='\033[1m' +D='\033[2m' +R='\033[0m' +G='\033[32m' +Y='\033[33m' +RD='\033[31m' +C='\033[36m' + +COLS=$(tput cols 2>/dev/null || echo 48) hr() { printf "${D}"; printf '─%.0s' $(seq 1 "$COLS"); printf "${R}\n"; } while true; do clear - - # ── Header ── - echo -e "${B}${C} ⚙ TIMMY DEV LOOP${R} ${D}$(date '+%H:%M:%S')${R}" + echo -e "${B}${C} TIMMY STATUS${R} ${D}$(date '+%H:%M:%S')${R}" hr - # ── Loop State ── - if [ -f "$STATE" ]; then - eval "$(python3 -c " -import json, sys -with open('$STATE') as f: s = json.load(f) -print(f'CYCLE={s.get(\"cycle\",\"?\")}')" 2>/dev/null)" - STATUS=$(python3 -c "import json; print(json.load(open('$STATE'))['status'])" 2>/dev/null || echo "?") - LAST_OK=$(python3 -c " + python3 - "$HOME/.timmy" "$HOME/.hermes" <<'PY' import json -from datetime import datetime, timezone -s = json.load(open('$STATE')) -t = s.get('last_completed','') -if t: - dt = datetime.fromisoformat(t.replace('Z','+00:00')) - delta = datetime.now(timezone.utc) - dt - mins = int(delta.total_seconds() / 60) - if mins < 60: print(f'{mins}m ago') - else: print(f'{mins//60}h {mins%60}m ago') -else: print('never') -" 2>/dev/null || echo "?") - CLOSED=$(python3 -c "import json; print(len(json.load(open('$STATE')).get('issues_closed',[])))" 2>/dev/null || echo 0) - CREATED=$(python3 -c "import json; print(len(json.load(open('$STATE')).get('issues_created',[])))" 2>/dev/null || echo 0) - ERRS=$(python3 -c "import json; print(len(json.load(open('$STATE')).get('errors',[])))" 2>/dev/null || echo 0) - LAST_ISSUE=$(python3 -c "import json; print(json.load(open('$STATE')).get('last_issue','—'))" 2>/dev/null || echo "—") - LAST_PR=$(python3 -c "import json; print(json.load(open('$STATE')).get('last_pr','—'))" 2>/dev/null || echo "—") - TESTS=$(python3 -c " -import json -s = json.load(open('$STATE')) -t = s.get('test_results',{}) -if t: - print(f\"{t.get('passed',0)} pass, {t.get('failed',0)} fail, {t.get('coverage','?')} cov\") +import sys +from pathlib import Path + +timmy = Path(sys.argv[1]) +hermes = Path(sys.argv[2]) + +last_tick = timmy / "heartbeat" / "last_tick.json" +model_health = hermes / "model_health.json" +checkpoint = timmy / "twitter-archive" / "checkpoint.json" + +if last_tick.exists(): + try: + tick = json.loads(last_tick.read_text()) + sev = tick.get("decision", {}).get("severity", "?") + tick_id = tick.get("tick_id", "?") + print(f" heartbeat {tick_id} severity={sev}") + except Exception: + print(" heartbeat unreadable") else: - print('no data') -" 2>/dev/null || echo "no data") + print(" heartbeat missing") - # Status badge - case "$STATUS" in - working) BADGE="${BY} WORKING ${R}" ;; - idle) BADGE="${BG} IDLE ${R}" ;; - error) BADGE="${BR} ERROR ${R}" ;; - *) BADGE="${D} $STATUS ${R}" ;; - esac - - echo -e " ${B}Status${R} $BADGE ${D}cycle${R} ${B}$CYCLE${R}" - echo -e " ${B}Last OK${R} ${G}$LAST_OK${R} ${D}issue${R} #$LAST_ISSUE ${D}PR${R} #$LAST_PR" - echo -e " ${G}✓${R} $CLOSED closed ${C}+${R} $CREATED created ${RD}✗${R} $ERRS errs" - echo -e " ${D}Tests:${R} $TESTS" - else - echo -e " ${RD}No state file${R}" - fi - - hr - - # ── Ollama Status ── - echo -e " ${B}${M}◆ OLLAMA${R}" - OLLAMA_PS=$(curl -s http://localhost:11434/api/ps 2>/dev/null) - if [ -n "$OLLAMA_PS" ] && echo "$OLLAMA_PS" | python3 -c "import sys,json; json.load(sys.stdin)" &>/dev/null; then - python3 -c " -import json, sys -data = json.loads('''$OLLAMA_PS''') -models = data.get('models', []) -if not models: - print(' \033[2m(no models loaded)\033[0m') -for m in models: - name = m.get('name','?') - vram = m.get('size_vram', 0) / 1e9 - exp = m.get('expires_at','') - print(f' \033[32m●\033[0m {name} \033[2m{vram:.1f}GB VRAM\033[0m') -" 2>/dev/null - else - echo -e " ${RD}● offline${R}" - fi - - # ── Timmy Health ── - TIMMY_HEALTH=$(curl -s --max-time 2 http://localhost:8000/health 2>/dev/null) - if [ -n "$TIMMY_HEALTH" ]; then - python3 -c " -import json -h = json.loads('''$TIMMY_HEALTH''') -status = h.get('status','?') -ollama = h.get('services',{}).get('ollama','?') -model = h.get('llm_model','?') -agent_st = list(h.get('agents',{}).values())[0].get('status','?') if h.get('agents') else '?' -up = int(h.get('uptime_seconds',0)) -hrs, rem = divmod(up, 3600) -mins = rem // 60 -print(f' \033[1m\033[35m◆ TIMMY DASHBOARD\033[0m') -print(f' \033[32m●\033[0m {status} model={model}') -print(f' \033[2magent={agent_st} ollama={ollama} up={hrs}h{mins}m\033[0m') -" 2>/dev/null - else - echo -e " ${B}${M}◆ TIMMY DASHBOARD${R}" - echo -e " ${RD}● unreachable${R}" - fi - - hr - - # ── Open Issues ── - echo -e " ${B}${Y}▶ OPEN ISSUES${R}" - if [ -n "$TOKEN" ]; then - curl -s "${API}/issues?state=open&limit=10&sort=created&direction=desc" \ - -H "Authorization: token $TOKEN" 2>/dev/null | \ - python3 -c " -import json, sys -try: - issues = json.load(sys.stdin) - if not issues: - print(' \033[2m(none)\033[0m') - for i in issues[:10]: - num = i['number'] - title = i['title'][:36] - labels = ','.join(l['name'][:8] for l in i.get('labels',[])) - lbl = f' \033[2m[{labels}]\033[0m' if labels else '' - print(f' \033[33m#{num:<4d}\033[0m {title}{lbl}') - if len(issues) > 10: - print(f' \033[2m... +{len(issues)-10} more\033[0m') -except: print(' \033[2m(fetch failed)\033[0m') -" 2>/dev/null - else - echo -e " ${RD}(no token)${R}" - fi - - # ── Open PRs ── - echo -e " ${B}${G}▶ OPEN PRs${R}" - if [ -n "$TOKEN" ]; then - curl -s "${API}/pulls?state=open&limit=5" \ - -H "Authorization: token $TOKEN" 2>/dev/null | \ - python3 -c " -import json, sys -try: - prs = json.load(sys.stdin) - if not prs: - print(' \033[2m(none)\033[0m') - for p in prs[:5]: - num = p['number'] - title = p['title'][:36] - print(f' \033[32mPR #{num:<4d}\033[0m {title}') -except: print(' \033[2m(fetch failed)\033[0m') -" 2>/dev/null - else - echo -e " ${RD}(no token)${R}" - fi - - hr - - # ── Git Log ── - echo -e " ${B}${D}▶ RECENT COMMITS${R}" - cd "$REPO" 2>/dev/null && git log --oneline --no-decorate -6 2>/dev/null | while read line; do - HASH=$(echo "$line" | cut -c1-7) - MSG=$(echo "$line" | cut -c9- | cut -c1-32) - echo -e " ${C}${HASH}${R} ${D}${MSG}${R}" - done - - hr - - # ── Claims ── - CLAIMS_FILE="$REPO/.loop/claims.json" - if [ -f "$CLAIMS_FILE" ]; then - CLAIMS=$(python3 -c " -import json -with open('$CLAIMS_FILE') as f: c = json.load(f) -active = [(k,v) for k,v in c.items() if v.get('status') == 'active'] -if active: - for k,v in active: - print(f' \033[33m⚡\033[0m #{k} claimed by {v.get(\"agent\",\"?\")[:12]}') +if model_health.exists(): + try: + health = json.loads(model_health.read_text()) + provider_ok = health.get("api_responding") + inference_ok = health.get("inference_ok") + models = len(health.get("models_loaded", []) or []) + print(f" model api={provider_ok} inference={inference_ok} models={models}") + except Exception: + print(" model unreadable") else: - print(' \033[2m(none active)\033[0m') -" 2>/dev/null) - if [ -n "$CLAIMS" ]; then - echo -e " ${B}${Y}▶ CLAIMED${R}" - echo "$CLAIMS" - fi - fi + print(" model missing") - # ── System ── - echo -e " ${B}${D}▶ SYSTEM${R}" - # Disk - DISK=$(df -h / 2>/dev/null | tail -1 | awk '{print $4 " free / " $2}') - echo -e " ${D}Disk:${R} $DISK" - # Memory (macOS) - if command -v memory_pressure &>/dev/null; then - MEM_PRESS=$(memory_pressure 2>/dev/null | grep "System-wide" | head -1 | sed 's/.*: //') - echo -e " ${D}Mem:${R} $MEM_PRESS" - elif [ -f /proc/meminfo ]; then - MEM=$(awk '/MemAvailable/{printf "%.1fGB free", $2/1048576}' /proc/meminfo 2>/dev/null) - echo -e " ${D}Mem:${R} $MEM" - fi - # CPU load - LOAD=$(uptime | sed 's/.*averages: //' | cut -d',' -f1 | xargs) - echo -e " ${D}Load:${R} $LOAD" +if checkpoint.exists(): + try: + cp = json.loads(checkpoint.read_text()) + print(f" archive batches={cp.get('batches_completed', '?')} next={cp.get('next_offset', '?')} phase={cp.get('phase', '?')}") + except Exception: + print(" archive unreadable") +else: + print(" archive missing") +PY hr + echo -e " ${B}freshness${R}" + ~/.hermes/bin/pipeline-freshness.sh 2>/dev/null | sed 's/^/ /' || echo -e " ${Y}unknown${R}" - # ── Notes from last cycle ── - if [ -f "$STATE" ]; then - NOTES=$(python3 -c " + hr + echo -e " ${B}review queue${R}" + python3 - "$GITEA_URL" "$TOKEN" "$CORE_REPOS" <<'PY' import json -s = json.load(open('$STATE')) -n = s.get('notes','') -if n: - lines = n[:150] - if len(n) > 150: lines += '...' - print(lines) -" 2>/dev/null) - if [ -n "$NOTES" ]; then - echo -e " ${B}${D}▶ LAST CYCLE NOTE${R}" - echo -e " ${D}${NOTES}${R}" - hr - fi +import sys +import urllib.request - # Timmy observations - TIMMY_OBS=$(python3 -c " +base = sys.argv[1].rstrip("/") +token = sys.argv[2] +repos = sys.argv[3].split() +headers = {"Authorization": f"token {token}"} if token else {} + +count = 0 +for repo in repos: + try: + req = urllib.request.Request(f"{base}/api/v1/repos/{repo}/issues?state=open&limit=50&type=pulls", headers=headers) + with urllib.request.urlopen(req, timeout=5) as resp: + items = json.loads(resp.read().decode()) + for item in items: + assignees = [a.get("login", "") for a in (item.get("assignees") or [])] + if any(name in assignees for name in ("Timmy", "allegro")): + print(f" {repo.split('/',1)[1]:12s} #{item['number']:<4d} {item['title'][:28]}") + count += 1 + if count >= 6: + raise SystemExit + except SystemExit: + break + except Exception: + continue +if count == 0: + print(" (clear)") +PY + + hr + echo -e " ${B}unassigned${R}" + python3 - "$GITEA_URL" "$TOKEN" "$CORE_REPOS" <<'PY' import json -s = json.load(open('$STATE')) -obs = s.get('timmy_observations','') -if obs: - lines = obs[:120] - if len(obs) > 120: lines += '...' - print(lines) -" 2>/dev/null) - if [ -n "$TIMMY_OBS" ]; then - echo -e " ${B}${M}▶ TIMMY SAYS${R}" - echo -e " ${D}${TIMMY_OBS}${R}" - hr - fi - fi +import sys +import urllib.request - # ── Watchdog: restart loop if it died ────────────────────────────── - LOOP_LOCK="/tmp/timmy-loop.lock" - if [ -f "$LOOP_LOCK" ]; then - LOOP_PID=$(cat "$LOOP_LOCK" 2>/dev/null) - if ! kill -0 "$LOOP_PID" 2>/dev/null; then - echo -e " ${BR} ⚠ LOOP DIED — RESTARTING ${R}" - rm -f "$LOOP_LOCK" - tmux send-keys -t "dev:2.1" "bash ~/.hermes/bin/timmy-loop.sh" Enter 2>/dev/null - fi - else - # No lock file at all — loop never started or was killed - if ! pgrep -f "timmy-loop.sh" >/dev/null 2>&1; then - echo -e " ${BR} ⚠ LOOP NOT RUNNING — STARTING ${R}" - tmux send-keys -t "dev:2.1" "bash ~/.hermes/bin/timmy-loop.sh" Enter 2>/dev/null - fi - fi +base = sys.argv[1].rstrip("/") +token = sys.argv[2] +repos = sys.argv[3].split() +headers = {"Authorization": f"token {token}"} if token else {} - echo -e " ${D}↻ 8s${R}" - sleep 8 +count = 0 +for repo in repos: + try: + req = urllib.request.Request(f"{base}/api/v1/repos/{repo}/issues?state=open&limit=50&type=issues", headers=headers) + with urllib.request.urlopen(req, timeout=5) as resp: + items = json.loads(resp.read().decode()) + for item in items: + if not item.get("assignees"): + print(f" {repo.split('/',1)[1]:12s} #{item['number']:<4d} {item['title'][:28]}") + count += 1 + if count >= 6: + raise SystemExit + except SystemExit: + break + except Exception: + continue +if count == 0: + print(" (none)") +PY + + hr + sleep 10 done