#!/usr/bin/env python3 from __future__ import annotations import hashlib, html, json, math, os, re, shutil, sqlite3, subprocess, time, urllib.error, urllib.request, yaml from collections import Counter from datetime import datetime, timedelta, timezone from pathlib import Path from urllib.parse import urlparse from zoneinfo import ZoneInfo from typing import Any ROOT=Path(__file__).resolve().parents[1] DIGEST_ROOT=Path(os.environ.get('DORIS_DIGEST_DIR','/home/fizzlepoof/.openclaw/workspace/doris-digest')) PUBLIC=ROOT/'public'; DATA=ROOT/'data'; LOGS=ROOT/'logs' PAPERLESS_STATE_PATH=DATA/'paperless_review_state.json' LAT=36.53; LON=-87.36 LOCAL_TZ=ZoneInfo('America/Chicago') HERMES_HOME=Path.home()/'.hermes' HERMES_STATE_DB=HERMES_HOME/'state.db' HERMES_CONFIG=HERMES_HOME/'config.yaml' DASHBOARD_JSON=DATA/'dashboard.json' WIND_WATCHDOG_STATE_PATH=HERMES_HOME/'state'/'wind_alert_watchdog.json' LABELS={ 'astronomy':('🔭','Astronomy'),'homelab':('🏠','Homelab'),'smart_home':('🏡','Smart Home'), 'local':('📍','Local'),'radio':('📻','Ham Radio'),'security':('⚠️','Security'), 'tech':('🧰','Tech'),'us':('📰','News'),'world':('🌍','World'),'cooking':('🍳','Cooking'),'gaming':('🎮','Gaming')} WEATHER_CODES={0:'Clear',1:'Mainly clear',2:'Partly cloudy',3:'Overcast',45:'Fog',48:'Rime fog',51:'Light drizzle',53:'Drizzle',55:'Heavy drizzle',61:'Light rain',63:'Rain',65:'Heavy rain',71:'Light snow',73:'Snow',75:'Heavy snow',80:'Rain showers',81:'Rain showers',82:'Heavy showers',95:'Thunderstorm'} LOW_VALUE_LABELS={'shared','duesoon','submitted','waitingsubmission','notes'} PRIORITY_COLORS={1:'#ff6b6b',2:'#ff9f1c',3:'#ffd166',4:'#8ecae6'} SERVICE_DIRECTORY = [ { 'group': 'Portal', 'items': [ {'name': 'Doris Dashboard', 'url': 'index.html', 'health_url': 'http://127.0.0.1:8787/', 'purpose': 'Morning briefing, alerts, and operator homepage.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8787'}, {'name': 'Services Directory', 'url': 'services.html', 'health_url': 'http://127.0.0.1:8787/services.html', 'purpose': 'Front door into the rest of the stack.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8787'}, ], }, { 'group': 'Productivity / Workflow', 'items': [ {'name': 'Donetick', 'url': 'https://donetick.paccoco.com', 'purpose': 'Chores, recurring work, and household task coordination.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '2021'}, {'name': 'Paperless', 'url': 'https://paperless.paccoco.com', 'purpose': 'Document archive, OCR intake, and household paperwork triage.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '8083'}, {'name': 'n8n', 'url': 'https://n8n.paccoco.com', 'purpose': 'Automation bus for webhooks, alerts, and cross-service glue.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '5678'}, {'name': 'Doris Schoolhouse', 'url': 'https://schoolhouse.paccoco.com', 'purpose': 'Class workflows, uploads, recordings, and school intake.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'NOMAD', 'port': '8091'}, {'name': 'Doris Kitchen', 'url': 'http://10.5.1.16:8092', 'health_url': 'http://10.5.1.16:8092', 'purpose': 'Meal planning, grocery workflows, and kitchen-side operator tasks.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8092'}, ], }, { 'group': 'AI / Search', 'items': [ {'name': 'LiteLLM API', 'url': 'http://10.5.1.6:4000', 'health_url': 'http://10.5.1.6:4000/v1/models', 'purpose': 'Shared model gateway for automations, Schoolhouse, and local tools.', 'exposure': 'LAN', 'state': 'attention', 'host': 'PD', 'port': '4000'}, {'name': 'OpenWebUI', 'url': 'https://openwebui.paccoco.com', 'health_url': 'http://10.5.1.6:8282', 'purpose': 'Chat UI for the lab models, docs, and retrieval workflows.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '8282'}, {'name': 'Qdrant', 'url': 'http://10.5.1.16:6333/dashboard', 'health_url': 'http://10.5.1.16:6333/healthz', 'purpose': 'Vector store for Project NOMAD and local retrieval systems.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '6333/6334'}, {'name': 'Whisper API', 'url': 'http://10.5.1.16:8786/docs', 'health_url': 'http://10.5.1.16:8786/health', 'purpose': 'Fast local transcription endpoint for Schoolhouse and ad-hoc uploads.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8786'}, {'name': 'Ollama (NOMAD)', 'url': 'http://10.5.1.16:11434/api/tags', 'health_url': 'http://10.5.1.16:11434/api/tags', 'purpose': 'Project NOMAD local model runtime and admin surface.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '11434'}, {'name': 'SearXNG', 'url': 'http://10.5.1.6:8888', 'health_url': 'http://10.5.1.6:8888', 'purpose': 'Private metasearch box for research and agent-side browsing.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8888'}, ], }, { 'group': 'Homelab / Control Plane', 'items': [ {'name': 'Grafana', 'url': 'https://grafana.paccoco.com', 'purpose': 'Metrics, dashboards, and alert drill-down.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '3000'}, {'name': 'Gitea', 'url': 'https://gitea.paccoco.com', 'purpose': 'Repos, issues, and source control.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '3002 / 2222'}, {'name': 'Dockhand', 'url': 'http://10.5.1.6:3230', 'health_url': 'http://10.5.1.6:3230', 'purpose': 'Container housekeeping and image/update operations.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3230'}, {'name': 'Uptime Kuma', 'url': 'http://10.5.1.6:3001', 'health_url': 'http://10.5.1.6:3001', 'purpose': 'Availability checks and service-watch status board.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3001'}, {'name': 'MeshMonitor', 'url': 'https://meshmonitor.paccoco.com', 'health_url': 'http://10.5.1.6:8081', 'purpose': 'Meshtastic monitoring, node views, and map/radio telemetry.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '8081'}, ], }, { 'group': 'Nomad Local Apps', 'items': [ {'name': 'Dozzle', 'url': 'http://10.5.1.16:9999', 'health_url': 'http://10.5.1.16:9999', 'purpose': 'Live Docker logs on NOMAD without opening a shell.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '9999'}, {'name': 'Flatnotes', 'url': 'http://10.5.1.16:8200', 'health_url': 'http://10.5.1.16:8200', 'purpose': 'Quick notes and lightweight knowledge capture.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8200'}, {'name': 'Kiwix', 'url': 'http://10.5.1.16:8090', 'health_url': 'http://10.5.1.16:8090', 'purpose': 'Offline library / knowledge mirror for disconnected use.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8090'}, {'name': 'Home Assistant', 'url': '', 'purpose': 'Smart-home control hub documented on PD, but no operator-safe route is exposed from NOMAD yet.', 'exposure': 'PRIVATE', 'state': 'attention', 'host': 'PD', 'port': '8123', 'probe': False, 'badge': 'Manual', 'detail': 'documented active on PD; add a stable route before probing from Doris'}, ], }, ] SECTION_STATE_RANK={'ok':0,'attention':1,'warn':2} def esc(v:Any)->str: return html.escape(str(v or ''),quote=True) def load_json(p:Path,d:Any)->Any: try: return json.loads(p.read_text()) if p.exists() else d except Exception: return d def strip_markup(text:str)->str: text=re.sub(r'<[^>]+>',' ',text or '') return re.sub(r'\s+',' ',html.unescape(text)).strip() def parse_time(v:str)->datetime: try: return datetime.fromisoformat((v or '').replace('Z','+00:00')).astimezone(timezone.utc) except Exception: return datetime.now(timezone.utc) def parse_dt(v:str|None)->datetime|None: if not v: return None try: return datetime.fromisoformat(v.replace('Z','+00:00')) except Exception: return None def c_to_f(c:float|int|None)->str: if c is None: return '—' return f"{round(c*9/5+32)}°F" def kmh_to_mph(k:float|int|None)->str: if k is None: return '—' return f"{round(k*0.621371)} mph" def compact_number(value:int|float|None)->str: if value in (None,''): return '—' try: n=float(value) except Exception: return str(value) abs_n=abs(n) if abs_n >= 1_000_000_000: return f"{n/1_000_000_000:.1f}B" if abs_n >= 1_000_000: return f"{n/1_000_000:.1f}M" if abs_n >= 1_000: return f"{n/1_000:.1f}K" if n.is_integer(): return f"{int(n):,}" return f"{n:,.1f}" def pct_text(value:float|int|None)->str: if value in (None,''): return '—' try: return f"{round(float(value))}%" except Exception: return '—' def percent_value(part:int|float|None,total:int|float|None)->float|None: try: numerator=float(part or 0) denominator=float(total or 0) except Exception: return None if denominator <= 0: return None return (numerator/denominator)*100.0 def usage_window_state(remaining_percent:float|int|None)->str: if remaining_percent in (None,''): return 'attention' try: remaining=float(remaining_percent) except Exception: return 'attention' if remaining <= 15: return 'warn' if remaining <= 35: return 'attention' return 'ok' def find_usage_window(windows:list[dict[str,Any]], label_hint:str)->dict[str,Any] | None: hint=(label_hint or '').strip().lower() for window in windows: label=str(window.get('label') or '').strip().lower() if hint and hint in label: return window return windows[0] if windows else None def total_tokens_value(row:dict[str,Any] | sqlite3.Row)->int: keys=('input_tokens','output_tokens','cache_read_tokens','cache_write_tokens','reasoning_tokens') total=0 for key in keys: try: total += int((row[key] if isinstance(row, sqlite3.Row) else row.get(key,0)) or 0) except Exception: pass return total def read_hermes_model_config()->dict[str,str]: default='unknown'; provider='unknown' try: data=yaml.safe_load(HERMES_CONFIG.read_text(encoding='utf-8')) or {} except Exception: return {'default_model':default,'provider':provider} model_cfg=data.get('model') if isinstance(data,dict) else {} if isinstance(model_cfg,dict): default=str(model_cfg.get('default') or default) provider=str(model_cfg.get('provider') or provider) return {'default_model':default or 'unknown','provider':provider or 'unknown'} def moon_phase(dt:datetime)->dict[str,str]: # Known new moon: 2000-01-06 18:14 UTC; synodic month 29.53058867 days. known=datetime(2000,1,6,18,14,tzinfo=timezone.utc) days=(dt.astimezone(timezone.utc)-known).total_seconds()/86400 age=days%29.53058867 illum=(1-math.cos(2*math.pi*age/29.53058867))/2 names=[(1.85,'New Moon','🌑'),(5.54,'Waxing Crescent','🌒'),(9.23,'First Quarter','🌓'),(12.92,'Waxing Gibbous','🌔'),(16.61,'Full Moon','🌕'),(20.30,'Waning Gibbous','🌖'),(23.99,'Last Quarter','🌗'),(27.68,'Waning Crescent','🌘'),(29.54,'New Moon','🌑')] name,emoji='New Moon','🌑' for limit,n,e in names: if agedict[str,Any]: url=(f'https://api.open-meteo.com/v1/forecast?latitude={LAT}&longitude={LON}' '¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code,apparent_temperature' '&hourly=temperature_2m,precipitation_probability' '&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max' '&timezone=America/Chicago&forecast_days=4') try: with urllib.request.urlopen(url,timeout=12) as r: raw=json.load(r) cur=raw.get('current',{}); daily=raw.get('daily',{}); hourly=raw.get('hourly',{}) probs=hourly.get('precipitation_probability') or [] daily_codes=daily.get('weather_code') or [] return { 'ok':True, 'current':cur, 'daily':daily, 'max_precip':max(probs[:24] or [0]), 'condition':WEATHER_CODES.get(cur.get('weather_code'),'Weather code '+str(cur.get('weather_code'))), 'today_condition':WEATHER_CODES.get(daily_codes[0], WEATHER_CODES.get(cur.get('weather_code'),'Weather code '+str(cur.get('weather_code')))) if daily_codes else WEATHER_CODES.get(cur.get('weather_code'),'Weather code '+str(cur.get('weather_code'))), 'source':'Open-Meteo' } except Exception as e: return {'ok':False,'error':str(e),'source':'Open-Meteo'} def decrypt_donetick()->dict[str,Any]|None: secret=Path('/home/fizzlepoof/.openclaw/workspace/.secrets/donetick_secrets.json.enc') keyfile=Path('/home/fizzlepoof/.openclaw/workspace/.secrets/encryption.key') if not secret.exists() or not keyfile.exists(): return None try: res=subprocess.run(['openssl','enc','-aes-256-cbc','-d','-pbkdf2','-in',str(secret),'-k',keyfile.read_text().strip()],capture_output=True,text=True,timeout=8,check=True) return json.loads(res.stdout).get('donetick') except Exception: return None def donetick_headers()->dict[str,str]|None: sec=decrypt_donetick() if not sec: return None try: login=urllib.request.Request(sec['instance']+sec['jwt_endpoint'],data=json.dumps({'username':sec['service_account']['username'],'password':sec['service_account']['password']}).encode(),headers={'Content-Type':'application/json','Accept':'application/json'},method='POST') with urllib.request.urlopen(login,timeout=12) as r: token=json.loads(r.read())['access_token'] return {'Authorization':'Bearer '+token,'Accept':'application/json','Content-Type':'application/json'} except Exception: return None def combine_states(states:list[str])->str: best='ok' for state in states: if SECTION_STATE_RANK.get(state,-1) > SECTION_STATE_RANK.get(best,-1): best=state return best def fetch_donetick_todos()->list[dict[str,Any]]: sec=decrypt_donetick(); headers=donetick_headers() if not sec or not headers: return [] try: req=urllib.request.Request(sec['instance']+'/api/v1/chores',headers=headers) with urllib.request.urlopen(req,timeout=12) as r: chores=json.loads(r.read()).get('res',[]) users={} try: ureq=urllib.request.Request(sec['instance']+'/api/v1/users',headers=headers) with urllib.request.urlopen(ureq,timeout=12) as r: users={u.get('id'):u.get('displayName') or u.get('username') for u in json.loads(r.read()).get('res',[])} except Exception: pass out=[] for c in chores: if c.get('status') not in (0,None): continue due=c.get('nextDueDate') or '' assignee=users.get(c.get('assignedTo')) or ('Unassigned' if not c.get('assignedTo') else f"User {c.get('assignedTo')}") due_title,state,due_local=due_title_and_state(due) out.append({ 'title':c.get('name','Untitled task'), 'time':due[:16].replace('T',' '), 'body':strip_markup(c.get('description','')), 'assignee':assignee, 'priority':priority_num(c.get('priority')) or 0, 'priority_text':priority_text(c.get('priority')) or '', 'due':due, 'due_title':due_title, 'state':state, 'subtitle':subtitle_text(c), 'chips':visible_label_chips(c), 'source':'Donetick', 'id':c.get('id'), '_sort_overdue':0 if due_local and due_local < datetime.now().astimezone() else (2 if not due_local else 1), '_sort_missing':0 if due_local else 1, '_sort_due':due_local.timestamp() if due_local else 99999999999, '_sort_name':str(c.get('name','')).lower(), }) out.sort(key=lambda x:(x.get('_sort_overdue',9),x.get('_sort_missing',9),x.get('_sort_due',99999999999),x.get('_sort_name',''))) for item in out: for k in ('_sort_overdue','_sort_missing','_sort_due','_sort_name'): item.pop(k,None) return out[:10] except Exception: return [] def priority_text(priority:Any)->str|None: try: p=int(priority) return f'P{p}' if p in (1,2,3,4) else None except Exception: return None def priority_num(priority:Any)->int|None: try: p=int(priority) return p if p in (1,2,3,4) else None except Exception: return None def recurrence_text(task:dict[str,Any])->str|None: ftype=str(task.get('frequencyType','') or '').strip().lower() freq=task.get('frequency') try: freq_num=int(freq) if freq is not None else None except Exception: freq_num=None if not ftype: return None if ftype=='daily': return 'Daily' if freq_num in (None,1) else f'Every {freq_num} days' if ftype=='weekly': return 'Weekly' if freq_num in (None,1) else f'Every {freq_num} weeks' if ftype=='monthly': return 'Monthly' if freq_num in (None,1) else f'Every {freq_num} months' if ftype=='yearly': return 'Yearly' if freq_num in (None,1) else f'Every {freq_num} years' if ftype=='once': return 'One-time' return ftype.capitalize() def due_title_and_state(due_raw:str|None)->tuple[str,str,datetime|None]: dt=parse_dt(due_raw) if not dt: return ('No due date','nodue',None) local=dt.astimezone(LOCAL_TZ) now=datetime.now(LOCAL_TZ) days=(local.date()-now.date()).days time_text=local.strftime('%-I:%M %p') if days < 0: if abs(days)==1: return (f'Overdue • Yesterday {time_text}','overdue',local) return (f"Overdue • {local.strftime('%a, %b %-d')} {time_text}",'overdue',local) if days == 0: return (f'Due today • {time_text}','today',local) if days == 1: return (f'Due tomorrow • {time_text}','tomorrow',local) if days <= 6: return (f"Due {local.strftime('%a')} • {time_text}",'upcoming',local) return (f"Due {local.strftime('%b %-d')} • {time_text}",'upcoming',local) def dedupe_parts(parts:list[str])->list[str]: seen=set(); out=[] for p in parts: if not p: continue key=p.strip().lower() if key in seen: continue seen.add(key); out.append(p) return out def subtitle_text(task:dict[str,Any])->str: parts=[] labels=task.get('labelsV2') or [] label_names=[str(lbl.get('name','')).strip() for lbl in labels if lbl.get('name')] prominent=[x for x in label_names if x.strip().lower() not in LOW_VALUE_LABELS] low=[x for x in label_names if x.strip().lower() in LOW_VALUE_LABELS] parts.extend(prominent[:3]) ptxt=priority_text(task.get('priority')) if ptxt: parts.append(ptxt) rtxt=recurrence_text(task) if rtxt: label_keys={x.strip().lower() for x in label_names} if rtxt.strip().lower() not in label_keys: parts.append(rtxt) if not prominent and low: parts.extend(low[:1]) return ' • '.join(dedupe_parts(parts)) def visible_label_chips(task:dict[str,Any])->list[dict[str,str]]: chips=[] for lbl in task.get('labelsV2') or []: name=str(lbl.get('name','')).strip() if not name or name.lower() in LOW_VALUE_LABELS: continue color=str(lbl.get('color','')).strip() or '#607d8b' chips.append({'name':name,'color':color}) return chips[:4] def run(cmd:list[str])->str: try: return subprocess.check_output(cmd,stderr=subprocess.DEVNULL,text=True,timeout=8).strip() except Exception: return '' def dashboard_service_status()->dict[str,str]: active=run(['systemctl','is-active','doris-dashboard.service']) substate=run(['systemctl','show','-p','SubState','--value','doris-dashboard.service']) pid=run(['systemctl','show','-p','ExecMainPID','--value','doris-dashboard.service']) if active: detail=active if substate and substate != active: detail=f'{active} ({substate})' if active == 'active' and pid and pid != '0': detail=f'{detail} · pid {pid}' return {'value':detail,'state':'ok' if active == 'active' else 'warn'} listener=run(['bash','-lc',"ss -ltn '( sport = :8787 )' | tail -n +2"]) if listener: return {'value':'web listener up on :8787','state':'ok'} return {'value':'no systemd state and nothing listening on :8787','state':'warn'} def dashboard_cron_status()->dict[str,str]: info=load_json(DASHBOARD_JSON,{}) if DASHBOARD_JSON.exists() else {} generated_raw=info.get('generated_at_local') or info.get('generated_at') or '' generated=parse_dt(generated_raw) if not generated and DASHBOARD_JSON.exists(): try: generated=datetime.fromtimestamp(DASHBOARD_JSON.stat().st_mtime, tz=timezone.utc).astimezone(LOCAL_TZ) except Exception: generated=None if not generated: return {'value':'no dashboard generation timestamp yet','state':'warn'} local_generated=generated.astimezone(LOCAL_TZ) now=datetime.now(LOCAL_TZ) age_minutes=max(0,int((now-local_generated).total_seconds()//60)) next_run=local_generated.replace(second=0,microsecond=0) slots=(5,35) probe=now.replace(second=0,microsecond=0) while True: if probe.minute in slots and probe > now: next_run=probe break probe += timedelta(minutes=1) age_text='just now' if age_minutes < 1 else (f'{age_minutes}m ago' if age_minutes < 60 else f'{age_minutes//60}h {age_minutes%60}m ago') value=f'updated {age_text} · next {next_run.strftime("%-I:%M %p")}' state='ok' if age_minutes <= 45 else 'warn' return {'value':value,'state':state} def homelab_pulse(candidates:list[dict[str,Any]])->list[dict[str,str]]: usage=shutil.disk_usage('/home/fizzlepoof/.openclaw/workspace') disk=f'{usage.used/usage.total*100:.0f}% used ({usage.free/1024**3:.1f} GiB free)' dash=dashboard_service_status() cron=dashboard_cron_status() newest=max((parse_time(i.get('collected_at') or i.get('published_at') or '') for i in candidates), default=datetime.now(timezone.utc)) age=(datetime.now(timezone.utc)-newest).total_seconds()/3600 return [ {'label':'Dashboard service','value':dash['value'],'state':dash['state']}, {'label':'Dashboard cron','value':cron['value'],'state':cron['state']}, {'label':'Digest freshness','value':f'{age:.1f}h since newest candidate','state':'ok' if age<8 else 'warn'}, {'label':'Workspace disk','value':disk,'state':'ok' if usage.free/usage.total>.15 else 'warn'}, ] def system_snapshot()->list[dict[str,str]]: uptime_text='unknown' try: uptime_seconds=float(Path('/proc/uptime').read_text().split()[0]) hours=int(uptime_seconds//3600) minutes=int((uptime_seconds%3600)//60) uptime_text=(f'{hours}h {minutes}m' if hours else f'{minutes}m') except Exception: pass load_text='unknown' load_state='ok' try: load1,load5,load15=os.getloadavg() load_text=f'{load1:.2f} / {load5:.2f} / {load15:.2f}' cpu_count=os.cpu_count() or 1 if load1 > cpu_count*1.25: load_state='warn' elif load1 > cpu_count*0.85: load_state='attention' except Exception: load_state='warn' mem_text='unknown' mem_state='ok' try: parts=Path('/proc/meminfo').read_text().splitlines() raw={line.split(':',1)[0]: int(line.split(':',1)[1].strip().split()[0]) for line in parts if ':' in line} total=raw.get('MemTotal',0) avail=raw.get('MemAvailable',0) used=max(total-avail,0) pct=(used/total*100) if total else 0 mem_text=f'{pct:.0f}% used ({used/1024/1024:.1f} / {total/1024/1024:.1f} GiB)' if pct >= 85: mem_state='warn' elif pct >= 70: mem_state='attention' except Exception: mem_state='warn' root_usage=shutil.disk_usage('/') root_pct=root_usage.used/root_usage.total*100 if root_usage.total else 0 root_text=f'{root_pct:.0f}% used ({root_usage.free/1024**3:.1f} GiB free)' return [ {'label':'Host','value':run(['hostname']) or 'nomad','state':'ok'}, {'label':'Uptime','value':uptime_text,'state':'ok'}, {'label':'Load avg','value':load_text,'state':load_state}, {'label':'Memory','value':mem_text,'state':mem_state}, {'label':'Root disk','value':root_text,'state':'warn' if root_pct >= 85 else ('attention' if root_pct >= 70 else 'ok')}, ] TOPIC_STOPWORDS={ 'the','a','an','and','or','to','of','for','in','on','with','after','at','from','is','are','be','as','by','how','why','what','when','your','you','new','this','that','its','their','his','her','our','but','into','over','under','about','amid','through','during','just','says','say','will','can','could','should','would','has','have','had','was','were','not','than','more','less','look','looks','looking','likely' } TOPIC_TOKEN_ALIASES={ 'spacex':'spacex','starship':'starship','psyche':'psyche','nasa':'nasa','mars':'mars','moon':'moon','artemis':'artemis','maven':'maven', 'filing':'ipo','paperwork':'ipo','public':'ipo','ipo':'ipo','s1':'ipo','s-1':'ipo', 'marshals':'marshals','marshal':'marshals','fugitive':'wanted','wanted':'wanted','alias':'alias','identity':'alias','captured':'arrested','custody':'arrested', 'homelab':'homelab','docker':'docker','truenas':'truenas','proxmox':'proxmox','opnsense':'opnsense','wireguard':'wireguard','zigbee':'zigbee','matter':'matter','radio':'radio' } def normalize_topic_tokens(item:dict[str,Any])->list[str]: raw=' '.join(str(item.get(k,'') or '') for k in ('title','summary','url')).lower() raw=raw.replace('u.s.','us').replace('u.s','us').replace('u k','uk') words=re.findall(r"[a-z0-9]+", raw) out=[] for word in words: if len(word) <= 2 or word in TOPIC_STOPWORDS: continue out.append(TOPIC_TOKEN_ALIASES.get(word, word)) return out def explicit_topic_key(item:dict[str,Any])->str|None: raw=' '.join(str(item.get(k,'') or '') for k in ('title','summary','url')).lower() raw=raw.replace('u.s.','us').replace('u.s','us') if 'spacex' in raw and any(term in raw for term in (' ipo', ' s-1', 's1', 'going public', 'going-public', 'paperwork', 'financial filing', 'finances', 'opens its books', 'sec.gov/archives/edgar')): return 'spacex-ipo' if 'psyche' in raw and 'mars' in raw: return 'psyche-mars-flyby' if 'psyche' in raw: return 'psyche-mission' if 'starship' in raw and 'spacex' in raw: return 'spacex-starship' if 'marshals' in raw and 'nashville' in raw and any(term in raw for term in ('wanted', 'fugitive', 'alias', 'false identity')): return 'nashville-marshals-fugitive' return None def topic_key(item:dict[str,Any],docfreq:Counter[str]|None=None)->str: explicit=explicit_topic_key(item) if explicit: return explicit tokens=normalize_topic_tokens(item) if not tokens: return slugify(str(item.get('title') or item.get('url') or 'story')) unique=[] seen=set() for tok in tokens: if tok in seen: continue seen.add(tok) unique.append(tok) if docfreq is None: return '-'.join(unique[:4]) unique.sort(key=lambda tok:(docfreq.get(tok,999999), -len(tok), tok)) return '-'.join(unique[:4]) def score_item(item:dict[str,Any],prefs:dict[str,Any])->float: score=float(item.get('priority',1)) title=str(item.get('title','')) summary=str(item.get('summary','')) source=str(item.get('source','')) category=str(item.get('category') or 'other') text=f"{title} {summary}".lower() if category in prefs.get('boost_categories',[]): score+=2 if category in prefs.get('quiet_categories',[]): score-=3 if source in prefs.get('boost_sources',[]): score+=1.5 for kw in prefs.get('boost_keywords',[]): if kw.lower() in text: score+=1.5 for kw in prefs.get('mute_keywords',[]): if kw.lower() in text: score-=3 practical_terms=( 'guide','explainer','explained','tutorial','comic','formula','study','scientists','mission', 'linux','self-hosted','open source','homelab','home assistant','radio','antenna','weather', 'budget','housing','school','schools','college','habit','learning','science' ) if any(term in text for term in practical_terms): score+=1.2 low_value_terms=( 'celebrity','singer','sexual assault','allegation','allegations','raped','rape','fugitive', 'arrested','on the run','heroin','gossip','lawsuit' ) if any(term in text for term in low_value_terms): score-=4 if category=='local': civic_terms=('budget','tax','funding','school','schools','housing','storm','weather','traffic','road','county','city') if any(term in text for term in civic_terms): score+=1.4 if any(term in text for term in ('fugitive','arrested','shooting','murder','heroin')): score-=4.5 if category in {'us','world'} and any(term in text for term in ('celebrity','singer','sexual assault','allegation','allegations','gossip')): score-=5 if source=='CISA Alerts': terms=[t.lower() for t in prefs.get('homelab_relevance_keywords',[])] if terms and not any(t in text for t in terms): score-=8 return score def select_items(items:list[dict[str,Any]],prefs:dict[str,Any],max_items:int=14)->list[dict[str,Any]]: now=datetime.now(timezone.utc); recent=[] for i in items: if (now-parse_time(i.get('published_at',''))).total_seconds()<96*3600: recent.append(i) docfreq=Counter() for i in recent: docfreq.update(set(normalize_topic_tokens(i))) recent.sort(key=lambda i:(score_item(i,prefs),i.get('published_at','')),reverse=True) out=[]; seen=set(); topic_seen=set(); sources=Counter(); categories=Counter() def consider(limit_per_category:int|None, topic_budget:int=1)->None: for i in recent: if len(out)>=max_items: break url=i.get('url',''); src=i.get('source',''); cat=(i.get('category') or 'other').strip() or 'other'; tkey=topic_key(i,docfreq) if url in seen or sources[src]>=2: continue if tkey in topic_seen and topic_budget <= 0: continue if limit_per_category is not None and categories[cat] >= limit_per_category: continue out.append(i); seen.add(url); sources[src]+=1; categories[cat]+=1 if tkey: topic_seen.add(tkey) # First fill favors breadth across categories and unique topics, then allows a # little more category depth while still suppressing same-topic clones. consider(1, 0) consider(2, 0) consider(3, 0) return out def select_exploration_items(items:list[dict[str,Any]],prefs:dict[str,Any],exclude_urls:set[str]|None=None,max_items:int=4)->list[dict[str,Any]]: now=datetime.now(timezone.utc) exclude_urls=exclude_urls or set() boosted={str(x).strip() for x in prefs.get('boost_categories',[])} quiet={str(x).strip() for x in prefs.get('quiet_categories',[])} preferred=['tech','radio','security','gaming','cooking'] recent=[i for i in items if (now-parse_time(i.get('published_at',''))).total_seconds()<96*3600] docfreq=Counter() for i in recent: docfreq.update(set(normalize_topic_tokens(i))) recent.sort(key=lambda i:(score_item(i,prefs),i.get('published_at','')),reverse=True) seeded_topics={topic_key(i,docfreq) for i in recent if i.get('url','') in exclude_urls} out=[]; seen=set(exclude_urls); topic_seen=set(seeded_topics); categories=Counter(); sources=Counter() def consider(allowed_boosted:bool, only_categories:set[str]|None=None, per_category_limit:int=1)->None: for i in recent: if len(out)>=max_items: break url=i.get('url',''); src=i.get('source',''); cat=(i.get('category') or 'other').strip() or 'other'; tkey=topic_key(i,docfreq) if not url or url in seen or sources[src]>=1 or categories[cat]>=per_category_limit or tkey in topic_seen: continue if cat in quiet: continue if only_categories is not None and cat not in only_categories: continue if not allowed_boosted and cat in boosted: continue out.append(i); seen.add(url); topic_seen.add(tkey); categories[cat]+=1; sources[src]+=1 # Keep this lane genuinely exploratory: adjacent-interest categories first, # then broader off-profile picks, all with one story per topic. consider(False, set(preferred), 1) consider(False, set(preferred), 2) consider(False, None, 1) consider(False, None, 2) return out def label(cat:str)->str: e,n=LABELS.get(cat,('🧩',(cat or 'Other').title())); return f'{e} {n}' def slugify(text:str)->str: return re.sub(r'[^a-z0-9]+','-',(text or '').strip().lower()).strip('-') or 'section' def why(i:dict[str,Any])->str: cat=i.get('category'); src=i.get('source','') if cat=='astronomy': return 'Space/astronomy signal from your YouTube + feed tuning.' if cat=='homelab': return 'Practical self-hosting / infrastructure signal.' if cat=='smart_home': return 'Smart-home/Home Assistant adjacent.' if cat=='local': return 'Local or regional relevance.' if cat=='radio': return 'Ham-radio interest signal.' if cat=='security': return 'Kept only because it appears practically relevant.' return f'Interesting signal from {src}.' def card(i:dict[str,Any], compact:bool=False)->str: summary=(i.get('summary') or '').strip()[:120 if compact else 260] when=(i.get('published_at','')[:16].replace('T',' ')) cls='compact-card' if compact else 'card' why_html='' if compact else f"
Why: {esc(why(i))}
" topic=(i.get('category') or 'other').strip() or 'other' source=(i.get('source') or '').strip() domain=(urlparse(i.get('url') or '').netloc or '').lower().removeprefix('www.') feedback_html='' if not compact else ( f"
" f"" f"" f"
" ) return f"""
{esc(label(i.get('category','')))}{esc(i.get('source'))}{esc(when)} UTC

