983 lines
56 KiB
Python
Executable File
983 lines
56 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
from __future__ import annotations
|
||
|
||
import html, json, math, os, re, shutil, sqlite3, subprocess, time, urllib.error, urllib.request, yaml
|
||
from collections import Counter
|
||
from datetime import datetime, 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'
|
||
|
||
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'}
|
||
|
||
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 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 age<limit: name,emoji=n,e; break
|
||
return {'emoji':emoji,'name':name,'age':f'{age:.1f} days','illumination':f'{illum*100:.0f}%'}
|
||
|
||
def fetch_weather()->dict[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=precipitation_sum,sunrise,sunset&timezone=America/Chicago&forecast_days=3')
|
||
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 []
|
||
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'))),'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 fetch_donetick_todos()->list[dict[str,Any]]:
|
||
sec=decrypt_donetick()
|
||
if not sec: return []
|
||
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']
|
||
headers={'Authorization':'Bearer '+token,'Accept':'application/json'}
|
||
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 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=run(['systemctl','is-active','doris-dashboard.service']) or 'unknown'
|
||
cron='present' if 'DORIS DASHBOARD' in run(['bash','-lc','crontab -l 2>/dev/null']) else 'missing'
|
||
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,'state':'ok' if dash=='active' else 'warn'},
|
||
{'label':'Dashboard cron','value':cron,'state':'ok' if cron=='present' else 'warn'},
|
||
{'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'
|
||
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'
|
||
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 'ok'},
|
||
]
|
||
|
||
def score_item(item:dict[str,Any],prefs:dict[str,Any])->float:
|
||
score=float(item.get('priority',1)); text=f"{item.get('title','')} {item.get('summary','')}".lower()
|
||
if item.get('category') in prefs.get('boost_categories',[]): score+=2
|
||
if item.get('category') in prefs.get('quiet_categories',[]): score-=3
|
||
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
|
||
if item.get('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)
|
||
recent.sort(key=lambda i:(score_item(i,prefs),i.get('published_at','')),reverse=True)
|
||
|
||
out=[]; seen=set(); sources=Counter(); categories=Counter()
|
||
|
||
def consider(limit_per_category:int|None)->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'
|
||
if url in seen or sources[src]>=2: 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
|
||
|
||
# First fill favors breadth across categories, then allows a second item per
|
||
# category, and only then permits a small spillover from the strongest buckets.
|
||
consider(1)
|
||
consider(2)
|
||
consider(3)
|
||
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','smart_home','gaming','cooking','security']
|
||
recent=[i for i in items if (now-parse_time(i.get('published_at',''))).total_seconds()<96*3600]
|
||
recent.sort(key=lambda i:(score_item(i,prefs),i.get('published_at','')),reverse=True)
|
||
|
||
out=[]; seen=set(exclude_urls); 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'
|
||
if not url or url in seen or sources[src]>=1 or categories[cat]>=per_category_limit:
|
||
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); categories[cat]+=1; sources[src]+=1
|
||
|
||
# Keep this lane genuinely exploratory: preferred off-profile categories first,
|
||
# then broader off-profile picks, allowing a little depth if the pool is narrow.
|
||
consider(False, set(preferred), 1)
|
||
consider(False, set(preferred), 3)
|
||
consider(False, None, 1)
|
||
consider(False, None, 3)
|
||
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"<div class='why'>Why: {esc(why(i))}</div>"
|
||
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"<div class='feedback'>"
|
||
f"<button type='button' class='feedback-btn' data-feedback='up' aria-pressed='false' title='Prefer more {esc(topic)} stories'>👍</button>"
|
||
f"<button type='button' class='feedback-btn' data-feedback='down' aria-pressed='false' title='Less like this — hide this story'>👎</button>"
|
||
f"</div>"
|
||
)
|
||
return f"""<article class='{cls} {esc(i.get('category',''))}' data-topic='{esc(topic)}' data-source='{esc(source)}' data-domain='{esc(domain)}'>
|
||
<div class='meta'><span>{esc(label(i.get('category','')))}</span><span>{esc(i.get('source'))}</span><span>{esc(when)} UTC</span></div>
|
||
<h3><a href='{esc(i.get('url'))}' target='_blank' rel='noreferrer'>{esc(i.get('title'))}</a></h3>
|
||
<p>{esc(summary)}</p>{feedback_html}{why_html}</article>"""
|
||
|
||
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"<p class='empty'>{esc(empty)}</p>"
|
||
style='news-list' if compact else 'grid'
|
||
return collapsible_section(title,sub,f"<div class='{style}'>{body}</div>",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='')->str:
|
||
open_attr=' open' if open_by_default else ''
|
||
sub_html=f"<p>{esc(sub)}</p>" if sub else ''
|
||
return f"<details class='collapsible {esc(extra_class)}' data-section-key='{esc(key)}'{open_attr}><summary><div class='collapsible-heading'><h2>{esc(title)}</h2>{sub_html}</div><span class='collapse-indicator' aria-hidden='true'>▾</span></summary><div class='collapsible-body'>{inner_html}</div></details>"
|
||
|
||
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.','<p class="empty">No documents waiting.</p>','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"""<article class='paperless-card {urgency_class.get(urgency,'ok')}{status_class}' data-paperless-id='{esc(item.get('id'))}'>
|
||
<div class='paperless-head'><div class='meta'><span class='urgency {urgency_class.get(urgency,'ok')}' data-urgency='{esc(urgency)}'>{esc(str(urgency).upper())}</span><span>{esc(item.get('original_filename') or 'Document #'+str(item.get('id','')))}</span></div><div class='paperless-actions'><button type='button' class='paperless-btn' data-paperless-action='{primary_action}'>{primary_label}</button><button type='button' class='paperless-btn good' data-paperless-action='good_to_go'>Good to go</button>{f"<a href='{esc(item.get('archive_url',''))}' class='link' target='_blank' rel='noreferrer'>View in Paperless</a>" if item.get('archive_url') else ''}</div></div>
|
||
<h4>{esc(item.get('title') or triage.get('suggested_title','Untitled'))}</h4>
|
||
{f"<p class='summary'>{esc(summary)}</p>" if summary else ''}
|
||
{f"<p class='paperless-meta'>{esc(meta)}</p>" if meta else ''}
|
||
{f"<p class='paperless-reason'>{esc(reason)}</p>" if reason else ''}
|
||
</article>"""
|
||
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="<p class='empty'>No active documents waiting.</p>"
|
||
body=f"<div class='paperless-grid'>{active_cards or empty_active}</div>"
|
||
if overflow_active:
|
||
body += f"<details class='paperless-more'><summary>Show {esc(str(len(overflow_active)))} more active items</summary><div class='paperless-grid overflow-grid'>{''.join(render_card(item,False) for item in overflow_active[:8])}</div></details>"
|
||
if flag_count:
|
||
body += f"<details class='paperless-acked' open><summary>Needs attention <span class='badge'>{esc(str(flag_count))}</span></summary><div class='paperless-grid acked-grid'>{flagged_cards}</div><p class='empty'>Flagged items stay here so I can revisit them later and, if needed, ask you about the issue.</p></details>"
|
||
if count+flag_count>3:
|
||
body += f"<p class='empty'>Showing {esc(str(min(count,3)))} active up front and keeping the rest tucked away to cut page bloat.</p>"
|
||
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"<li class='empty'>{esc(empty)}</li>"
|
||
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"<span class='muted'>{esc(assignee)}</span>" if assignee else ''
|
||
headline_html=f"<div class='panel-item-title'>{esc(headline)}</div>" if headline else ''
|
||
detail_html=f"<div class='panel-item-body'>{esc(detail)}</div>" if detail else ''
|
||
rows.append(f"<li class='panel-item'><div class='panel-item-top'><strong>{esc(kicker)}</strong>{assignee_html}</div>{headline_html}{detail_html}</li>")
|
||
body=''.join(rows)
|
||
return f"<section class='panel'><h2>{esc(title)}</h2><ul>{body}</ul></section>"
|
||
|
||
def donetick_panel(items:list[dict[str,Any]],empty:str)->str:
|
||
if not items:
|
||
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='empty'>{esc(empty)}</div></section>"
|
||
rows=[]
|
||
for x in items[:6]:
|
||
chips=''.join(f"<span class='task-chip' style='background:{esc(c.get('color','#607d8b'))};'>{esc(c.get('name',''))}</span>" 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"<span class='task-assignee'>{esc(x.get('assignee') or '')}</span>" if x.get('assignee') and x.get('assignee')!='Unassigned' else ''
|
||
chips_html=f"<div class='task-chips'>{chips}</div>" if chips else ''
|
||
subtitle_html=f"<div class='task-subtitle'>{esc(subtitle)}</div>" if subtitle else ''
|
||
rows.append(f"<article class='task-card {state}'><div class='task-top'><div class='task-state-dot' aria-hidden='true'></div><div class='task-due'{due_style}>{esc(x.get('due_title') or x.get('time') or 'No due date')}</div>{assignee_html}</div><div class='task-name'>{esc(x.get('title') or 'Untitled task')}</div>{chips_html}{subtitle_html}</article>")
|
||
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='task-grid'>{''.join(rows)}</div></section>"
|
||
|
||
def feedback_bootstrap()->str:
|
||
return """<script>
|
||
(()=>{
|
||
const KEY='doris-dashboard-topic-prefs-v2';
|
||
const HIDDEN_CARD_KEY='doris-dashboard-hidden-cards-v1';
|
||
const COLLAPSE_KEY='doris-dashboard-collapsed-v1';
|
||
const safeRead=()=>{try{return JSON.parse(localStorage.getItem(KEY)||'{"hidden":[]}')}catch(e){return {hidden:[]}}};
|
||
const safeHiddenCards=()=>{try{return JSON.parse(localStorage.getItem(HIDDEN_CARD_KEY)||'[]')}catch(e){return []}};
|
||
const safeCollapse=()=>{try{return JSON.parse(localStorage.getItem(COLLAPSE_KEY)||'{}')}catch(e){return {}}};
|
||
const save=(prefs)=>localStorage.setItem(KEY, JSON.stringify({hidden:[...new Set(prefs.hidden||[])]}));
|
||
const saveHiddenCards=(ids)=>localStorage.setItem(HIDDEN_CARD_KEY, JSON.stringify([...new Set(ids||[])]));
|
||
const prefs=safeRead();
|
||
let hiddenCards=safeHiddenCards();
|
||
const collapsed=safeCollapse();
|
||
const cards=[...document.querySelectorAll('[data-topic]')];
|
||
const sections=[...document.querySelectorAll('details[data-section-key]')];
|
||
const toast=document.createElement('div');
|
||
toast.className='dashboard-toast';
|
||
toast.hidden=true;
|
||
document.body.appendChild(toast);
|
||
let lastAction=null;
|
||
let toastTimer=null;
|
||
const postJson=(url,payload)=>fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.ok?r.json():r.json().catch(()=>({})).then(err=>Promise.reject(err)));
|
||
const cardId=(card)=>card.querySelector('h3 a')?.getAttribute('href')||card.querySelector('h3 a')?.textContent||card.dataset.topic||Math.random().toString(36).slice(2);
|
||
cards.forEach(card=>{card.dataset.cardId=cardId(card);});
|
||
const setToast=(html)=>{
|
||
toast.innerHTML=html;
|
||
toast.hidden=false;
|
||
clearTimeout(toastTimer);
|
||
toastTimer=setTimeout(()=>{toast.hidden=true;},4500);
|
||
};
|
||
const sync=()=>{
|
||
cards.forEach(card=>{
|
||
const topic=card.dataset.topic||'other';
|
||
const hidden=(prefs.hidden||[]).includes(topic) || hiddenCards.includes(card.dataset.cardId||'');
|
||
card.style.display=hidden?'none':'';
|
||
card.classList.remove('topic-liked');
|
||
card.querySelectorAll('[data-feedback]').forEach(btn=>{
|
||
btn.setAttribute('aria-pressed', 'false');
|
||
btn.classList.toggle('active-up', false);
|
||
btn.classList.toggle('active-down', false);
|
||
});
|
||
});
|
||
const badge=document.querySelector('[data-hidden-topics-count]');
|
||
if (badge) badge.textContent=String((prefs.hidden||[]).length);
|
||
};
|
||
sections.forEach(sec=>{
|
||
const key=sec.dataset.sectionKey;
|
||
if(Object.prototype.hasOwnProperty.call(collapsed,key)) sec.open=!collapsed[key];
|
||
sec.addEventListener('toggle',()=>{
|
||
collapsed[key]=!sec.open;
|
||
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(collapsed));
|
||
});
|
||
});
|
||
document.addEventListener('click', (ev)=>{
|
||
const btn=ev.target.closest('[data-feedback]');
|
||
if(!btn) return;
|
||
const card=btn.closest('[data-topic]');
|
||
if(!card) return;
|
||
const topic=card.dataset.topic||'other';
|
||
const id=card.dataset.cardId||'';
|
||
if(btn.dataset.feedback==='down'){
|
||
lastAction={prefs:JSON.parse(JSON.stringify(prefs)), hiddenCards:[...hiddenCards]};
|
||
hiddenCards=hiddenCards.filter(x=>x!==id);
|
||
hiddenCards.push(id);
|
||
saveHiddenCards(hiddenCards);
|
||
setToast(`Right — less <strong>${topic}</strong> like this, and this story’s off the board. <button type="button" class="toast-action" data-undo-topic>Undo</button>`);
|
||
} else {
|
||
lastAction={prefs:JSON.parse(JSON.stringify(prefs)), hiddenCards:[...hiddenCards]};
|
||
hiddenCards=hiddenCards.filter(x=>x!==id);
|
||
hiddenCards.push(id);
|
||
saveHiddenCards(hiddenCards);
|
||
setToast(`Right — I’ll look for more <strong>${topic}</strong> like this, and I’ve cleared this story off the board. <button type="button" class="toast-action" data-undo-topic>Undo</button>`);
|
||
}
|
||
save(prefs); sync();
|
||
postJson('/api/topic-feedback',{term:topic,vote:btn.dataset.feedback==='down'?'down':'up',source:card.dataset.source||'',domain:card.dataset.domain||''}).catch(err=>console.warn('topic feedback failed',err));
|
||
});
|
||
document.addEventListener('click',(ev)=>{
|
||
const undo=ev.target.closest('[data-undo-topic]');
|
||
if(!undo || !lastAction) return;
|
||
prefs.hidden=[...(lastAction.prefs.hidden||[])];
|
||
hiddenCards=[...(lastAction.hiddenCards||[])];
|
||
save(prefs); saveHiddenCards(hiddenCards); sync();
|
||
setToast('Undid the last topic/story action.');
|
||
});
|
||
document.addEventListener('click',(ev)=>{
|
||
const btn=ev.target.closest('[data-paperless-action]');
|
||
if(!btn) return;
|
||
const card=btn.closest('[data-paperless-id]');
|
||
if(!card) return;
|
||
const id=card.dataset.paperlessId;
|
||
const action=btn.dataset.paperlessAction;
|
||
btn.disabled=true;
|
||
postJson('/api/paperless-action',{id,action}).then(()=>{
|
||
window.location.reload();
|
||
}).catch(err=>{console.warn('paperless action failed',err); btn.disabled=false;});
|
||
});
|
||
const reset=document.querySelector('[data-reset-topic-prefs]');
|
||
if(reset){reset.addEventListener('click',()=>{prefs.hidden=[]; hiddenCards=[]; save(prefs); saveHiddenCards(hiddenCards); sync(); setToast('Reset hidden topics and local story hides.'); postJson('/api/topic-feedback/reset',{}).catch(err=>console.warn('reset feedback failed',err));});}
|
||
sync();
|
||
})();
|
||
</script>"""
|
||
|
||
def metric_card(title:str,value:str,sub:str,cls:str='')->str:
|
||
return f"<div class='metric {esc(cls)}'><span>{esc(title)}</span><strong>{esc(value)}</strong><em>{esc(sub)}</em></div>"
|
||
|
||
def forecast_panel(weather:dict[str,Any])->str:
|
||
if not weather.get('ok'):
|
||
return ""
|
||
daily=weather.get('daily',{}) or {}
|
||
days=daily.get('time') or []
|
||
precip=daily.get('precipitation_sum') or []
|
||
sunrise=daily.get('sunrise') or []
|
||
sunset=daily.get('sunset') or []
|
||
cards=[]
|
||
for idx, day in enumerate(days[:3]):
|
||
try:
|
||
label=datetime.fromisoformat(day).strftime('%a')
|
||
except Exception:
|
||
label=day
|
||
rain=precip[idx] if idx < len(precip) else '—'
|
||
rise=(sunrise[idx][-5:] if idx < len(sunrise) and sunrise[idx] else '—')
|
||
set_=(sunset[idx][-5:] if idx < len(sunset) and sunset[idx] else '—')
|
||
cards.append(f"""<article class='forecast-card'>
|
||
<span class='forecast-day'>{esc(label)}</span>
|
||
<strong>{esc(str(rain))} mm rain</strong>
|
||
<em>{esc(rise)} sunrise · {esc(set_)} sunset</em>
|
||
</article>""")
|
||
if not cards:
|
||
return ""
|
||
return collapsible_section('3-day forecast','Rain, sunrise, and sunset over the next three days.',f"<div class='forecast-grid'>{''.join(cards)}</div>",'forecast',open_by_default=False,extra_class='panel forecast-panel')
|
||
|
||
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,
|
||
'models':[],
|
||
'platforms':[],
|
||
'top_tools':[],
|
||
'recent_sessions':[],
|
||
'generated_at':datetime.now(LOCAL_TZ).isoformat(),
|
||
}
|
||
if not HERMES_STATE_DB.exists():
|
||
stats['error']='Hermes state DB not found'
|
||
return stats
|
||
cutoff=time.time()-(days*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']
|
||
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)
|
||
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)
|
||
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['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:
|
||
return f"<article class='focus-card {esc(tone)}'><span class='focus-kicker'>{esc(kicker)}</span><h3>{esc(title)}</h3><strong>{esc(headline)}</strong><p>{esc(body)}</p></article>"
|
||
|
||
|
||
def build_focus_cards(local_items:list[dict[str,Any]], todos:list[dict[str,Any]], pulse:list[dict[str,str]], paperless:dict[str,Any])->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
|
||
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.'
|
||
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.'),
|
||
'local' if local_story else ''
|
||
),
|
||
focus_card(
|
||
'Chores with heat',
|
||
'Donetick',
|
||
chores_headline,
|
||
chores_body,
|
||
'warn' if urgent_todos else 'ok'
|
||
),
|
||
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'),
|
||
'warn' if paperless.get('flagged_count',0) else ('ok' if not paperless.get('active_count',0) else '')
|
||
),
|
||
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.'),
|
||
'warn' if warn_items else 'ok'
|
||
),
|
||
]
|
||
return "<section class='focus-grid'>" + ''.join(cards) + "</section>"
|
||
|
||
|
||
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'
|
||
return f"{urgent_todos} urgent chore{'s' if urgent_todos!=1 else ''}, {paperless_text}, {warn_count} homelab warning{'s' if warn_count!=1 else ''}, and Hermes has mostly been running on {hermes_model} while the {local_status}."
|
||
|
||
|
||
def list_rows(rows:list[str])->str:
|
||
return ''.join(f"<li>{row}</li>" for row in rows) if rows else "<li class='empty'>Nothing to show.</li>"
|
||
|
||
|
||
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.'
|
||
return collapsible_section('System snapshot',subtitle,f"<section class='metrics metrics-compact'>{metrics}</section>",'system-snapshot',open_by_default=True)
|
||
|
||
|
||
def build_hermes_section(hermes:dict[str,Any])->str:
|
||
if not hermes.get('ok'):
|
||
body="<section class='summary-grid'><article class='summary-card'><h3>Hermes telemetry unavailable</h3><p>Local Hermes usage stats could not be read right now.</p></article></section>"
|
||
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)
|
||
metrics=''.join([
|
||
metric_card('Default model',str(hermes.get('default_model','unknown')),str(hermes.get('provider','unknown')),'space'),
|
||
metric_card(f"{hermes.get('period_days',30)}-day tokens",compact_number(hermes.get('total_tokens',0)),'all session rows','space'),
|
||
metric_card('Sessions / messages',f"{compact_number(hermes.get('sessions',0))} / {compact_number(hermes.get('messages',0))}",'state.db window','space'),
|
||
metric_card('Tool calls',compact_number(hermes.get('tool_calls',0)),f"{compact_number(hermes.get('user_messages',0))} user · {compact_number(hermes.get('assistant_messages',0))} assistant",'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")
|
||
detail_html=f"""
|
||
<section class='summary-grid'>
|
||
<article class='summary-card'>
|
||
<h3>Model mix</h3>
|
||
<ul>{list_rows(model_rows)}</ul>
|
||
</article>
|
||
<article class='summary-card'>
|
||
<h3>Platform split</h3>
|
||
<ul>{list_rows(platform_rows)}</ul>
|
||
</article>
|
||
<article class='summary-card'>
|
||
<h3>Top tools</h3>
|
||
<ul>{list_rows(tool_rows)}</ul>
|
||
</article>
|
||
<article class='summary-card'>
|
||
<h3>Recent sessions</h3>
|
||
<ul>{list_rows(recent_rows)}</ul>
|
||
</article>
|
||
</section>
|
||
"""
|
||
body=f"<section class='metrics hermes-metrics'>{metrics}</section>{detail_html}"
|
||
subtitle=f"Live read from ~/.hermes/state.db over the last {hermes.get('period_days',30)} days."
|
||
return collapsible_section('Hermes usage',subtitle,body,'hermes-usage',open_by_default=True)
|
||
|
||
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()
|
||
paperless=paperless_focus_summary()
|
||
now=datetime.now(LOCAL_TZ); counts=Counter(i.get('category','other') for i in candidates)
|
||
if weather['ok']:
|
||
cur=weather['current']; daily=weather.get('daily',{})
|
||
weather_metrics=''.join([
|
||
metric_card('Clarksville',c_to_f(cur.get('temperature_2m')),weather.get('condition',''), 'weather'),
|
||
metric_card('Feels / humidity',f"{c_to_f(cur.get('apparent_temperature'))} / {cur.get('relative_humidity_2m','—')}%",'right now','weather'),
|
||
metric_card('Wind',kmh_to_mph(cur.get('wind_speed_10m')),'Open-Meteo','weather'),
|
||
metric_card('Rain chance',f"{weather.get('max_precip',0)}%",'next 24h','weather'),
|
||
metric_card('Sunrise / sunset',f"{(daily.get('sunrise') or ['—'])[0][-5:]} / {(daily.get('sunset') or ['—'])[0][-5:]}",'local time','weather')])
|
||
else:
|
||
weather_metrics=metric_card('Weather','Unavailable',weather.get('error','API issue'),'warn')
|
||
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=build_focus_cards(groups['local'],todos,pulse,paperless)
|
||
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),
|
||
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=True),
|
||
system_html,
|
||
hermes_html,
|
||
collapsible_section('Agenda','Calendar, Donetick, and pinned notes.',f"<section class='today'>{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.')}</section>",'today-overview',open_by_default=False),
|
||
collapsible_section('Environment','Weather, sky, and outside-the-window context.',f"<section class='metrics metrics-compact'>{weather_metrics}{moon_metrics}</section>",'at-a-glance',open_by_default=False),
|
||
forecast_html,
|
||
paperless_review_section(),
|
||
collapsible_section('Homelab pulse','Safe local checks on NOMAD only.',f"<div class='metrics metrics-compact'>{pulse_metrics}</div>",'homelab-pulse',open_by_default=False),
|
||
])
|
||
return f"""<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><meta http-equiv='refresh' content='1800'><title>Doris Dashboard</title><link rel='stylesheet' href='style.css'></head><body><main>
|
||
<header class='hero'><div><p class='eyebrow'>PC Doris Thatcher</p><h1>Doris Dashboard</h1><p class='sub'>{esc(hero_summary)}</p><div class='hero-actions'><button type='button' class='reset-btn' data-reset-topic-prefs>Reset hidden topics</button><span class='muted'>Hidden topics: <strong data-hidden-topics-count>0</strong></span></div></div><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Central time · refreshes every 30m</span></div><div><strong>{len(candidates)}</strong><span>Candidates</span></div><div><strong>{len(set(i.get('source','') for i in candidates))}</strong><span>Sources</span></div><div><strong>{esc(hero_tokens)}</strong><span>30-day Hermes tokens</span></div></div></header>
|
||
<div class='dashboard-shell'><section class='primary-column'>{primary_html}</section><aside class='secondary-column'>{secondary_html}</aside></div>
|
||
<footer><p>Category mix: {esc(', '.join(f'{k}: {v}' for k,v in counts.most_common(10)))}</p><p>Local-only on NOMAD unless John says otherwise. Weather from Open-Meteo; moon phase calculated locally. Hermes stats come from ~/.hermes/state.db.</p></footer></main>{feedback_bootstrap()}</body></html>"""
|
||
|
||
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)
|
||
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/'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')
|
||
return 0
|
||
if __name__=='__main__': raise SystemExit(main())
|