atlas-os/scripts/atlas_activity_export.py
Chaib Aarab 301cc75de9 init: Atlas OS — open-source patterns + tools for solo founder LLM cluster
3 browser-native chat surfaces (atlas-chat / atlas-hud / atlas-command, no build step)
+ 11 Python automation scripts (cluster, tray, finance, reminders, autonomy loops)
+ 2 patterns documented (cluster-then-polish, anti-impulse R1-R10)
MIT licensed, anonymized from production stack.
2026-05-26 16:21:16 +02:00

169 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""
atlas_activity_export.py - Query ActivityWatch API + classify into Atlas categories.
Outputs atlas-activity.json (today summary), pushed to atlas-01 dashboard.
Categories:
sales — Asana, WhatsApp Web, Outlook compose, Atlas Chat/Command/HUD
delivery — VSCode/Claude on existing-client work, atlasworks.nl wp-admin
infra — Claude Code, terminal, github, docker, atlas-* configs
admin — Inkomsten xlsx, Odoo, Knab/ING/Revolut, MEGA folder
learning — YouTube, BiSL/IT material, Obsidian vault read
other — everything else
Run daily via Atlas-Activity-Export schtask + before Friday-Wrap.
"""
import json
import sys
import subprocess
import urllib.request
import urllib.error
from datetime import datetime, timedelta
from pathlib import Path
from collections import defaultdict
AW_URL = "http://localhost:5600"
OUT_LOCAL = Path(r"C:\Users\Gebruiker\AppData\Local\Atlas\analyses\atlas-activity.json")
SSH = "root@<YOUR_VPS_IP>"
REMOTE = "/opt/atlas/dashboard/atlas-activity.json"
# Category classifiers (keyword in title or app)
CATEGORIES = {
"sales": [
"asana", "whatsapp", "atlas chat", "atlas command", "atlas hud",
"outlook.com", "mail.atlasworks", "compose", "messenger",
"instagram.com/direct", "linkedin.com/messag",
],
"delivery": [
"wp-admin", "atlasworks.nl/", "couscousbar", "charif", "bisogno",
"le miris", "kolvari", "dushi", "al mahaba", "kapper", "barber",
"elementor", "woocommerce",
],
"infra": [
"claude code", "claude.ai/", "cmd", "powershell", "terminal", "ms-terminal",
"git", "github", "vscode", "code -",
"docker", "atlas-01", "ssh ", "127.0.0.1", "tailscale",
".py - ", ".ps1 - ", "caddy", "ollama",
],
"admin": [
"inkomsten", "odoo", "knab", "ing.nl", "revolut", "mega",
"vaultwarden", "factuur", "rekeningafschrift", "boekhoud",
],
"learning": [
"youtube.com/watch", "bisl", "udemy", "coursera",
"obsidian", "atlas wiki", "00_atlas",
],
}
def fetch(url):
try:
with urllib.request.urlopen(url, timeout=5) as r:
return json.loads(r.read().decode("utf-8", errors="replace"))
except Exception:
return None
def classify(text):
t = (text or "").lower()
for cat, keywords in CATEGORIES.items():
for kw in keywords:
if kw in t:
return cat
return "other"
def get_today_events():
"""Pull today's window events from aw-watcher-window bucket."""
buckets = fetch(f"{AW_URL}/api/0/buckets/") or {}
win_bucket = next((k for k in buckets if "aw-watcher-window" in k), None)
if not win_bucket: return []
now = datetime.now()
start = datetime(now.year, now.month, now.day).isoformat()
end = now.isoformat()
url = f"{AW_URL}/api/0/buckets/{win_bucket}/events?start={start}&end={end}&limit=10000"
return fetch(url) or []
def get_afk_events():
"""Pull today's AFK events to filter non-active time."""
buckets = fetch(f"{AW_URL}/api/0/buckets/") or {}
afk_bucket = next((k for k in buckets if "aw-watcher-afk" in k), None)
if not afk_bucket: return []
now = datetime.now()
start = datetime(now.year, now.month, now.day).isoformat()
end = now.isoformat()
url = f"{AW_URL}/api/0/buckets/{afk_bucket}/events?start={start}&end={end}&limit=10000"
return fetch(url) or []
def aggregate():
win_events = get_today_events()
afk_events = get_afk_events()
# Build active timeline from AFK (only count non-afk windows)
active_intervals = []
for e in afk_events:
if e.get("data", {}).get("status") == "not-afk":
start = datetime.fromisoformat(e["timestamp"].replace("Z", "+00:00"))
dur = e.get("duration", 0)
active_intervals.append((start, start + timedelta(seconds=dur)))
by_cat = defaultdict(float)
by_app = defaultdict(float)
by_title_top = defaultdict(float)
for e in win_events:
t = datetime.fromisoformat(e["timestamp"].replace("Z", "+00:00"))
dur = e.get("duration", 0)
if dur < 1: continue
# Check if this event is during active (non-afk) time — approximate
is_active = any(s <= t <= en for s, en in active_intervals)
if not is_active: continue
data = e.get("data", {})
app = (data.get("app") or "?").lower()
title = data.get("title", "")
combined = f"{app} {title}"
cat = classify(combined)
by_cat[cat] += dur
by_app[app] += dur
by_title_top[title[:80]] += dur
total = sum(by_cat.values())
cat_pct = {c: round(s / total * 100, 1) if total else 0 for c, s in by_cat.items()}
top_apps = sorted(by_app.items(), key=lambda x: x[1], reverse=True)[:5]
top_titles = sorted(by_title_top.items(), key=lambda x: x[1], reverse=True)[:5]
return {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"date": datetime.now().date().isoformat(),
"active_total_hours": round(total / 3600, 2),
"by_category_seconds": {c: int(s) for c, s in by_cat.items()},
"by_category_pct": cat_pct,
"top_apps": [{"app": a, "hours": round(s / 3600, 2)} for a, s in top_apps],
"top_titles": [{"title": t, "hours": round(s / 3600, 2)} for t, s in top_titles],
# Rule R10 check
"r10_sales_hours": round(by_cat.get("sales", 0) / 3600, 2),
"r10_infra_hours": round(by_cat.get("infra", 0) / 3600, 2),
"r10_pass": by_cat.get("sales", 0) >= by_cat.get("infra", 0),
}
def main():
summary = aggregate()
OUT_LOCAL.parent.mkdir(parents=True, exist_ok=True)
OUT_LOCAL.write_text(json.dumps(summary, indent=2), encoding="utf-8")
print(f"wrote {OUT_LOCAL}")
print(f" active: {summary['active_total_hours']}h today")
print(f" by category: {summary['by_category_pct']}")
print(f" R10 (sales >= infra): {'PASS' if summary['r10_pass'] else 'FAIL'} ({summary['r10_sales_hours']}h sales vs {summary['r10_infra_hours']}h infra)")
try:
subprocess.run(["scp", str(OUT_LOCAL), f"{SSH}:{REMOTE}"], check=True, timeout=20)
print(f"pushed to {SSH}:{REMOTE}")
except Exception as e:
print(f"scp failed: {e}")
if __name__ == "__main__":
main()