{esc(i.get('title'))}

{esc(summary)}

{feedback_html}{why_html}
""" def section(title:str,sub:str,items:list[dict[str,Any]],empty:str,compact:bool=True)->str: body=''.join(card(i,compact=compact) for i in items) if items else f"

{esc(empty)}

" style='news-list' if compact else 'grid' return collapsible_section(title,sub,f"
{body}
",slugify(title),open_by_default=True) def collapsible_section(title:str,sub:str,inner_html:str,key:str,open_by_default:bool=True,extra_class:str='',state:str='',smart:bool=False)->str: open_attr=' open' if open_by_default else '' sub_html=f"

{esc(sub)}

" if sub else '' state_class=f' state-{state}' if state else '' smart_attr=f" data-smart-state='{esc(state)}'" if smart and state else '' return f"

{esc(title)}

{sub_html}
{inner_html}
" def inject_dashboard_todos(items:list[dict[str,Any]])->list[dict[str,Any]]: out=list(items or []) title='Paperless notes/tags audit' if not any((x.get('title') or '').strip().lower()==title.lower() for x in out): out.insert(0,{"title":title,"time":"","body":"Review and correct all Paperless notes and tags. Keep this visible until the wider cleanup is actually done.","assignee":"fizzlepoof","priority":2,"priority_text":"P2","due":"","due_title":"Pinned follow-up","state":"today","subtitle":"Dashboard • P2","chips":[],"source":"Dashboard"}) return out def load_paperless_review()->list[dict[str,Any]]: p=DATA/'paperless_review.json' remote=os.environ.get('PAPERLESS_TRIAGE_REMOTE_QUEUE','truenas_admin@10.5.1.6:/mnt/docker-ssd/docker/appdata/n8n/paperless-triage/paperless_review.json') ssh_key=os.environ.get('PAPERLESS_TRIAGE_SSH_KEY','/home/fizzlepoof/.ssh/id_ed25519_andys') if remote: tmp=DATA/'paperless_review.remote.tmp' try: cmd=['scp','-q','-o','ConnectTimeout=8'] if ssh_key and Path(ssh_key).exists(): cmd.extend(['-i',ssh_key]) cmd.extend([remote,str(tmp)]) res=subprocess.run(cmd,capture_output=True,text=True,timeout=12) if res.returncode==0 and tmp.exists(): tmp.replace(p) elif tmp.exists(): tmp.unlink() except Exception: try: tmp.unlink() except Exception: pass try: return json.loads(p.read_text()) except Exception: return [] def load_paperless_state()->dict[str,Any]: return load_json(PAPERLESS_STATE_PATH,{}) or {} def paperless_focus_summary(items:list[dict[str,Any]]|None=None,state:dict[str,Any]|None=None)->dict[str,Any]: items=items if items is not None else load_paperless_review() state=state if state is not None else load_paperless_state() active=[]; flagged=[] for item in items: status=((state.get(str(item.get('id'))) or {}).get('status') or '').lower() if status in {'dismissed','good_to_go'}: continue if status in {'acknowledged','needs_attention'}: flagged.append(item) else: active.append(item) headline=(flagged[0] if flagged else (active[0] if active else {})).get('title') or 'No documents waiting' return {'active_count':len(active),'flagged_count':len(flagged),'headline':headline} def paperless_review_section()->str: items=load_paperless_review() state=load_paperless_state() if not items: return collapsible_section('📄 Paperless Review','No documents waiting.','

No documents waiting.

','paperless-review',open_by_default=False,extra_class='panel') urgency_class={'high':'warn','medium':'info','low':'ok','none':'ok'} active=[]; needs_attention=[] for item in items: status=((state.get(str(item.get('id'))) or {}).get('status') or '').lower() if status in {'dismissed','good_to_go'}: continue if status=='acknowledged': status='needs_attention' (needs_attention if status=='needs_attention' else active).append(item) def render_card(item:dict[str,Any], needs_attention_card:bool=False)->str: triage=item.get('triage',{}) or {} current=item.get('current',{}) or {} urgency=triage.get('urgency','none') summary=strip_markup(triage.get('summary','')) reason=strip_markup(triage.get('reason','')) meta_parts=[x for x in [triage.get('document_type',''), current.get('correspondent','')] if x] meta=' • '.join(str(x) for x in meta_parts) status_class=' needs-attention' if needs_attention_card else '' primary_label='Clear flag' if needs_attention_card else 'Needs attention' primary_action='needs_attention' return f"""
{esc(str(urgency).upper())}{esc(item.get('original_filename') or 'Document #'+str(item.get('id','')))}
{f"View in Paperless" if item.get('archive_url') else ''}

{esc(item.get('title') or triage.get('suggested_title','Untitled'))}

{f"

{esc(summary)}

" if summary else ''} {f"

{esc(meta)}

" if meta else ''} {f"

{esc(reason)}

" if reason else ''}
""" visible_active=active[:3] overflow_active=active[3:] active_cards=''.join(render_card(item,False) for item in visible_active) flagged_cards=''.join(render_card(item,True) for item in needs_attention[:8]) count=len(active) flag_count=len(needs_attention) empty_active="

No active documents waiting.

" body=f"
{active_cards or empty_active}
" if overflow_active: body += f"
Show {esc(str(len(overflow_active)))} more active items
{''.join(render_card(item,False) for item in overflow_active[:8])}
" if flag_count: body += f"
Needs attention {esc(str(flag_count))}
{flagged_cards}

Flagged items stay here so I can revisit them later and, if needed, ask you about the issue.

" if count+flag_count>3: body += f"

Showing {esc(str(min(count,3)))} active up front and keeping the rest tucked away to cut page bloat.

" return collapsible_section(f'📄 Paperless Review ({count} active)',f'{flag_count} flagged for follow-up · review queue written by n8n paperless-intake-triage workflow.',body,'paperless-review',open_by_default=(count+flag_count)>0,extra_class='panel') def list_panel(title:str,items:list[dict[str,Any]],empty:str)->str: if not items: body=f"
  • {esc(empty)}
  • " else: rows=[] for x in items[:8]: kicker=x.get('time') or x.get('title') or x.get('label') headline=x.get('title') if x.get('time') else '' assignee=x.get('assignee') or '' detail=x.get('body') or x.get('summary') or x.get('value') or '' assignee_html=f"{esc(assignee)}" if assignee else '' headline_html=f"
    {esc(headline)}
    " if headline else '' detail_html=f"
    {esc(detail)}
    " if detail else '' rows.append(f"
  • {esc(kicker)}{assignee_html}
    {headline_html}{detail_html}
  • ") body=''.join(rows) return f"

    {esc(title)}

    " def donetick_panel(items:list[dict[str,Any]],empty:str)->str: if not items: return f"

    Donetick

    {esc(empty)}
    " rows=[] for x in items[:6]: chips=''.join(f"{esc(c.get('name',''))}" for c in (x.get('chips') or [])) subtitle_text=(x.get('subtitle') or '').strip() chip_names={str(c.get('name','')).strip().lower() for c in (x.get('chips') or []) if c.get('name')} subtitle_parts=[] for part in [p.strip() for p in subtitle_text.split('•') if p.strip()]: if part.lower() in chip_names: continue subtitle_parts.append(part) subtitle=' • '.join(subtitle_parts) state=esc(x.get('state') or 'upcoming') due_style='' if state=='overdue': due_style=' style="color:#ff6b6b;"' elif x.get('priority') in PRIORITY_COLORS: due_style=f' style="color:{esc(PRIORITY_COLORS[x.get("priority")])};"' assignee_html=f"{esc(x.get('assignee') or '')}" if x.get('assignee') and x.get('assignee')!='Unassigned' else '' chips_html=f"
    {chips}
    " if chips else '' subtitle_html=f"
    {esc(subtitle)}
    " if subtitle else '' done_button=f"" if x.get('id') else '' rows.append(f"
    {esc(x.get('due_title') or x.get('time') or 'No due date')}
    {assignee_html}
    {esc(x.get('title') or 'Untitled task')}
    {chips_html}{subtitle_html}{done_button}
    ") return f"

    Donetick

    {''.join(rows)}
    " def feedback_bootstrap()->str: return """""" def metric_card(title:str,value:str,sub:str,cls:str='')->str: severity=cls if cls in SECTION_STATE_RANK else '' severity_attr=f" data-severity='{esc(severity)}'" if severity else '' return f"
    {esc(title)}{esc(value)}{esc(sub)}
    " def load_wind_watchdog_notice()->dict[str,Any]: state=load_json(WIND_WATCHDOG_STATE_PATH,{}) or {} notice=state.get('dashboard_notice') or {} return notice if notice.get('active') else {} def forecast_panel(weather:dict[str,Any])->str: if not weather.get('ok'): return "" daily=weather.get('daily',{}) or {} days=daily.get('time') or [] highs=daily.get('temperature_2m_max') or [] lows=daily.get('temperature_2m_min') or [] precip=daily.get('precipitation_probability_max') or [] codes=daily.get('weather_code') or [] cards=[] day_slice=days[1:4] if len(days) > 1 else days[:3] start_idx=1 if len(days) > 1 else 0 for offset, day in enumerate(day_slice): idx=start_idx + offset try: dt=datetime.fromisoformat(day) label=dt.strftime('%a') full_label=dt.strftime('%A') except Exception: label=day full_label=day high=highs[idx] if idx < len(highs) else None low=lows[idx] if idx < len(lows) else None rain=precip[idx] if idx < len(precip) else '—' condition=WEATHER_CODES.get(codes[idx],'Forecast') if idx < len(codes) else 'Forecast' cards.append(f"""
    {esc(label)} {esc(full_label)}
    {esc(c_to_f(high))} / {esc(c_to_f(low))}
    {esc(condition)}
    Rain chance {esc(str(rain))}%
    """) if not cards: return "" return collapsible_section('3-day forecast','Next three days: conditions, highs/lows, and rain chance.',f"
    {''.join(cards)}
    ",'forecast',open_by_default=True,extra_class='panel forecast-panel') def weather_overview_panel(weather:dict[str,Any], wind_notice:dict[str,Any]|None=None)->str: if not weather.get('ok'): return collapsible_section('Weather','Current weather unavailable.',f"
    {metric_card('Weather','Unavailable',weather.get('error','API issue'),'warn')}
    ",'weather-overview',open_by_default=True) cur=weather.get('current',{}) or {} daily=weather.get('daily',{}) or {} highs=daily.get('temperature_2m_max') or [] lows=daily.get('temperature_2m_min') or [] precip=daily.get('precipitation_probability_max') or [] today_high=highs[0] if highs else None today_low=lows[0] if lows else None today_rain=precip[0] if precip else weather.get('max_precip',0) notice=wind_notice or {} notice_html='' notice_state='' if notice: notice_state=str(notice.get('state') or 'attention') notice_title=notice.get('title') or 'Weather heads-up' notice_headline=notice.get('headline') or '' notice_body=notice.get('body') or '' notice_timing=notice.get('timing') or '' lead_hours=notice.get('lead_hours') lead_html=f"Lead {esc(str(lead_hours))}h" if lead_hours not in (None,'') else '' timing_html=f"{esc(notice_timing)}" if notice_timing else '' notice_html=f"""
    {esc(notice_title)} {esc(notice_state.upper())}
    {f"{esc(notice_headline)}" if notice_headline else ''} {f"

    {esc(notice_body)}

    " if notice_body else ''}
    {lead_html}{timing_html}
    """ overview=f"""
    Clarksville {esc(weather.get('source',''))}
    {esc(c_to_f(cur.get('temperature_2m')))}
    {esc(weather.get('condition','Current conditions'))}
    Today {esc(c_to_f(today_high))} / {esc(c_to_f(today_low))}
    Feels {esc(c_to_f(cur.get('apparent_temperature')))} Humidity {esc(str(cur.get('relative_humidity_2m','—')))}% Wind {esc(kmh_to_mph(cur.get('wind_speed_10m')))} Rain {esc(str(today_rain))}%
    {notice_html}
    """ sub='Today first, then the next three days.' if not notice else 'Weather first. Longer-range wind heads-up stays here until it is close enough to deserve a ping.' return collapsible_section('Weather',sub,overview,'weather-overview',open_by_default=True,extra_class='panel weather-panel',state=notice_state,smart=bool(notice_state)) def fetch_hermes_usage(days:int=30)->dict[str,Any]: config=read_hermes_model_config() stats={ 'ok':False, 'period_days':days, 'default_model':config.get('default_model','unknown'), 'provider':config.get('provider','unknown'), 'sessions':0, 'messages':0, 'tool_calls':0, 'user_messages':0, 'assistant_messages':0, 'total_tokens':0, 'input_tokens':0, 'output_tokens':0, 'cache_read_tokens':0, 'cache_write_tokens':0, 'reasoning_tokens':0, 'last_24h_tokens':0, 'last_24h_sessions':0, 'last_24h_tool_calls':0, 'last_7d_tokens':0, 'avg_tokens_per_session':0, 'avg_tool_calls_per_session':0, 'cache_share_percent':None, 'models':[], 'platforms':[], 'top_tools':[], 'recent_sessions':[], 'latest_session':None, 'account_windows':[], 'generated_at':datetime.now(LOCAL_TZ).isoformat(), } if not HERMES_STATE_DB.exists(): stats['error']='Hermes state DB not found' return stats now_ts=time.time() cutoff=now_ts-(days*86400) day_cutoff=now_ts-86400 week_cutoff=now_ts-(7*86400) try: conn=sqlite3.connect(f"file:{HERMES_STATE_DB}?mode=ro", uri=True) conn.row_factory=sqlite3.Row cur=conn.cursor() overview=cur.execute(""" SELECT count(*) AS sessions, sum(coalesce(input_tokens,0)) AS input_tokens, sum(coalesce(output_tokens,0)) AS output_tokens, sum(coalesce(cache_read_tokens,0)) AS cache_read_tokens, sum(coalesce(cache_write_tokens,0)) AS cache_write_tokens, sum(coalesce(reasoning_tokens,0)) AS reasoning_tokens, sum(coalesce(api_call_count,0)) AS api_calls FROM sessions WHERE started_at >= ? """,(cutoff,)).fetchone() stats.update({ 'sessions':int((overview['sessions'] or 0) if overview else 0), 'input_tokens':int((overview['input_tokens'] or 0) if overview else 0), 'output_tokens':int((overview['output_tokens'] or 0) if overview else 0), 'cache_read_tokens':int((overview['cache_read_tokens'] or 0) if overview else 0), 'cache_write_tokens':int((overview['cache_write_tokens'] or 0) if overview else 0), 'reasoning_tokens':int((overview['reasoning_tokens'] or 0) if overview else 0), }) stats['total_tokens']=stats['input_tokens']+stats['output_tokens']+stats['cache_read_tokens']+stats['cache_write_tokens']+stats['reasoning_tokens'] stats['cache_share_percent']=percent_value(stats['cache_read_tokens'], stats['total_tokens']) stats['avg_tokens_per_session']=round(stats['total_tokens']/stats['sessions']) if stats['sessions'] else 0 row=cur.execute("SELECT count(*) AS c FROM messages WHERE role != 'session_meta' AND timestamp >= ?",(cutoff,)).fetchone() stats['messages']=int((row['c'] or 0) if row else 0) row=cur.execute("SELECT count(*) AS c FROM messages WHERE role='tool' AND timestamp >= ?",(cutoff,)).fetchone() stats['tool_calls']=int((row['c'] or 0) if row else 0) stats['avg_tool_calls_per_session']=round(stats['tool_calls']/stats['sessions'],1) if stats['sessions'] else 0 row=cur.execute("SELECT count(*) AS c FROM messages WHERE role='user' AND timestamp >= ?",(cutoff,)).fetchone() stats['user_messages']=int((row['c'] or 0) if row else 0) row=cur.execute("SELECT count(*) AS c FROM messages WHERE role='assistant' AND timestamp >= ?",(cutoff,)).fetchone() stats['assistant_messages']=int((row['c'] or 0) if row else 0) row=cur.execute(""" SELECT count(*) AS sessions, sum(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens FROM sessions WHERE started_at >= ? """,(day_cutoff,)).fetchone() stats['last_24h_sessions']=int((row['sessions'] or 0) if row else 0) stats['last_24h_tokens']=int((row['total_tokens'] or 0) if row else 0) row=cur.execute("SELECT count(*) AS c FROM messages WHERE role='tool' AND timestamp >= ?",(day_cutoff,)).fetchone() stats['last_24h_tool_calls']=int((row['c'] or 0) if row else 0) row=cur.execute(""" SELECT sum(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens FROM sessions WHERE started_at >= ? """,(week_cutoff,)).fetchone() stats['last_7d_tokens']=int((row['total_tokens'] or 0) if row else 0) stats['models']=[dict(r) for r in cur.execute(""" SELECT model, count(*) AS sessions, sum(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens FROM sessions WHERE started_at >= ? GROUP BY model ORDER BY total_tokens DESC, sessions DESC LIMIT 5 """,(cutoff,))] stats['platforms']=[dict(r) for r in cur.execute(""" SELECT source, count(*) AS sessions, sum(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens FROM sessions WHERE started_at >= ? GROUP BY source ORDER BY total_tokens DESC, sessions DESC LIMIT 5 """,(cutoff,))] stats['top_tools']=[dict(r) for r in cur.execute(""" SELECT tool_name, count(*) AS calls FROM messages WHERE role='tool' AND tool_name IS NOT NULL AND tool_name != '' AND timestamp >= ? GROUP BY tool_name ORDER BY calls DESC, tool_name ASC LIMIT 8 """,(cutoff,))] stats['recent_sessions']=[dict(r) for r in cur.execute(""" SELECT source, model, started_at, (coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens, coalesce(api_call_count,0) AS api_calls FROM sessions WHERE started_at >= ? ORDER BY started_at DESC LIMIT 5 """,(cutoff,))] for item in stats['recent_sessions']: started=item.get('started_at') try: item['started_local']=datetime.fromtimestamp(float(started), LOCAL_TZ).strftime('%a %-I:%M %p') except Exception: item['started_local']='recent' stats['latest_session']=stats['recent_sessions'][0] if stats['recent_sessions'] else None try: hermes_agent_root=HERMES_HOME/'hermes-agent' hermes_agent_python=hermes_agent_root/'venv'/'bin'/'python' if hermes_agent_python.exists(): script=( "import importlib, json\n" "from zoneinfo import ZoneInfo\n" "fetch_account_usage=getattr(importlib.import_module('agent.account_usage'),'fetch_account_usage')\n" "snapshot=fetch_account_usage('openai-codex')\n" "windows=[]\n" "if snapshot:\n" " local_tz=ZoneInfo('America/Chicago')\n" " for window in getattr(snapshot,'windows',()):\n" " reset_at=getattr(window,'reset_at',None)\n" " used=float(getattr(window,'used_percent',0.0) or 0.0)\n" " windows.append({'label': str(getattr(window,'label','') or ''), 'used_percent': used, 'remaining_percent': max(0.0,100.0-used), 'reset_local': reset_at.astimezone(local_tz).strftime('%a %-I:%M %p') if reset_at else 'unknown'})\n" "print(json.dumps(windows))\n" ) res=subprocess.run([str(hermes_agent_python),'-c',script],capture_output=True,text=True,timeout=20,check=True,cwd=str(hermes_agent_root)) stats['account_windows']=json.loads(res.stdout or '[]') except Exception: stats['account_windows']=[] stats['ok']=True conn.close() return stats except Exception: stats['error']='Hermes telemetry is temporarily unavailable.' return stats def focus_card(title:str,kicker:str,headline:str,body:str,tone:str='')->str: severity='' for token in str(tone).split(): if token in SECTION_STATE_RANK: severity=token break severity_attr=f" data-severity='{esc(severity)}'" if severity else '' return f"
    {esc(kicker)}

    {esc(title)}

    {esc(headline)}

    {esc(body)}

    " def build_focus_cards(local_items:list[dict[str,Any]], todos:list[dict[str,Any]], pulse:list[dict[str,str]], paperless:dict[str,Any], hermes:dict[str,Any])->tuple[str,str]: urgent_todos=[item for item in todos if (item.get('state') or '') in {'overdue','today'} and (item.get('source') or '') != 'Dashboard'] warn_items=[item for item in pulse if item.get('state')=='warn'] local_story=local_items[0] if local_items else None session_window=find_usage_window(hermes.get('account_windows',[]), 'session') if hermes.get('ok') else None session_remaining=session_window.get('remaining_percent') if session_window else None session_reset=str(session_window.get('reset_local') or 'unknown') if session_window else 'unknown' hermes_model=(hermes.get('models') or [{}])[0].get('model') or hermes.get('default_model') or 'your main model' if urgent_todos: chores_headline=f"{len(urgent_todos)} urgent / due today" first_urgent=urgent_todos[0] chores_body=(first_urgent.get('title') or 'You have work waiting') + (f" • {first_urgent.get('due_title')}" if first_urgent.get('due_title') else '') else: chores_headline='No urgent chores' chores_body='Nothing due soon enough to nag you here.' if hermes.get('ok'): if session_remaining is not None: hermes_headline=f"{hermes_model} · {pct_text(session_remaining)} session left" hermes_body=f"{compact_number(hermes.get('last_24h_tokens',0))} tokens in 24h · {compact_number(hermes.get('last_24h_tool_calls',0))} tool calls · resets {session_reset}" else: hermes_headline=f"{hermes_model} · {compact_number(hermes.get('last_24h_tokens',0))} tokens today" hermes_body=f"{compact_number(hermes.get('last_24h_sessions',0))} sessions in 24h · {compact_number(hermes.get('tool_calls',0))} tool calls in {hermes.get('period_days',30)} days" else: hermes_headline='Hermes telemetry unavailable' hermes_body='Could not read local Hermes usage stats right now.' local_state='attention' if local_story else 'ok' chores_state='warn' if urgent_todos else 'ok' paperless_state='warn' if paperless.get('flagged_count',0) else ('ok' if not paperless.get('active_count',0) else 'attention') homelab_state='warn' if warn_items else 'ok' hermes_state=usage_window_state(session_remaining) if hermes.get('ok') else 'attention' cards=[ focus_card( 'Local watch', 'Useful nearby', (local_story.get('title') if local_story else 'Local lane quiet'), (f"{local_story.get('source','Local feed')} • {label(local_story.get('category','local'))}" if local_story else 'Nothing local survived filtering hard enough to earn the homepage.'), f'local {local_state}'.strip() ), focus_card( 'Chores with heat', 'Donetick', chores_headline, chores_body, chores_state ), focus_card( 'Hermes now', 'Usage / budget', hermes_headline, hermes_body, hermes_state ), focus_card( 'Paperless follow-up', 'Queue check', (f"{paperless.get('active_count',0)} active · {paperless.get('flagged_count',0)} flagged" if paperless.get('active_count',0) or paperless.get('flagged_count',0) else 'No documents waiting'), str(paperless.get('headline') or 'No documents waiting'), paperless_state ), focus_card( 'Homelab pulse', 'NOMAD checks', ('Stable' if not warn_items else f"{len(warn_items)} warning{'s' if len(warn_items)!=1 else ''}"), ('; '.join(f"{item.get('label')}: {item.get('value')}" for item in warn_items[:2]) if warn_items else 'Dashboard service, cron, digest freshness, and disk all look fine.'), homelab_state ), ] section_state=combine_states([local_state,chores_state,hermes_state,paperless_state,homelab_state]) return ("
    " + ''.join(cards) + "
    ", section_state) def build_hero_summary(local_items:list[dict[str,Any]], todos:list[dict[str,Any]], pulse:list[dict[str,str]], paperless:dict[str,Any], hermes:dict[str,Any])->str: urgent_todos=len([item for item in todos if (item.get('state') or '') in {'overdue','today'} and (item.get('source') or '') != 'Dashboard']) warn_count=len([item for item in pulse if item.get('state')=='warn']) local_status='local lane has signal' if local_items else 'local lane is quiet' paperless_text=f"{paperless.get('active_count',0)} Paperless item{'s' if paperless.get('active_count',0)!=1 else ''}" hermes_model=(hermes.get('models') or [{}])[0].get('model') or hermes.get('default_model') or 'your main model' session_window=find_usage_window(hermes.get('account_windows',[]), 'session') if hermes.get('ok') else None if hermes.get('ok'): if session_window and session_window.get('remaining_percent') is not None: hermes_text=f"Hermes pushed {compact_number(hermes.get('last_24h_tokens',0))} tokens in the last day on {hermes_model} with {pct_text(session_window.get('remaining_percent'))} session quota left" else: hermes_text=f"Hermes pushed {compact_number(hermes.get('last_24h_tokens',0))} tokens in the last day on {hermes_model}" else: hermes_text='Hermes telemetry is unavailable right now' return f"{urgent_todos} urgent chore{'s' if urgent_todos!=1 else ''}, {paperless_text}, {warn_count} homelab warning{'s' if warn_count!=1 else ''}, {hermes_text}, and the {local_status}." def donetick_focus_signature(todos:list[dict[str,Any]])->str: urgent=[{ 'id': str(item.get('id') or ''), 'title': str(item.get('title') or ''), 'due_title': str(item.get('due_title') or ''), 'due': str(item.get('due') or ''), 'state': str(item.get('state') or ''), } for item in todos if (item.get('state') or '') in {'overdue','today'} and (item.get('source') or '') != 'Dashboard'] payload=json.dumps(urgent,separators=(',',':'),sort_keys=True) return hashlib.sha1(payload.encode('utf-8')).hexdigest()[:16] def list_rows(rows:list[str])->str: return ''.join(f"
  • {row}
  • " for row in rows) if rows else "
  • Nothing to show.
  • " def build_system_section(snapshot:list[dict[str,str]])->str: metrics=''.join(metric_card(item.get('label','Metric'), item.get('value','—'), 'live host state', item.get('state','')) for item in snapshot) subtitle='Live NOMAD host state: uptime, load, memory pressure, and root disk usage.' state=combine_states([str(item.get('state') or 'ok') for item in snapshot]) return collapsible_section('System snapshot',subtitle,f"
    {metrics}
    ",'system-snapshot',open_by_default=(state=='warn'),state=state,smart=True) def build_hermes_section(hermes:dict[str,Any])->str: if not hermes.get('ok'): body="

    Hermes telemetry unavailable

    Local Hermes usage stats could not be read right now.

    " return collapsible_section('Hermes usage',f"Could not read the last {hermes.get('period_days',30)} days of local Hermes state.",body,'hermes-usage',open_by_default=True) session_window=find_usage_window(hermes.get('account_windows',[]), 'session') weekly_window=find_usage_window(hermes.get('account_windows',[]), 'week') account_cards=[] for window in [session_window, weekly_window]: if not window: continue label=str(window.get('label') or 'Usage window') remaining_value=window.get('remaining_percent') remaining=pct_text(remaining_value) used=pct_text(window.get('used_percent')) reset_local=str(window.get('reset_local') or 'unknown') state=usage_window_state(remaining_value) account_cards.append(metric_card(f"Codex {label}",remaining,f"{used} used · resets {reset_local}",state)) latest=hermes.get('latest_session') or {} latest_summary='No recent session rows' if latest: latest_summary=f"{str(latest.get('source') or 'session').title()} · {str(latest.get('started_local') or 'recent')} · {compact_number(latest.get('total_tokens',0))} tokens" metrics=''.join(account_cards + [ metric_card('Default model',str(hermes.get('default_model','unknown')),str(hermes.get('provider','unknown')),'space'), metric_card('Last 24h',compact_number(hermes.get('last_24h_tokens',0)),f"{compact_number(hermes.get('last_24h_sessions',0))} sessions · {compact_number(hermes.get('last_24h_tool_calls',0))} tool calls",'space'), metric_card('7-day tokens',compact_number(hermes.get('last_7d_tokens',0)),'rolling week','space'), metric_card('Cache reuse',pct_text(hermes.get('cache_share_percent')),f"{compact_number(hermes.get('cache_read_tokens',0))} cached of {compact_number(hermes.get('total_tokens',0))}",'space'), metric_card(f"{hermes.get('period_days',30)}-day tokens",compact_number(hermes.get('total_tokens',0)),'all session rows','space'), metric_card('Avg session',compact_number(hermes.get('avg_tokens_per_session',0)),f"{compact_number(hermes.get('avg_tool_calls_per_session',0))} tool calls / session",'space'), metric_card('Sessions / messages',f"{compact_number(hermes.get('sessions',0))} / {compact_number(hermes.get('messages',0))}",'state.db window','space'), metric_card('Latest session',str(latest.get('model') or hermes.get('default_model','unknown')),latest_summary,'space'), ]) model_rows=[] for item in hermes.get('models',[])[:5]: model_rows.append(f"{esc(item.get('model') or 'unknown')} · {compact_number(item.get('sessions',0))} sessions · {compact_number(item.get('total_tokens',0))} tokens") platform_rows=[] for item in hermes.get('platforms',[])[:5]: platform_rows.append(f"{esc((item.get('source') or 'unknown').title())} · {compact_number(item.get('sessions',0))} sessions · {compact_number(item.get('total_tokens',0))} tokens") tool_rows=[] for item in hermes.get('top_tools',[])[:8]: tool_rows.append(f"{esc(item.get('tool_name') or 'tool')} · {compact_number(item.get('calls',0))} calls") recent_rows=[] for item in hermes.get('recent_sessions',[])[:5]: source=(item.get('source') or 'session').title() model=item.get('model') or 'unknown' recent_rows.append(f"{esc(source)} · {esc(item.get('started_local') or 'recent')} · {esc(model)} · {compact_number(item.get('total_tokens',0))} tokens") operator_rows=[ f"Last 24h burn: {compact_number(hermes.get('last_24h_tokens',0))} tokens across {compact_number(hermes.get('last_24h_sessions',0))} sessions", f"Rolling 7-day burn: {compact_number(hermes.get('last_7d_tokens',0))} tokens", f"Cache reuse: {pct_text(hermes.get('cache_share_percent'))}", f"Average session size: {compact_number(hermes.get('avg_tokens_per_session',0))} tokens", ] detail_html=f"""

    Operator read

      {list_rows(operator_rows)}

    Model mix

      {list_rows(model_rows)}

    Platform split

      {list_rows(platform_rows)}

    Top tools

      {list_rows(tool_rows)}

    Recent sessions

      {list_rows(recent_rows)}
    """ body=f"
    {metrics}
    {detail_html}" subtitle=f"Live read from ~/.hermes/state.db over the last {hermes.get('period_days',30)} days, plus current Codex budget windows when available." return collapsible_section('Hermes usage',subtitle,body,'hermes-usage',open_by_default=True) def check_service_health(item:dict[str,Any])->dict[str,str]: if item.get('probe') is False: state=str(item.get('state') or 'attention') badge=str(item.get('badge') or ('Healthy' if state=='ok' else 'Manual')) detail=str(item.get('detail') or 'manual operator check only') return {'state':state,'badge':badge,'detail':detail} target=str(item.get('health_url') or item.get('url') or '').strip() if not target: return {'state':'warn','badge':'Down','detail':'no health URL configured'} started=time.time() req=urllib.request.Request(target,headers={'User-Agent':'DorisDashboard/1.0'}) final_url=target try: with urllib.request.urlopen(req,timeout=8) as r: code=getattr(r,'status',200) or 200 final_url=r.geturl() or target elapsed=max(1,int((time.time()-started)*1000)) detail_parts=[str(code), f'{elapsed} ms'] lower=final_url.lower() if '/auth/resource/' in lower or '/accounts/login/' in lower: detail_parts.append('auth gate') elif final_url.rstrip('/') != target.rstrip('/'): detail_parts.append('redirected') return {'state':'ok','badge':'Healthy','detail':' · '.join(detail_parts)} except urllib.error.HTTPError as e: elapsed=max(1,int((time.time()-started)*1000)) code=e.code if code in {401,403}: return {'state':'ok','badge':'Healthy','detail':f'{code} · {elapsed} ms · auth required'} if code in {404,429}: return {'state':'attention','badge':'Degraded','detail':f'{code} · {elapsed} ms'} if code >= 500: return {'state':'warn','badge':'Down','detail':f'{code} · {elapsed} ms'} return {'state':'attention','badge':'Degraded','detail':f'{code} · {elapsed} ms'} except Exception as e: elapsed=max(1,int((time.time()-started)*1000)) return {'state':'warn','badge':'Down','detail':f'{type(e).__name__} · {elapsed} ms'} def service_group_state(items:list[dict[str,Any]])->str: return combine_states([str(item.get('health',{}).get('state') or item.get('state') or 'attention') for item in items]) def top_nav(active:str='home')->str: items=[ ('Home','index.html','home'), ('Services','services.html','services'), ('Donetick','https://donetick.paccoco.com','external'), ('Paperless','https://paperless.paccoco.com','external'), ('n8n','https://n8n.paccoco.com','external'), ('Grafana','https://grafana.paccoco.com','external'), ('Gitea','https://gitea.paccoco.com','external'), ('Schoolhouse','https://schoolhouse.paccoco.com','external'), ] links=[] for label,url,key in items: classes=['nav-link'] attrs=[] if key==active: classes.append('active') attrs.append("aria-current='page'") if url.startswith('http'): attrs.append("target='_blank'") attrs.append("rel='noreferrer'") links.append(f"{esc(label)}") return "" def build_services_inventory()->tuple[list[dict[str,Any]],list[dict[str,Any]]]: groups=[] flat=[] for group in SERVICE_DIRECTORY: items=[] for entry in group.get('items',[]): item=dict(entry) item['health']=check_service_health(item) item['health_state']=str(item.get('health',{}).get('state') or item.get('state') or 'ok') items.append(item) flat.append(item) groups.append({'group':str(group.get('group') or 'Services'),'items':items,'state':service_group_state(items)}) return groups, flat def build_services_directory(groups_data:list[dict[str,Any]])->str: groups=[] for group in groups_data: cards=[] for item in group.get('items',[]): health=item.get('health',{}) state=str(item.get('health_state') or health.get('state') or item.get('state') or 'ok') exposure=str(item.get('exposure') or 'local').upper() url=str(item.get('url') or '').strip() attrs="target='_blank' rel='noreferrer'" if url.startswith('http') else '' facts=[] if item.get('host'): facts.append(f"Host{esc(item.get('host'))}") if item.get('port'): facts.append(f"Port{esc(item.get('port'))}") facts.append(f"Access{esc(exposure)}") note=str(item.get('note') or '').strip() meta_text=esc(health.get('detail') or 'No live check') if note: meta_text += f" · {esc(note)}" action_html=(f"Open" if url else "No direct route") title_html=(f"{esc(item.get('name') or 'Service')}" if url else esc(item.get('name') or 'Service')) cards.append( f"
    " f"
    {esc(group.get('group','Service'))}" f"

    {title_html}

    " f"
    {esc(health.get('badge') or state.title())}{esc(exposure)}
    " f"

    {esc(item.get('purpose') or '')}

    " f"
    {''.join(facts)}
    " f"
    {meta_text}
    " f"
    {action_html}
    " f"
    " ) groups.append(collapsible_section(str(group.get('group') or 'Services'),'Direct links into the stack.',f"
    {''.join(cards)}
    ",slugify(str(group.get('group') or 'services')),open_by_default=(group.get('state')=='warn'),state=str(group.get('state') or 'ok'),smart=True)) return ''.join(groups) def render_services_inventory(flat_items:list[dict[str,Any]])->str: state_counts=Counter(str(item.get('health_state') or 'attention') for item in flat_items) exposure_counts=Counter(str(item.get('exposure') or 'LOCAL').upper() for item in flat_items) host_counts=Counter(str(item.get('host') or 'Unknown') for item in flat_items) total=len(flat_items) host_bits=''.join(f"
  • {esc(host)}{count} services
  • " for host,count in host_counts.most_common()) exposure_bits=''.join(f"
  • {esc(name)}{count}
  • " for name,count in exposure_counts.most_common()) return ( "
    " f"

    Coverage

    • {total}listed services
    • {state_counts.get('ok',0)}healthy now
    • {state_counts.get('attention',0)}manual/degraded
    • {state_counts.get('warn',0)}down
    " f"

    Access split

      {exposure_bits}
    " f"

    Host map

      {host_bits}
    " "

    Operator rule

    Doris is the launchpad. Public apps can stay behind auth gates; LAN tools should still show a clean probe. Manual cards mean the docs say the service exists but Doris does not yet have a stable operator-safe route for it.

    " "
    " ) def render_services_page(hero_summary:str,candidate_count:int,source_count:int,hero_tokens:str,now:datetime)->str: groups_data, flat_items=build_services_inventory() services_html=build_services_directory(groups_data) inventory_html=render_services_inventory(flat_items) utility_html=collapsible_section('Portal notes','Keep Doris as the front door, not a monolith.',"

    How to use this

    Use Home for today, alerts, and the briefing. Use Services when you want the rest of the stack quickly without hunting bookmarks.

    Design rule

    Shared navigation and status live here; full workflows still stay in their own apps.

    ",'portal-notes',open_by_default=True)+collapsible_section('Inventory','Coverage, access model, and host distribution.',inventory_html,'services-inventory',open_by_default=True) return f"Doris Services
    {top_nav('services')}

    PC Doris Thatcher

    Services Directory

    Shared navigation across your sites, with Doris still acting as the operational front door. {esc(hero_summary)}

    {esc(now.strftime('%a %b %-d, %-I:%M %p'))}Central time
    {candidate_count}News candidates
    {source_count}Sources
    {esc(hero_tokens)}30-day Hermes tokens
    {services_html}

    Operator portal links are curated from the homelab docs and current live dashboard context.

    Local-only on NOMAD unless John says otherwise.

    " def render()->str: candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{}) donetick_todos=fetch_donetick_todos(); todos=inject_dashboard_todos(donetick_todos or load_json(DATA/'todos.json',[])); calendar=load_json(DATA/'calendar.json',[]); notes=load_json(DATA/'notes.json',[]) selected=select_items(candidates,prefs); top_briefing=selected[:4]; top_urls={i.get('url','') for i in top_briefing if i.get('url')}; groups={'space':[],'homelab':[],'local':[],'signals':[]} exploration=select_exploration_items(candidates,prefs,{i.get('url','') for i in selected if i.get('url')},4) for i in selected: if i.get('url','') in top_urls: continue cat=i.get('category') if cat=='astronomy': groups['space'].append(i) elif cat in {'homelab','smart_home','radio'}: groups['homelab'].append(i) elif cat=='local': groups['local'].append(i) else: groups['signals'].append(i) weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates); hermes=fetch_hermes_usage(30); system=system_snapshot() wind_notice=load_wind_watchdog_notice() paperless=paperless_focus_summary() now=datetime.now(LOCAL_TZ); counts=Counter(i.get('category','other') for i in candidates) weather_html=weather_overview_panel(weather,wind_notice) moon_metrics=''.join([ metric_card('Moon',f"{moon['emoji']} {moon['name']}",f"{moon['illumination']} lit, age {moon['age']}",'space'), metric_card('Sky note','Space feeds live','Moon phase is computed locally','space')]) pulse_metrics=''.join(metric_card(x['label'],x['value'],x['state'],x['state']) for x in pulse) forecast_html=forecast_panel(weather) hero_summary=build_hero_summary(groups['local'],todos,pulse,paperless,hermes) focus_html,focus_state=build_focus_cards(groups['local'],todos,pulse,paperless,hermes) focus_signature=donetick_focus_signature(todos) hermes_html=build_hermes_section(hermes) system_html=build_system_section(system) hero_tokens=compact_number(hermes.get('total_tokens',0)) if hermes.get('ok') else '—' primary_html=''.join([ section('Top briefing','Compact scan of what is worth your time.',top_briefing,'Nothing worth bothering you with.',compact=True), weather_html, forecast_html, section('Homelab / Smart Home / Radio','Infrastructure, self-hosting, smart home, and ham radio.',groups['homelab'][:4],'No homelab signal right now.',compact=True), section('Sky / Space','SpaceX, NASA, planetary science, and astronomy.',groups['space'][:4],'No sky signal right now.',compact=True), section('Local','Clarksville / Middle Tennessee items that seem useful.',groups['local'][:3],'No local signal right now.',compact=True), section('New to You','Intentional oddballs outside your usual top lanes, so you can see if any new categories deserve a slot.',exploration[:4],'No worthwhile experimental picks right now.',compact=True), section('Other signals','Everything else that survived filtering.',groups['signals'][:3],'No extra signals.',compact=True), ]) secondary_html=''.join([ collapsible_section('Focus now','What actually deserves attention before you go wandering through feeds.',focus_html,'focus-now',open_by_default=(focus_state=='warn'),state=focus_state,smart=True), system_html, hermes_html, collapsible_section('Agenda','Calendar, Donetick, and pinned notes.',f"
    {list_panel('Calendar',calendar,'Calendar integration not wired yet.')}{donetick_panel(todos,'No open Donetick tasks found; JSON placeholder active.')}{list_panel('Notes',notes,'No notes.')}
    ",'today-overview',open_by_default=False), collapsible_section('Sky','Moon and outside-the-window context that still matters.',f"
    {moon_metrics}
    ",'at-a-glance',open_by_default=False), paperless_review_section(), collapsible_section('Homelab pulse','Safe local checks on NOMAD only.',f"
    {pulse_metrics}
    ",'homelab-pulse',open_by_default=False), ]) return f"""Doris Dashboard
    {top_nav('home')}

    PC Doris Thatcher

    Doris Dashboard

    {esc(hero_summary)}

    Hidden stories: 0
    {esc(now.strftime('%a %b %-d, %-I:%M %p'))}Central time · refreshes every 30m
    {len(candidates)}Candidates
    {len(set(i.get('source','') for i in candidates))}Sources
    {esc(hero_tokens)}30-day Hermes tokens
    {primary_html}
    {feedback_bootstrap()}""" def main()->int: PUBLIC.mkdir(parents=True,exist_ok=True); LOGS.mkdir(parents=True,exist_ok=True); DATA.mkdir(parents=True,exist_ok=True) candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{}) weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates); articles=select_items(candidates,prefs,20); todos=inject_dashboard_todos(fetch_donetick_todos()); hermes=fetch_hermes_usage(30); wind_notice=load_wind_watchdog_notice() generated={'generated_at':datetime.now(timezone.utc).isoformat(),'generated_at_local':datetime.now(LOCAL_TZ).isoformat(),'timezone':'America/Chicago','candidate_count':len(candidates),'source_count':len(set(i.get('source','') for i in candidates))} (DATA/'dashboard.json').write_text(json.dumps(generated,indent=2)+'\n') (DATA/'weather.json').write_text(json.dumps(weather,indent=2)+'\n') (DATA/'wind_alert.json').write_text(json.dumps(wind_notice or {'active':False},indent=2)+'\n') (DATA/'sky.json').write_text(json.dumps({'moon':moon},indent=2)+'\n') (DATA/'homelab.json').write_text(json.dumps(pulse,indent=2)+'\n') (DATA/'articles.json').write_text(json.dumps(articles,indent=2)+'\n') (DATA/'hermes_usage.json').write_text(json.dumps(hermes,indent=2)+'\n') if todos: (DATA/'todos.json').write_text(json.dumps(todos,indent=2)+'\n') (PUBLIC/'index.html').write_text(render(),encoding='utf-8') services_summary=build_hero_summary([item for item in articles if item.get('category')=='local'], todos, pulse, paperless_focus_summary(), hermes) (PUBLIC/'services.html').write_text(render_services_page(services_summary, len(candidates), len(set(i.get('source','') for i in candidates)), compact_number(hermes.get('total_tokens',0)) if hermes.get('ok') else '—', datetime.now(LOCAL_TZ)),encoding='utf-8') return 0 if __name__=='__main__': raise SystemExit(main())