commit 301cc75de91861981d2abff4170cf45b50f59c3c Author: Chaib Aarab Date: Tue May 26 16:21:16 2026 +0200 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81f2e23 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.env +*.log +.DS_Store +Thumbs.db +node_modules/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d9bf2c8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Atlas Corporation B.V. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b7fa14a --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# πŸ›°οΈ Atlas OS + +> Open-source patterns + tools for solo founders running a multi-node local LLM cluster + an own AI-first workspace. + +Built by [Atlas Corporation](https://atlascorporation.nl) β€” extracted from a working production stack. MIT licensed. + +## What's inside + +### Browser-native LLM surfaces (no build step, no React) + +| File | What | Lines | +|---|---|---| +| `web/atlas-chat.html` | Mobile-first chat UI for Ollama clusters + optional Claude API. Streaming, localStorage history, multi-model picker, Vault RAG toggle | ~520 | +| `web/atlas-hud.html` | Iron Man-style ambient HUD: camera feed + face detection (face-api.js) + circular audio waveform + Web Speech API ASR/TTS | ~600 | +| `web/atlas-command.html` | Unified cockpit: 6 tabs (Chat / Brief / Buy? / Autonomy / HUD / Portal). Live cluster + finance + strategy + R10 cards | ~800 | + +Drop in any web server. Single-file each. No npm install. + +### Python automation + +| Script | Purpose | +|---|---| +| `scripts/cluster.py` | Multi-node Ollama orchestrator (task β†’ node β†’ model routing, fallback chain) | +| `scripts/cluster_then_polish.py` | Two-pass LLM pattern: cluster generates draft β†’ flagged `polish_status: pending` | +| `scripts/atlas_self_prompting_loop.py` | Self-perpetuating autonomy: 8 topic templates Γ— N nodes | +| `scripts/atlas_tray.py` | pystray system tray icon β€” color-coded cluster health (polls every 30s) | +| `scripts/atlas_seasonal_strategy.py` | NL fiscal calendar awareness (vakantiegeld, BTW deadlines, schoolvakantie, Sint/Kerst) | +| `scripts/atlas_finance_export.py` | NL freelancer Inkomsten xlsx parser (Betaalcontrole sheet pattern) β†’ JSON | +| `scripts/atlas_reminders.py` | Burden-remover daily schtask: monthly Inkomsten + quarterly BTW + savings + IB prep | +| `scripts/atlas_activity_export.py` | ActivityWatch β†’ R10 hours ratio (sales vs infra) for daily/weekly discipline | +| `scripts/atlas_autonomy_export.py` | Expose autonomy loop state via JSON for live dashboards | +| `scripts/vault_rag_expand.py` | Batch upload Markdown to Open WebUI Knowledge Base via API | +| `scripts/atlas_lead_enrich.py` | Per-prospect personalized cold-WhatsApp generator (NL sectors) | + +### Patterns documented + +- `patterns/CLUSTER_THEN_POLISH.md` β€” Local 7B-8B for volume, Claude for signal +- `patterns/ANTI_IMPULSE_RULES.md` β€” R1-R10 cash-before-code rules + +## Why this exists + +After 12 months of solo founder building, the patterns that actually survived. Specifically for the founder who: +- Runs Ollama or similar local LLM (on PC + VPS + secondary desktop) +- Wants Atlas-style live dashboard, not enterprise BI +- Uses Obsidian as second brain +- Needs Dutch fiscal calendar awareness +- Hates WordPress + Elementor for AI-edit-friendly new builds +- Wants "cash before code" enforced by automation, not willpower + +## Install + +```bash +git clone https://git.atlascorporation.nl/chaib/atlas-os.git +cd atlas-os +``` + +### Web surfaces +Serve any `web/*.html` via Caddy / nginx / Cloudflare Pages. Each is self-contained (CDN-loaded marked.js + DOMPurify). + +### Python tools +```bash +cd scripts +pip install -r requirements.txt # pystray, pillow, openpyxl +# Run individual scripts; each documents its env vars +``` + +## Configuration + +Each tool has `` placeholders. Replace with your: +- VPS IP (Hetzner / DigitalOcean / wherever Ollama runs always-on) +- Desktop node IP (your secondary GPU machine) +- PC node IP (your daily-driver localhost or tailnet IP) +- Open WebUI JWT (for Vault RAG queries) +- Knowledge Base ID +- SMTP credentials (for reminder emails) +- Domain + +See individual file headers. + +## Architecture (the actual production setup) + +``` +Operator PC (Windows) + - Ollama (GPU) β€” ~10 models + - Atlas Command HTML served via Caddy on VPS + - Atlas Tray Python (taskbar β€” always visible) + - atlas_self_prompting_loop schtask (every 15 min, 12h windows) + | + v Tailscale mesh (encrypted overlay) + | +VPS (Hetzner CPX32 β€” always-on) + - Ollama (CPU) β€” 5 small/medium models + - Caddy reverse proxy + - Open WebUI (Vault RAG, multi-tenant chats) + - Odoo / Nextcloud / Vaultwarden / MeshCentral / Forgejo (this repo!) + | + v +Secondary desktop (GPU node) + - Ollama β€” Llama 3.1 8B + smaller +``` + +Total monthly hosting cost: ~€25 (VPS) + electricity for PC + desktop. No SaaS subscriptions. + +## License + +MIT. Use freely. Attribution appreciated but not required. + +## Roadmap (PRs welcome) + +- Astro + Decap CMS template for AI-edit-friendly websites +- OpenWakeWord OS-level voice trigger (replaces browser-tab requirement) +- Multi-tenant pattern for solo agencies running N client sites +- Local sentence-transformer embeddings (replace Open WebUI dependency) +- Cluster-of-experts pattern for narrative tasks (rotate prompt across nodes, vote on best) + +## Credits + +Built by [@chaib](https://atlascorporation.nl) Β· Tilburg, NL Β· 2025-2026. +Patterns surfaced through 8 months of daily live use + ruthless removal of what didn't earn its keep. + +> One person, one stack, one workspace. Atlas because it carries the load. diff --git a/patterns/ANTI_IMPULSE_RULES.md b/patterns/ANTI_IMPULSE_RULES.md new file mode 100644 index 0000000..eb13bd4 --- /dev/null +++ b/patterns/ANTI_IMPULSE_RULES.md @@ -0,0 +1,98 @@ +# Pattern: Anti-impulse Decision Rules (R1-R10) + +> Rules that prevent the operator-over-builds-instead-of-sells pattern. + +For solo founders who code, the temptation is always to build instead of sell. These rules, enforced by automation, fix that. + +## The rules + +### R1. Hardware lot purchase guard + +Do NOT buy a refurbished hardware lot if ANY of: +- Existing inventory > 8 units of same category +- Pre-sales coverage < 50% (i.e., orders waiting on stock) +- Last 5 lots averaged > 60 days on shelf + +### R2. Pre-purchase justification (write before clicking buy) + +1. Why this lot? (specific clients waiting? Or "looked cheap"?) +2. Projected sell-through (units Γ— weeks to clear) +3. Holding cost (3%/mo capital tied + storage) +4. GO / NO-GO: if projected net ROI < 30% OR sell-through > 90 days β†’ NO + +### R3. Frequency cap + +Max 1 lot purchase per week. New opportunity? Wait until next Monday. + +### R4. Client acceptance + +Accept new client only if EITHER: +- Recurring potential β‰₯ €100/mo (3-month commit), OR +- One-time margin β‰₯ €500, OR +- Strategic referral source (unlocks network) + +### R5. Minimum billable rate + +€75/hour for ad-hoc work. Below that, decline politely. + +### R6. Daily time budget + +- 4h cash generation (outreach, calls, quotes, sales conversion) +- 2h delivery (existing client work) +- 1h admin (invoicing, follow-up) +- 1h learning/infra (NOT during cash window) + +### R7. Morning protection + +**No infra/code work before 14:00 on weekdays.** Morning = cash + delivery only. + +### R8. Build justification + +Before building anything new, ask: "Which paying client needs this RIGHT NOW?" +- Unclear β†’ do not build. +- Clear β†’ log it as a feature request with client name + proceed. + +### R9. Use-it-or-archive + +If a tool isn't used by operator OR a client within 2 weeks of building, archive it. + +### R10. Cash before code (umbrella) + +Every Friday: if sales hours < infra hours that week, the week failed and resets next Monday. No exceptions. + +## Enforcement via automation + +### R10 β€” ActivityWatch + Friday-Wrap email + +`scripts/atlas_activity_export.py` reads ActivityWatch event log, classifies into 6 categories: +- sales (Asana, WhatsApp Web, mail compose, Atlas Chat/Command/HUD) +- delivery (VSCode/Claude on existing-client work, WordPress admin) +- infra (Claude Code, terminal, GitHub, Docker, config files) +- admin (Inkomsten xlsx, Odoo, bank statements) +- learning (YouTube courses, BiSL/certification material) +- other + +Output JSON includes `r10_pass` boolean. Friday-Wrap email shows verdict. + +### R1-R3 β€” Browser calculator + +`web/atlas-command.html` has a "BUY?" tab. Inputs: lot price, units, category, pre-sales %. Outputs: GO/NO-GO verdict + holding cost + net ROI calculation per category baselines. + +### R5/R8 β€” Atlas Reminders + +`scripts/atlas_reminders.py` daily schtask. Triggers conditional emails: +- 26th of month β†’ savings pots check +- 14d + 3d before BTW deadlines +- 1st of month β†’ Inkomsten xlsx update reminder +- 5 juni + 25 nov β†’ vacation booking reminder +- 1 mrt + 15 apr β†’ IB aangifte prep + +## Why these rules + +After 12 months of building infrastructure that produced €5K annual revenue (vault: 200+ project files, ~15 paid client events = 13:1 build:sell ratio), the founder needed rules that prevent willpower-based discipline. + +Rules survive willpower. Automation survives both. + +## Customize + +The numbers (€100/mo, €500 margin, 4h/2h/1h/1h budget) are Atlas-specific. Adjust to your context. The structure (one umbrella + nine specific rules + automated enforcement) is the transferable pattern. diff --git a/patterns/CLUSTER_THEN_POLISH.md b/patterns/CLUSTER_THEN_POLISH.md new file mode 100644 index 0000000..99a3738 --- /dev/null +++ b/patterns/CLUSTER_THEN_POLISH.md @@ -0,0 +1,71 @@ +# Pattern: Cluster-then-Polish + +> Local 7B-8B models generate volume. Claude / human polishes signal. + +## Problem + +Open-source LLMs (qwen2.5:7b, llama3.1:8b, hermes3:8b on consumer hardware) are good at: +- Classification / categorization +- Embeddings / RAG retrieval +- Code completion (within trained languages) +- Structured output (JSON, tables) +- Triage of inbox / queues at volume + +They are **bad** at: +- Narrative copywriting for clients +- Strategic analysis without grounded data +- Tone / voice consistency +- Factual claims β€” they will hallucinate numbers, dates, names +- Anything where wrong output sent externally has cost + +## Pattern + +1. Cluster generates a first draft with explicit `polish_status: pending` YAML frontmatter +2. Output saved as Markdown file with full metadata (node, model, elapsed_s, prompt, draft_id) +3. Polish review queue tracked in `~/.config/atlas/drafts/_registry.json` +4. Operator OR a stronger model (Claude, GPT-4) reviews + edits + flips frontmatter to `polish_status: polished` +5. Only `polished` items are usable externally (sent to clients, published, etc.) + +## Reference implementation + +`scripts/cluster_then_polish.py` β€” simple wrapper around any Ollama-compatible HTTP API. + +```python +from cluster_then_polish import draft, save_draft + +d = draft( + prompt="Schrijf cold-WhatsApp voor Tilburg lunchroom", + task='chat', + node_pref='pc', + purpose='cold_outreach', +) +save_draft(d, '~/.config/atlas/drafts/lunchroom_outreach_001.md') +# operator reviews -> mark_polished(d['draft_id']) +``` + +## Real-world data (production) + +After 7.5 hours of perpetual loop across 3 nodes: +- 1172 autonomy outputs generated +- ~5-10% immediately useful (operator polishes light) +- ~30% spark a useful follow-up question +- ~60% are noise (still saved for pattern detection later) +- 0% should be sent externally without review + +The pattern is not "less work" β€” it is "different work". Operator becomes editor, not writer. The cluster does the cold-start text generation that's the worst part of writing. + +## When NOT to use this pattern + +- When you have Claude API access and the task is small enough to just call Claude directly +- When the output must be perfect (legal documents, financial figures, contracts) +- When you don't have a polish step set up β€” generating drafts you never review is worse than no drafts + +## Anti-pattern to avoid + +Don't trust cluster output because it "looks confident". 7B models will produce well-formatted Dutch with hallucinated company names, wrong euro amounts, invented words. Production example: + +> qwen2.5:7b output: "Bisogno's marktshare neemt toe met €4.578 miljoen, Inbrengking bleef stabiel" +> +> Reality: revenue is €4,578 (not millions), "Inbrengking" is not a Dutch word, "marktshare" was confused with revenue concentration percentage. + +The `polish_status: pending` flag exists because of this exact failure mode. diff --git a/scripts/atlas_activity_export.py b/scripts/atlas_activity_export.py new file mode 100644 index 0000000..4a1646d --- /dev/null +++ b/scripts/atlas_activity_export.py @@ -0,0 +1,169 @@ +#!/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@" +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() diff --git a/scripts/atlas_autonomy_export.py b/scripts/atlas_autonomy_export.py new file mode 100644 index 0000000..9086a55 --- /dev/null +++ b/scripts/atlas_autonomy_export.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +atlas_autonomy_export.py - Expose autonomy loop state via JSON for live dashboard. + +Reads: + - autonomy_queue.json (pending/completed) + - autonomy_loop.log (last 50 lines) + - vault/00_Atlas_Wiki/_autonomy/*.md (last 20 outputs) + +Writes: + - atlas-autonomy.json β€” consumed by Atlas Command AUTONOMY tab + +Runs: every 1 min via Atlas-Autonomy-Export schtask (lightweight). +""" +import json +import sys +import subprocess +from pathlib import Path +from datetime import datetime + +import io +if sys.stdout.encoding != 'utf-8': + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + +QUEUE = Path(r"C:\Users\Gebruiker\.config\atlas\autonomy_queue.json") +LOG = Path(r"C:\Users\Gebruiker\.config\atlas\autonomy_loop.log") +AUTO = Path(r"C:\Users\Gebruiker\MEGA\Atlas Corporation\00_Atlas_Wiki\_autonomy") +OUT = Path(r"C:\Users\Gebruiker\AppData\Local\Atlas\analyses\atlas-autonomy.json") +SSH = "root@" +REMOTE = "/opt/atlas/dashboard/atlas-autonomy.json" + + +def parse_frontmatter(text): + """Extract simple YAML frontmatter (between --- markers).""" + if not text.startswith("---"): + return {}, text + end = text.find("---", 3) + if end == -1: + return {}, text + yaml_block = text[3:end].strip() + body = text[end+3:].strip() + fm = {} + for line in yaml_block.split("\n"): + if ":" in line: + k, v = line.split(":", 1) + fm[k.strip()] = v.strip() + return fm, body + + +def main(): + state = { + "generated_at": datetime.now().isoformat(timespec="seconds"), + "loop_running": False, + "queue_pending": 0, + "queue_completed": 0, + "log_tail": [], + "recent_outputs": [], + "next_fire": None, + "task_state": None, + } + + # Queue + if QUEUE.exists(): + try: + q = json.loads(QUEUE.read_text(encoding="utf-8")) + state["queue_pending"] = len(q.get("pending", [])) + state["queue_completed"] = len(q.get("completed", [])) + if q.get("completed"): + last = q["completed"][-1] + state["last_completed"] = { + "topic": last.get("topic"), + "node": last.get("node"), + "model": last.get("model"), + "completed_at": last.get("completed_at"), + "elapsed_s": last.get("elapsed_s"), + } + except Exception as e: + state["queue_err"] = str(e)[:100] + + # Log tail + if LOG.exists(): + try: + lines = LOG.read_text(encoding="utf-8", errors="replace").splitlines() + state["log_tail"] = lines[-50:] + except Exception: + pass + + # Schtask state + try: + r = subprocess.run( + ["powershell.exe", "-NoProfile", "-Command", + "$t=Get-ScheduledTask -TaskName Atlas-Autonomy-Hourly -ErrorAction SilentlyContinue;" + "if ($t) { $i=Get-ScheduledTaskInfo $t; " + "Write-Output ('STATE:' + $t.State + '|LAST:' + $i.LastRunTime + '|NEXT:' + $i.NextRunTime + '|RESULT:' + $i.LastTaskResult) }"], + capture_output=True, text=True, timeout=10 + ) + out = r.stdout.strip() + if out.startswith("STATE:"): + parts = dict(p.split(":", 1) for p in out[6:].split("|")) + state["task_state"] = "STATE:" + out[6:] + state["loop_running"] = (parts.get("STATE") != "Disabled") + state["next_fire"] = parts.get("NEXT") + except Exception as e: + state["task_state_err"] = str(e)[:100] + + # Recent outputs (last 20 by mtime) + if AUTO.exists(): + try: + md_files = sorted(AUTO.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True)[:20] + for p in md_files: + try: + text = p.read_text(encoding="utf-8", errors="replace") + fm, body = parse_frontmatter(text) + state["recent_outputs"].append({ + "filename": p.name, + "mtime": datetime.fromtimestamp(p.stat().st_mtime).isoformat(timespec="seconds"), + "topic": fm.get("topic", "?"), + "node": fm.get("node", "?"), + "model": fm.get("model", "?"), + "elapsed_s": fm.get("elapsed_s", "?"), + "polish_status": fm.get("polish_status", "?"), + "body_preview": body[:400].replace("\n", " ").strip(), + }) + except Exception: + pass + except Exception as e: + state["outputs_err"] = str(e)[:100] + + state["recent_output_count"] = len(state["recent_outputs"]) + + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") + print(f"wrote {OUT}") + print(f" pending={state['queue_pending']} completed={state['queue_completed']} outputs={state['recent_output_count']}") + try: + subprocess.run(["scp", str(OUT), f"{SSH}:{REMOTE}"], check=True, timeout=20) + print(f"pushed to {SSH}:{REMOTE}") + except Exception as e: + print(f"scp fail: {e}") + + +if __name__ == "__main__": + main() diff --git a/scripts/atlas_finance_export.py b/scripts/atlas_finance_export.py new file mode 100644 index 0000000..9da2ee4 --- /dev/null +++ b/scripts/atlas_finance_export.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +""" +atlas_finance_export.py - Parse Inkomsten xlsx + compute YTD/MRR/concentration, +output atlas-finance.json and scp to atlas-01 dashboard. + +Run manually or via schtask (Atlas-Finance-Export, daily). +""" +import json +import sys +import time +import subprocess +from pathlib import Path +from datetime import datetime, date +from collections import defaultdict + +try: + import openpyxl +except ImportError: + print("openpyxl required"); sys.exit(1) + +XLSX = Path(r"C:\Users\Gebruiker\MEGA\Atlas Corporation\07_Finance\2026\Inkomsten uit onderneming 2026.xlsx") +OUT_LOCAL = Path(r"C:\Users\Gebruiker\AppData\Local\Atlas\analyses\atlas-finance.json") +SSH = "root@" +REMOTE = "/opt/atlas/dashboard/atlas-finance.json" + + +DUTCH_MONTH = { + "jan": 1, "januari": 1, + "febr": 2, "feb": 2, "februari": 2, + "mrt": 3, "maart": 3, + "apr": 4, "april": 4, + "mei": 5, + "juni": 6, "jun": 6, + "juli": 7, "jul": 7, + "aug": 8, "augustus": 8, + "sept": 9, "sep": 9, "september": 9, + "okt": 10, "oktober": 10, + "nov": 11, "november": 11, + "dec": 12, "december": 12, +} + + +def parse_xlsx(p): + """Read Betaalcontrole sheet β€” canonical paid-invoices list with bank-verified amounts.""" + wb = openpyxl.load_workbook(p, data_only=True, read_only=True) + if "Betaalcontrole" not in wb.sheetnames: + return [] + sheet = wb["Betaalcontrole"] + rows = [] + headers = None + for row in sheet.iter_rows(values_only=True): + if not headers: + headers = [str(c).strip().lower() if c else "" for c in row] + continue + if not any(row): continue + d = dict(zip(headers, row)) + rows.append(d) + return rows + + +def to_float(v): + if v is None: return 0.0 + if isinstance(v, (int, float)): return float(v) + s = str(v).replace("€", "").replace(",", ".").replace(" ", "").strip() + try: return float(s) + except: return 0.0 + + +def to_date(v): + if isinstance(v, (datetime, date)): return v if isinstance(v, date) and not isinstance(v, datetime) else v.date() + if isinstance(v, str): + for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y", "%Y/%m/%d"): + try: return datetime.strptime(v.strip(), fmt).date() + except: continue + return None + + +def normalize(rows): + """Normalize Betaalcontrole rows: factuur, maand in rapport, klant, excl btw, incl/bankbedrag, betaalbewijs, actie.""" + out = [] + for r in rows: + # Skip rows without invoice number (= template/blank) + factuur = (r.get("factuur") or "").strip() if r.get("factuur") else "" + if not factuur or not factuur.startswith("INV"): + continue + # Skip rows marked as not paid or cancelled + actie = (r.get("actie") or "").lower() + if actie and ("niet" in actie or "geannul" in actie or "credit" in actie or "open" in actie): + continue + month_str = (r.get("maand in rapport") or "").strip().lower() + month_num = DUTCH_MONTH.get(month_str) + if not month_num: continue + # Build date as 15th of month (since exact paid date may not be in xlsx β€” bewijs column has it) + from datetime import date as _date + d = _date(2026, month_num, 15) + client = (r.get("klant") or "?").strip()[:60] + # Use "incl/bankbedrag" (actual money received), fall back to excl btw + amount = to_float(r.get("incl/bankbedrag") or r.get("excl btw")) + if amount <= 0: continue + out.append({ + "date": d.isoformat(), + "month_num": month_num, + "factuur": factuur, + "client": client, + "amount": round(amount, 2), + }) + return out + + +def aggregate(payments): + """Compute YTD, monthly, top clients, MRR estimate.""" + today = date.today() + ytd = 0 + by_month = defaultdict(float) + by_client = defaultdict(float) + by_client_count = defaultdict(int) + last_3mo_by_client = defaultdict(float) + + for p in payments: + d = datetime.fromisoformat(p["date"]).date() + if d.year != today.year: continue + ytd += p["amount"] + by_month[d.strftime("%Y-%m")] += p["amount"] + by_client[p["client"]] += p["amount"] + by_client_count[p["client"]] += 1 + days_ago = (today - d).days + if days_ago <= 90: + last_3mo_by_client[p["client"]] += p["amount"] + + sorted_clients = sorted(by_client.items(), key=lambda x: x[1], reverse=True) + top_5 = sorted_clients[:5] + total_top = sum(a for _, a in top_5) + concentration = round(top_5[0][1] / ytd * 100, 1) if top_5 and ytd > 0 else 0 + + # MRR estimate: clients who paid 2+ times last 3 months + mrr_eligible = {k: v for k, v in last_3mo_by_client.items() if by_client_count[k] >= 2} + mrr_est = round(sum(mrr_eligible.values()) / 3, 2) if mrr_eligible else 0 + + months_in_year = today.month + avg_per_month = round(ytd / months_in_year, 2) if months_in_year else 0 + + return { + "generated_at": datetime.now().isoformat(timespec="seconds"), + "as_of": today.isoformat(), + "ytd_eur": round(ytd, 2), + "ytd_count": sum(by_client_count.values()), + "unique_clients_ytd": len(by_client), + "avg_per_month_eur": avg_per_month, + "current_month_eur": round(by_month.get(today.strftime("%Y-%m"), 0), 2), + "by_month": dict(sorted(by_month.items())), + "top_5_clients": [{"name": k, "amount": round(v, 2), "share_pct": round(v/ytd*100, 1) if ytd else 0} for k, v in top_5], + "concentration_top_pct": concentration, + "mrr_estimate_eur": mrr_est, + "mrr_eligible_clients": len(mrr_eligible), + } + + +def main(): + if not XLSX.exists(): + print(f"missing: {XLSX}"); sys.exit(1) + rows = parse_xlsx(XLSX) + payments = normalize(rows) + print(f"parsed {len(rows)} rows, {len(payments)} usable payments") + summary = aggregate(payments) + 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" ytd: €{summary['ytd_eur']:.2f} ({summary['ytd_count']} payments)") + print(f" unique clients: {summary['unique_clients_ytd']}") + print(f" top client: {summary['top_5_clients'][0]['name'] if summary['top_5_clients'] else '?'} ({summary['concentration_top_pct']}%)") + print(f" MRR est: €{summary['mrr_estimate_eur']}") + # scp to atlas-01 + 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() diff --git a/scripts/atlas_lead_enrich.py b/scripts/atlas_lead_enrich.py new file mode 100644 index 0000000..66559bb --- /dev/null +++ b/scripts/atlas_lead_enrich.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +""" +atlas_lead_enrich.py β€” Per-prospect WhatsApp message generator. + +Use: + atlas_lead_enrich.py "Lunchroom Carrousel" "Oude Markt" --sector lunchroom --ig "ze posten dagmenu's" + +Generates a personalized WhatsApp message using cluster_then_polish pattern. + +Output: text printed + saved to ~/.config/atlas/drafts/lead_.md +""" +import sys +import re +import argparse +import io +from pathlib import Path +from datetime import datetime + +if sys.stdout.encoding != 'utf-8': + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + +sys.path.insert(0, r"C:\Users\Gebruiker\.config\atlas") + +try: + from cluster_then_polish import draft, save_draft +except ImportError: + print("Need cluster_then_polish.py β€” see ~/.config/atlas/") + sys.exit(1) + +SECTOR_TEMPLATES = { + "lunchroom": { + "package": "Werkplek STARTER €799", + "pain": "bonnetjes tellen 4-5u/wk", + "ref": "Couscousbar", + }, + "koffiebar": { + "package": "Werkplek STARTER €799", + "pain": "voorraad bijhouden + dagomzet tellen", + "ref": "Coffee Almahaba", + }, + "friteshouse": { + "package": "Werkplek PRO €1.499", + "pain": "afhalen-rush volgorde + tap-pay", + "ref": "Dushi Sazon", + }, + "cafetaria": { + "package": "Werkplek PRO €1.499", + "pain": "voorraad-melding + dag-rapportage", + "ref": "Cafetaria Online", + }, + "kapper": { + "package": "Werkplek STARTER €799", + "pain": "gemiste afspraken want naast knippen ook bellen/typen", + "ref": "Charif's Barbershop", + }, + "barber": { + "package": "Werkplek STARTER €799", + "pain": "afsprakenbeheer + kassa zonder onderbreken", + "ref": "Charif's Barbershop", + }, + "horeca": { + "package": "Werkplek KASSA €2.499", + "pain": "POS + voorraad + rapportage in één", + "ref": "Bisogno", + }, + "retail": { + "package": "Werkplek PRO €1.499", + "pain": "voorraad + kassa + dag-overzicht", + "ref": "Le Miris", + }, +} + + +def slugify(name): + s = re.sub(r"[^a-z0-9]+", "-", name.lower()) + return s.strip("-")[:40] + + +def build_message(name, location, sector, ig_hint, voornaam_hint=""): + """Build personalized WhatsApp via cluster + polish.""" + t = SECTOR_TEMPLATES.get(sector.lower(), SECTOR_TEMPLATES["horeca"]) + voornaam = voornaam_hint or "{voornaam}" + + prompt = f"""Schrijf één persoonlijk cold-WhatsApp bericht voor een Tilburgse zaak. NL, casual maar professional. + +ZAAK: {name} +LOCATIE: {location} +SECTOR: {sector} +IG/CONTEXT HINT: {ig_hint} +EIGENAAR VOORNAAM: {voornaam} + +AANBOD: {t['package']} +PIJNPUNT: {t['pain']} +REFERENTIE: {t['ref']} + +REGELS: +- Open met "Hoi {voornaam}," + 1 zin die TONE-PERSOONLIJK aansluit op IG-context hint +- Korte pijn-kwestie noemen (max 1 zin) +- Aanbod met prijs upfront +- Zachte CTA: 15-20 min koffie of bezoek, geen verkoop-druk +- Max 80 woorden totaal +- Eindigt met één concrete vraag + +Output ALLEEN het bericht, geen toelichting.""" + + # task=general β†’ qwen2.5:7b on atlas-01 (always-on, NL-decent) + return draft(prompt, task="general", node_pref="atlas-01", + max_tokens=400, temperature=0.6, purpose="lead_outreach") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("name", help="Zaak naam") + ap.add_argument("location", help="Adres / wijk") + ap.add_argument("--sector", required=True, choices=list(SECTOR_TEMPLATES.keys())) + ap.add_argument("--ig", default="", help="IG/context detail om aan te haken") + ap.add_argument("--voornaam", default="", help="Eigenaar voornaam") + ap.add_argument("--out", default=None, help="Save path (auto if omitted)") + args = ap.parse_args() + + print(f"Generating message for: {args.name} ({args.sector}) at {args.location}") + print(f"Hook: {args.ig or '(none)'}") + print() + + result = build_message(args.name, args.location, args.sector, args.ig, args.voornaam) + if not result.get("ok"): + print("ERR:", result.get("err")) + sys.exit(1) + + print("=" * 60) + print(result["text"]) + print("=" * 60) + print(f"\n[{result['node']}/{result['model']} {result['elapsed_s']}s]") + print(f"polish_status: {result['polish_status']}") + print(f"HINT: {result['hint']}") + print() + + out_path = args.out or ( + Path.home() / ".config" / "atlas" / "drafts" / + f"lead_{slugify(args.name)}_{datetime.now().strftime('%Y%m%d_%H%M')}.md" + ) + saved = save_draft(result, out_path) + print(f"\nSaved β†’ {saved}") + print("\nReview, edit if needed, copy to WhatsApp, send.") + print("After sending: mark_polished(draft_id) to track conversion.") + + +if __name__ == "__main__": + main() diff --git a/scripts/atlas_reminders.py b/scripts/atlas_reminders.py new file mode 100644 index 0000000..a690524 --- /dev/null +++ b/scripts/atlas_reminders.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +""" +atlas_reminders.py β€” Operator burden-remover. + +Fires based on date-pattern matching: + - 1e van de maand: Inkomsten xlsx update reminder + - 14d + 3d vΓ³Γ³r BTW deadlines: aangifte reminder + - Friday-end-of-month: personal financial summary + - Quarterly mid-last-month: plan revision reminder + - Annual prep windows + +Each reminder is an email to info@your-domain.tld via TransIP SMTP. + +Schedule: + Atlas-Reminders-Daily at 07:45 β€” checks all conditions, fires what matches. +""" +import sys +import smtplib +import json +from pathlib import Path +from datetime import date, datetime, timedelta +from email.mime.text import MIMEText + +SMTP_HOST = "smtp.transip.email" +SMTP_PORT = 587 +SMTP_USER = "your@your-domain.tld" +SMTP_PASS = "" +MAIL_FROM = "your@your-domain.tld" +MAIL_TO = "info@your-domain.tld" + +LOG = Path(r"C:\Users\Gebruiker\.config\atlas\atlas_reminders.log") + + +def log(msg): + LOG.parent.mkdir(parents=True, exist_ok=True) + with open(LOG, "a", encoding="utf-8") as f: + f.write(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}\n") + + +def send_html(subject, html_body): + msg = MIMEText(html_body, "html", "utf-8") + msg["Subject"] = subject + msg["From"] = MAIL_FROM + msg["To"] = MAIL_TO + try: + with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as s: + s.starttls() + s.login(SMTP_USER, SMTP_PASS) + s.send_message(msg) + log(f"sent: {subject}") + return True + except Exception as e: + log(f"send FAIL: {e}") + return False + + +def reminder_card(title, urgency, body_html): + color = {"high": "#dc2626", "med": "#f59e0b", "low": "#3b82f6"}[urgency] + return f""" +
+
{urgency} priority reminder
+

{title}

+
{body_html}
+
+ """ + + +def check_monthly_inkomsten(today): + """1e van de maand: update Inkomsten xlsx voor afgesloten maand.""" + if today.day not in (1, 2, 5): + return None + last_month = today.replace(day=1) - timedelta(days=1) + body = f""" +

Vandaag ({today.strftime('%d %B')}) is een check-dag voor de Inkomsten uit onderneming 2026.xlsx.

+

Actie: open Knab β†’ download afschrift {last_month.strftime('%B %Y')} β†’ + folder 07_Finance/2026/03_bank/ β†’ en update de + Betaalcontrole sheet met:

+
    +
  • Klant + factuurnummer + bedrag voor elke ontvangst
  • +
  • Knab afschrift-regel als bewijs
  • +
  • Status "opgenomen" zodra reconciled
  • +
+

Atlas Command BRIEF tab leest de xlsx automatisch (06:30) β†’ refresht je + live YTD/MRR/concentration cijfers.

+

Reminder doel: voorkom "xlsx niet bijgewerkt sinds vorige maand" patroon (gebeurde mei 2026).

+ """ + return ("πŸ“Š Monthly: Inkomsten xlsx update", "med", + reminder_card("Update Inkomsten xlsx voor afgelopen maand", "med", body)) + + +def check_btw_deadline(today): + """14d en 3d vΓ³Γ³r BTW kwartaal-deadlines.""" + btw_deadlines = [ + date(today.year, 4, 30), # Q1 + date(today.year, 7, 31), # Q2 + date(today.year, 10, 31), # Q3 + date(today.year + 1, 1, 31), # Q4 + ] + quarters = ["Q1", "Q2", "Q3", "Q4"] + for d, q in zip(btw_deadlines, quarters): + delta = (d - today).days + if delta in (14, 3): + urgency = "high" if delta == 3 else "med" + body = f""" +

BTW aangifte {q} {today.year} deadline: {d.strftime('%d %B %Y')} + β€” over {delta} dagen.

+

Actie:

+
    +
  1. Open Odoo (:8069) β†’ Boekhouding β†’ BTW
  2. +
  3. Genereer aangifte-overzicht voor {q}
  4. +
  5. Controleer met Knab + ING + Revolut totalen
  6. +
  7. Login Belastingdienst (Mijn Belastingdienst Zakelijk) β†’ aangifte indienen
  8. +
  9. BTW-bedrag overmaken vanuit Knab vΓ³Γ³r {d.strftime('%d %B')}
  10. +
+

+ Boete bij te late aangifte: vanaf €68. Bij te late betaling: 4% rente op jaarbasis. +

+ """ + return (f"⏳ BTW {q} deadline {delta}d - actie nodig", urgency, + reminder_card(f"BTW aangifte {q} β€” {delta} dagen", urgency, body)) + return None + + +def check_quarterly_review(today): + """15e van laatste maand van kwartaal: revise Q+1 plan.""" + quarterly_review_days = [ + date(today.year, 3, 15), + date(today.year, 6, 15), + date(today.year, 9, 15), + date(today.year, 12, 15), + ] + if today not in quarterly_review_days: + return None + quarter = (today.month - 1) // 3 + 1 + body = f""" +

Mid-laatste-maand Q{quarter} β€” tijd voor Q{quarter+1 if quarter < 4 else 1} planning.

+

Acties:

+
    +
  • Open Atlas Command β†’ BRIEF tab β†’ screenshot finance + concentration + R10
  • +
  • Open vault note 05.5 - Q3 2026 Plan.md (of Q+1 versie)
  • +
  • Herzie cijfers: YTD vs target, MRR groei, concentration delta
  • +
  • Definieer 3-5 top prioriteiten voor komend kwartaal
  • +
  • Schrijf Q+1 versie in 00_Atlas_Wiki/
  • +
+

Tip: gebruik [[06.5 - Master Cockpit & 90-day System]] als template.

+ """ + return (f"πŸ” Q{quarter+1 if quarter<4 else 1} planning tijd", "med", + reminder_card(f"Quarterly review β€” Q{quarter+1 if quarter<4 else 1} plan opstellen", "med", body)) + + +def check_holiday_booking(today): + """Vrij van eerste juni-week: reminder om vakantie te boeken voor zomer.""" + triggers = [date(today.year, 6, 5), date(today.year, 11, 25)] # June + late Nov + if today not in triggers: + return None + body = f""" +

Solo founder herinnering: plan je vakantie zelf in, anders gebeurt het niet.

+

Suggestie 2026 windows (zie [[06.5 - Master Cockpit & 90-day System]] Β§7):

+
    +
  • 6-12 juli: eerste zomerweek (Bisogno retainer dekt cash)
  • +
  • 17-21 augustus: pre-schoolstart week
  • +
  • 14-20 december: pre-kerst rust
  • +
+

Actie: blokkeer in Nextcloud Kalender + Apple Calendar. + Stuur "out of office" voor Atlas Agency klanten. + Werkplek-pakket levering = plan voor of na vakantie.

+ """ + return ("🌴 Vakantie boeking reminder", "low", + reminder_card("Plan je zomer/winter vakantie nu", "low", body)) + + +def check_savings(today): + """26e van maand: na vakantiegeld / na payday β†’ savings reminder.""" + if today.day != 26: + return None + body = """ +

Het is de 26e β€” payday is geweest (25e is NL salaris-norm).

+

Actie: open Knab β†’ check 3 spaar-pots:

+
    +
  • πŸš— Auto-pot: doel €6.500 voor december β€” storting deze maand?
  • +
  • πŸ›‘οΈ Buffer-pot: 3 mnd vaste lasten doel €5.000 β€” storting deze maand?
  • +
  • 🌴 Vakantie-pot: doel €1.500/jaar β€” €125/mnd
  • +
+

Niet sparen = niet halen. Auto-transfer instellen via Knab is one-time setup, daarna fire-and-forget.

+

Bron: [[06.5 - Master Cockpit]] Β§7 savings goals tabel.

+ """ + return ("πŸ’° Savings reminder (26e)", "low", + reminder_card("Spaar-pots check", "low", body)) + + +def check_annual_prep(today): + """1 maart: IB aangifte voorjaars-window opent. 1 juni: deadline 1 mei is voorbij.""" + if today not in (date(today.year, 3, 1), date(today.year, 4, 15)): + return None + body = f""" +

IB aangifte 2025 voor {today.year} β€” deadline 1 mei {today.year}.

+

Acties:

+
    +
  1. Stuur boekhouder Inkomsten + Kosten 2025 xlsx (al beschikbaar in 07_Finance)
  2. +
  3. Verzamel BTW aangiften 2025 (4 kwartalen)
  4. +
  5. Verzamel zakelijke kosten-bonnetjes 2025 (05_overig/ + 02_uitgaven/)
  6. +
  7. Vraag KOR-check (kleine ondernemingsregeling) bij boekhouder
  8. +
  9. Plan voorlopige IB-aanslag voor {today.year} samen met boekhouder
  10. +
+ """ + return ("πŸ“‹ IB aangifte voorjaarsstart", "high", + reminder_card("IB aangifte 2025 voorbereiden", "high", body)) + + +def main(): + today = date.today() + log(f"reminders check for {today.isoformat()}") + cards = [] + for check in [check_monthly_inkomsten, check_btw_deadline, + check_quarterly_review, check_holiday_booking, + check_savings, check_annual_prep]: + result = check(today) + if result: + subject, urgency, html = result + cards.append((subject, urgency, html)) + log(f" triggered: {subject}") + + if not cards: + log(" no reminders today") + return + + # Combine all cards into one email + body_html = f""" +

πŸ”” Atlas Reminders β€” {today.strftime('%A %d %B %Y')}

+ {''.join(c[2] for c in cards)} +
+ Atlas Reminders Β· auto-fired by daily 07:45 schtask Β· disable via Disable-ScheduledTask Atlas-Reminders-Daily +
+ """ + subject = f"πŸ”” Atlas Reminders ({len(cards)}) β€” {today.strftime('%d %b')}" + send_html(subject, body_html) + + +if __name__ == "__main__": + main() diff --git a/scripts/atlas_seasonal_strategy.py b/scripts/atlas_seasonal_strategy.py new file mode 100644 index 0000000..88df5fe --- /dev/null +++ b/scripts/atlas_seasonal_strategy.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +""" +atlas_seasonal_strategy.py β€” Generate context-aware strategic recommendations +based on calendar season, NL economic windows, local Tilburg events, and +Google Trends signals where available. + +Output: atlas-strategy.json β€” consumed by Atlas Command BRIEF tab "Strategy" card. + +The engine knows about: + - Vakantiegeld payouts (25 mei – 5 juni) + - Belastingteruggaaf windows (Mar-Apr) + - Schoolstart (late aug / early sept) + - Sint/Kerst spending (Nov-Dec) + - Tilburgse Kermis (juli) + - Q1/Q2/Q3/Q4 BTW aangifte deadlines + - Ramadan / Suikerfeest (year-variable) + +Per current window, recommends: + - Which revenue stream to prioritize + - Which prospects to push + - Which framing to use + - What NOT to do +""" +import json +import subprocess +import sys +import urllib.request +from pathlib import Path +from datetime import datetime, date, timedelta + +OUT_LOCAL = Path(r"C:\Users\Gebruiker\AppData\Local\Atlas\analyses\atlas-strategy.json") +SSH = "root@" +REMOTE = "/opt/atlas/dashboard/atlas-strategy.json" + + +# Calendar windows (year-relative β€” adjusted to 2026) +WINDOWS_2026 = [ + { + "name": "Vakantiegeld", + "start": "2026-05-25", "end": "2026-06-05", + "signal": "Consumer cash boost (€1.500-3.000 per consumer)", + "push": [ + "Werkplek STARTER €799 (consumer-friendly price)", + "Kapper / horeca consumer-facing zaak-eigenaars (zij ervaren spend-piek)", + "Atlas Agency retainers (klanten hebben budget voor pakketten)" + ], + "framing": "\"Vakantiegeld al binnen? Nieuwe werkplek-set is nu beste timing.\"", + "avoid": ["Geen B2B-koud sales β€” owners distracted door consumer-rush"], + "channels": ["WhatsApp", "Instagram", "Tilburg community"], + }, + { + "name": "Pre-zomer rust", + "start": "2026-06-06", "end": "2026-07-05", + "signal": "Stabiele B2B window, vΓ³Γ³r schoolvakantie", + "push": [ + "Atlas Agency cold-outreach (max response rate)", + "POS Support Lite contract conversions (existing klanten)", + "Werkplek PRO + KASSA (besluitvorming-tijd)" + ], + "framing": "\"Inrichten vΓ³Γ³r zomerstop β€” na zomer = nieuwe kalender, schone start.\"", + "avoid": ["Geen consumer-spend campagnes β€” vakantiegeld op"], + "channels": ["WhatsApp", "Email B2B", "LinkedIn (1Γ—/wk max)"], + }, + { + "name": "Tilburgse Kermis", + "start": "2026-07-17", "end": "2026-07-26", + "signal": "Lokale horeca-piek (10 dagen)", + "push": [ + "POS Support Lite NU verkopen voor july readiness", + "Kermis-tijdelijke POS rental aanbieden (€50/dag) β€” test market", + "WhatsApp campagne richting kermis-deelnemers vΓ³Γ³r kermis" + ], + "framing": "\"Kermis = jouw drukste week. Atlas Support Lite zorgt dat je POS niet stilvalt.\"", + "avoid": ["Geen large-budget B2B proposals β€” operationele focus"], + "channels": ["WhatsApp horeca", "Instagram Tilburg-tags"], + }, + { + "name": "Schoolvakantie", + "start": "2026-07-06", "end": "2026-08-17", + "signal": "MKB-eigenaars meer tijd voor strategie + nieuwe leveranciers", + "push": [ + "Atlas Agency cold-outreach (BEST window β€” owners hebben tijd)", + "Werkplek follow-ups op bestaande quotes", + "Workshop POS voor Horeca (eerste editie juli)" + ], + "framing": "\"Tijd voor de zaak achter de zaak β€” Atlas helpt het systeem rond te krijgen.\"", + "avoid": ["Geen sales-druk eerste 2 weken juli (vakantie-spirit)"], + "channels": ["WhatsApp", "Instagram zaak-eigenaar netwerken", "Charif referral"], + }, + { + "name": "Back to school + work", + "start": "2026-08-18", "end": "2026-08-31", + "signal": "Nieuwe office-setups, MKB equipment-piek", + "push": [ + "Werkplek PRO + KASSA push (office-upgrade tijd)", + "Group-buy aanbod (5+ werkplekken voor MKB)", + "Studenten-werkplekken (TilburgU/Avans semesterstart)" + ], + "framing": "\"Begin het nieuwe seizoen met systemen die werken.\"", + "avoid": ["Geen low-budget peripherals β€” push hoog-marge bundels"], + "channels": ["WhatsApp MKB", "TilburgU community", "Avans-area outreach"], + }, + { + "name": "Najaar groei", + "start": "2026-09-01", "end": "2026-10-31", + "signal": "Najaars-momentum, beslissingen-tijd", + "push": [ + "Atlas Agency retainers (€250/mo) β€” vol momentum", + "Bisogno + andere bestaande klanten verdieping", + "Workshops 2Γ—/mo schaal" + ], + "framing": "\"Voor december komt: maak je systeem klaar voor jaar-eind drukte.\"", + "avoid": [], + "channels": ["Alles open"], + }, + { + "name": "Sint/Kerst opmaat", + "start": "2026-11-01", "end": "2026-11-30", + "signal": "B2C horeca-piek voorbereiden + boekhouders druk", + "push": [ + "Werkplek KASSA voor horeca die met sint/kerst druk gaat krijgen", + "Cross-sell naar boekhouders (referral network β€” zij zien hun klanten worstelen)", + "Year-end Atlas Support Lite incentives" + ], + "framing": "\"December = duurste maand om POS te laten falen. Nu is het tijd.\"", + "avoid": [], + "channels": ["Alles open"], + }, + { + "name": "Eindejaarsspecial", + "start": "2026-12-01", "end": "2026-12-23", + "signal": "Cash + planning + 'kerstcadeau voor jezelf'-framing", + "push": [ + "Werkplek STARTER €799 als kerstcadeau voor zzp'er", + "Q4 retainer-vernieuwingen + annual prepay deals", + "Atlas Support Lite jaarcontracten (12% korting bij vooruitbetaling)" + ], + "framing": "\"Kerstcadeau aan jezelf Γ©n aan je zaak.\"", + "avoid": ["Geen B2B sales na 23 dec β€” niemand bereikbaar"], + "channels": ["WhatsApp warm-list", "Email bestaande klanten", "IG"], + }, + { + "name": "Kerst-doodweek", + "start": "2026-12-24", "end": "2027-01-04", + "signal": "Holiday lull β€” minimaal contact", + "push": ["Eigen administratie afronden", "2027 plan schrijven", "Cluster onderhoud"], + "framing": "n.v.t. β€” geen sales", + "avoid": ["Alle externe outreach"], + "channels": [], + }, + { + "name": "Q1 freshness", + "start": "2027-01-05", "end": "2027-01-31", + "signal": "\"Nieuw jaar nieuw systeem\" β€” annual contract renewals", + "push": [ + "Werkplek upgrade calls naar Q1 klanten", + "Atlas Agency jaarcontracten ipv maandelijks", + "POS Support Lite hernieuwingen + uitbreidingen" + ], + "framing": "\"Begin 2027 met systemen die in 2026 hebben bewezen te werken.\"", + "avoid": [], + "channels": ["Alles open"], + }, +] + + +def current_window(today=None): + today = today or date.today() + for w in WINDOWS_2026: + start = date.fromisoformat(w["start"]) + end = date.fromisoformat(w["end"]) + if start <= today <= end: + days_in = (today - start).days + days_left = (end - today).days + return {**w, "days_in": days_in, "days_left": days_left} + return None + + +def upcoming_windows(today=None, n=3): + today = today or date.today() + upcoming = [] + for w in WINDOWS_2026: + start = date.fromisoformat(w["start"]) + if start > today: + upcoming.append({**w, "days_until": (start - today).days}) + if len(upcoming) >= n: break + return upcoming + + +def btw_deadline(today=None): + """Quarterly NL BTW aangifte deadlines: end of month after quarter end.""" + today = today or date.today() + q_ends = [ + (date(today.year, 4, 30), "Q1"), + (date(today.year, 7, 31), "Q2"), + (date(today.year, 10, 31), "Q3"), + (date(today.year + 1, 1, 31), "Q4"), + ] + for d, q in q_ends: + if d >= today: + return {"quarter": q, "deadline": d.isoformat(), "days_until": (d - today).days} + return None + + +def days_to_next_payday(): + """Most NL employers pay 25th of month β€” quick proxy.""" + today = date.today() + if today.day < 25: + nxt = date(today.year, today.month, 25) + else: + m = today.month + 1 + y = today.year + if m > 12: m = 1; y += 1 + nxt = date(y, m, 25) + return (nxt - today).days + + +def build_summary(): + today = date.today() + curr = current_window(today) + upcoming = upcoming_windows(today, 3) + btw = btw_deadline(today) + + summary = { + "generated_at": datetime.now().isoformat(timespec="seconds"), + "as_of": today.isoformat(), + "current_window": curr, + "upcoming_windows": upcoming, + "btw_aangifte": btw, + "days_to_next_payday": days_to_next_payday(), + "weekday": today.strftime("%A"), + "is_weekend": today.weekday() >= 5, + # Action triggers + "primary_recommendation": curr["push"][0] if curr and curr["push"] else "Geen actieve window β€” gebruik default plan", + "primary_framing": curr["framing"] if curr else "n.v.t.", + "avoid_today": curr["avoid"] if curr else [], + "channels_today": curr["channels"] if curr else [], + } + return summary + + +def push_to_atlas01(summary): + OUT_LOCAL.parent.mkdir(parents=True, exist_ok=True) + OUT_LOCAL.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8") + print(f"wrote {OUT_LOCAL}") + 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 fail: {e}") + + +def main(): + s = build_summary() + push_to_atlas01(s) + print(f"\nCurrent window: {s['current_window']['name'] if s['current_window'] else 'none'}") + if s['current_window']: + print(f" Days in: {s['current_window']['days_in']}, days left: {s['current_window']['days_left']}") + print(f" Push: {s['primary_recommendation']}") + print(f"BTW deadline: {s['btw_aangifte']['quarter']} on {s['btw_aangifte']['deadline']} ({s['btw_aangifte']['days_until']}d)") + print(f"Next payday (25th): {s['days_to_next_payday']}d") + + +if __name__ == "__main__": + main() diff --git a/scripts/atlas_self_prompting_loop.py b/scripts/atlas_self_prompting_loop.py new file mode 100644 index 0000000..f709c03 --- /dev/null +++ b/scripts/atlas_self_prompting_loop.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +""" +atlas_self_prompting_loop.py - Overnight autonomy. + +Runs every 15 min via schtask (Atlas-Autonomy-Hourly): + 1. Read state (atlas-finance.json, atlas-strategy.json, atlas-activity.json, recent vault changes) + 2. Pick next task from queue OR generate new task if queue empty + 3. Dispatch to cluster (atlas-01 + MET + PC) via cluster_then_polish + 4. Save output to vault under 00_Atlas_Wiki/_autonomy/_.md + 5. Maintain a "what happened tonight" log readable by operator in morning + +Generated tasks pull from a recurring backlog + state-driven prompts. +NO external messages, NO code modifications, NO destructive ops β€” pure +note generation + analysis + plan refinement. +""" +import sys +import os +import json +import time +import random +import urllib.request +from pathlib import Path +from datetime import datetime, date + +sys.path.insert(0, r"C:\Users\Gebruiker\.config\atlas") + +import io +if sys.stdout.encoding != 'utf-8': + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + +try: + from cluster_then_polish import draft, save_draft + import cluster +except ImportError as e: + print(f"missing dep: {e}") + sys.exit(1) + +VAULT_AUTONOMY = Path(r"C:\Users\Gebruiker\MEGA\Atlas Corporation\00_Atlas_Wiki\_autonomy") +QUEUE_FILE = Path(r"C:\Users\Gebruiker\.config\atlas\autonomy_queue.json") +LOG_FILE = Path(r"C:\Users\Gebruiker\.config\atlas\autonomy_loop.log") +STATE_URLS = { + "finance": "http://atlas-01/atlas-finance.json", + "strategy": "http://atlas-01/atlas-strategy.json", + "activity": "http://atlas-01/atlas-activity.json", + "priorities": "http://atlas-01/priorities.json", +} + +MAX_RUNTIME_SECONDS = 600 # 10 min hard cap per fire β€” never block schtask +MIN_QUEUE_SIZE = 5 + +# Recurring task templates β€” picked when queue is low +RECURRING_TEMPLATES = [ + { + "topic": "client_deepdive", + "rotation": "random_client", # picks from client list + "prompt_template": "Schrijf een korte client-summary voor {client}: hun sector, laatste touchpoint, recurring revenue potential, 1 concrete actie deze week. NL, 100 woorden max.", + "task": "general", + "node": "atlas-01", + }, + { + "topic": "season_action", + "prompt_template": "Gegeven huidige seasonal window {window} (vakantiegeld/kermis/etc.), wat is 1 specifieke actie voor Atlas Agency deze week die geen ander cluster ook al heeft? Schrijf in NL, 150 woorden.", + "task": "reason", + "node": "atlas-01", + }, + { + "topic": "vault_synthesis", + "prompt_template": "Vat de 3 belangrijkste lessons uit Atlas Q1 2026 in 100 woorden NL. Bron: YTD revenue €4.578, Bisogno 57%, mei €0 ingebrekening.", + "task": "general", + "node": "atlas-01", + }, + { + "topic": "anti_impulse_check", + "prompt_template": "Gegeven decision rules R1-R10 (anti-impulse hardware buy + cash-before-code), schrijf 1 paragraaf 'wat ik vannacht zou doen als ik in deze valkuil viel' (om operator te helpen herkennen). NL, 100 woorden.", + "task": "general", + "node": "atlas-01", + }, + { + "topic": "outreach_personalization", + "rotation": "random_prospect", + "prompt_template": "Schrijf 1 zin alternative opening voor cold WhatsApp aan {prospect} ({sector}) die anders is dan een template. NL, max 25 woorden, hook moet specifiek aan zaak-context aansluiten.", + "task": "chat", + "node": "pc", # PC heeft hermes3:8b voor chat-tone + }, + { + "topic": "weekly_metrics", + "prompt_template": "Gegeven YTD €{ytd}, MRR €{mrr}, concentration {conc}%, R10 status {r10}, wat is 1 KPI die niet maar in de dashboard zit en wel moet? NL, 80 woorden.", + "task": "reason", + "node": "atlas-01", + }, + { + "topic": "scale_path", + "prompt_template": "Atlas Agency target 1200 sites. Huidige: 1 (Charif). Wat is concrete week-22 actie die schaalbaarheid voorbereidt zonder grote build? NL, 150 woorden.", + "task": "reason", + "node": "atlas-01", + }, + { + "topic": "vault_health_check", + "prompt_template": "Atlas vault heeft 7000+ MD files. Welke 3 hygiene-acties zouden vault zoekbaarder maken zonder content te verliezen? NL, 100 woorden.", + "task": "general", + "node": "met", + }, +] + +# Known prospects (for rotation) +PROSPECTS = [ + {"name": "Lunchroom Carrousel", "sector": "lunchroom"}, + {"name": "June Tilburg", "sector": "koffiebar"}, + {"name": "Buutvrij", "sector": "lunchroom"}, + {"name": "Cafetaria Online", "sector": "cafetaria"}, + {"name": "Cafetaria Franske", "sector": "cafetaria"}, + {"name": "Kapper Muro", "sector": "kapper"}, + {"name": "The Crooks Barbershop", "sector": "barber"}, + {"name": "De Meesterbarbier", "sector": "kapper"}, + {"name": "Fresh Up Barbershop", "sector": "barber"}, + {"name": "Picasso Barbers", "sector": "kapper"}, +] + +KNOWN_CLIENTS = [ + "Koffie Bisogno", "Cargo Nexus", "HK International Care", + "Edessa Management", "Akmoh IT", "Ozside V.O.F.", + "Dushi Sazon", "Ayintap Food", "GobYtes B.V.", "TRWC B.V.", + "Coffee Almahaba", "WinstUITstroom", "Couscousbar", "Kolvari", + "Le Miris", "Sweet Mahaba", +] + + +def log(msg): + LOG_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}\n") + + +def fetch_state(url, timeout=4): + try: + with urllib.request.urlopen(url, timeout=timeout) as r: + return json.loads(r.read().decode("utf-8", errors="replace")) + except Exception: + return None + + +def load_queue(): + if QUEUE_FILE.exists(): + try: return json.loads(QUEUE_FILE.read_text(encoding="utf-8")) + except: pass + return {"pending": [], "completed": []} + + +def save_queue(q): + QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True) + QUEUE_FILE.write_text(json.dumps(q, indent=2, ensure_ascii=False), encoding="utf-8") + + +def generate_task(state): + """Pick a template + fill in dynamic vars.""" + tpl = random.choice(RECURRING_TEMPLATES) + fin = state.get("finance", {}) or {} + strat = state.get("strategy", {}) or {} + act = state.get("activity", {}) or {} + + vars_ctx = { + "client": random.choice(KNOWN_CLIENTS), + "ytd": fin.get("ytd_eur", 0), + "mrr": fin.get("mrr_estimate_eur", 0), + "conc": fin.get("concentration_top_pct", 0), + "r10": "PASS" if act.get("r10_pass") else "FAIL", + "window": (strat.get("current_window") or {}).get("name", "default"), + } + if tpl.get("rotation") == "random_prospect": + p = random.choice(PROSPECTS) + vars_ctx["prospect"] = p["name"] + vars_ctx["sector"] = p["sector"] + + try: + prompt = tpl["prompt_template"].format(**vars_ctx) + except KeyError as e: + log(f"template format fail {tpl['topic']}: {e}") + return None + + return { + "topic": tpl["topic"], + "prompt": prompt, + "task": tpl["task"], + "node": tpl["node"], + "context": vars_ctx, + "queued_at": datetime.now().isoformat(timespec="seconds"), + } + + +def refill_queue_if_low(q, state): + if len(q["pending"]) >= MIN_QUEUE_SIZE: + return + needed = MIN_QUEUE_SIZE * 2 - len(q["pending"]) + for _ in range(needed): + t = generate_task(state) + if t: q["pending"].append(t) + log(f"refilled queue: now {len(q['pending'])} pending") + + +def execute_one(task): + """Dispatch task to cluster via cluster_then_polish.draft().""" + t0 = time.time() + try: + result = draft( + task["prompt"], + task=task.get("task", "general"), + node_pref=task.get("node"), + max_tokens=500, + temperature=0.6, + purpose=f"autonomy_{task['topic']}", + ) + elapsed = round(time.time() - t0, 1) + if not result.get("ok"): + return {"ok": False, "err": result.get("err"), "elapsed": elapsed} + return {"ok": True, "text": result["text"], "model": result["model"], + "node": result["node"], "elapsed": elapsed, + "draft_id": result.get("draft_id")} + except Exception as e: + return {"ok": False, "err": str(e)[:200], "elapsed": round(time.time() - t0, 1)} + + +def save_output_to_vault(task, result): + """Write to 00_Atlas_Wiki/_autonomy/_.md""" + VAULT_AUTONOMY.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y-%m-%d_%H%M") + safe_topic = task["topic"].replace("/", "_")[:40] + fname = f"{ts}_{safe_topic}.md" + p = VAULT_AUTONOMY / fname + body = f"""--- +generated_at: {datetime.now().isoformat(timespec='seconds')} +topic: {task['topic']} +node: {result.get('node', '?')} +model: {result.get('model', '?')} +elapsed_s: {result.get('elapsed', 0)} +draft_id: {result.get('draft_id', '?')} +polish_status: pending +tags: [autonomy, generated, {task['topic']}] +context: {json.dumps(task.get('context', {}), ensure_ascii=False)} +--- + +# {task['topic'].replace('_', ' ').title()} + +> [!note] Autonomous output β€” review needed before any external use +> Generated by Atlas overnight autonomy loop. Cluster output, not Claude-polished. + +**Prompt**: {task['prompt']} + +--- + +{result.get('text', '(no output)')} + +--- + +*Source: atlas_self_prompting_loop.py Β· [[06.5 - Master Cockpit & 90-day System]]* +""" + p.write_text(body, encoding="utf-8") + return p + + +def main(): + log("=== loop fire ===") + start = time.time() + + # Fetch live state + state = {k: fetch_state(v) for k, v in STATE_URLS.items()} + log(f"state fetched: { {k: bool(v) for k, v in state.items()} }") + + # Verify cluster has at least one node + nodes_alive = cluster.nodes_status(force=False) + alive = [n for n, info in nodes_alive.items() if info.get("reachable")] + log(f"alive nodes: {alive}") + if not alive: + log("NO CLUSTER NODES ALIVE β€” abort this fire") + return + + # Queue management + q = load_queue() + refill_queue_if_low(q, state) + + # Execute up to 2 tasks per fire (rate-limit cluster + save energy) + executed = 0 + max_per_fire = 2 + while executed < max_per_fire and q["pending"]: + if time.time() - start > MAX_RUNTIME_SECONDS: + log("hit runtime cap") + break + task = q["pending"].pop(0) + log(f"executing: {task['topic']} ({task['node']}/{task['task']})") + result = execute_one(task) + if result["ok"]: + saved = save_output_to_vault(task, result) + q["completed"].append({ + **task, + "completed_at": datetime.now().isoformat(timespec="seconds"), + "model": result["model"], + "node": result["node"], + "elapsed_s": result["elapsed"], + "file": str(saved), + }) + log(f" OK {result['node']}/{result['model']} {result['elapsed']}s β†’ {saved.name}") + else: + q["completed"].append({ + **task, + "completed_at": datetime.now().isoformat(timespec="seconds"), + "err": result["err"], + }) + log(f" FAIL: {result['err']}") + executed += 1 + save_queue(q) + + # Keep only last 200 completed entries in queue file (rolling log) + if len(q["completed"]) > 200: + q["completed"] = q["completed"][-200:] + save_queue(q) + + log(f"=== loop end: executed {executed} ===") + + +if __name__ == "__main__": + main() diff --git a/scripts/atlas_tray.py b/scripts/atlas_tray.py new file mode 100644 index 0000000..f1782c6 --- /dev/null +++ b/scripts/atlas_tray.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +""" +atlas_tray.py - Atlas always-on system tray icon. + +Right-click menu for quick access to Atlas Command / HUD / Chat / Portal + +util actions. Icon color reflects cluster health. + +Run with pythonw.exe so no console flash: + pythonw atlas_tray.py +""" +import os +import sys +import time +import json +import webbrowser +import threading +import urllib.request +import subprocess +from pathlib import Path + +try: + import pystray + from pystray import MenuItem as Item, Menu + from PIL import Image, ImageDraw, ImageFont +except ImportError as e: + print(f"Missing dep: {e}\nInstall: pip install pystray pillow") + sys.exit(1) + +HOME = Path.home() +ATLAS = HOME / ".config" / "atlas" +LOG = ATLAS / "atlas_tray.log" + +CFG = { + "ollama_url": "http://atlas-01/ollama/api/tags", + "priorities": "http://atlas-01/priorities.json", + "poll_seconds": 30, +} + +STATE = { + "status": "unknown", + "overdue_eur": 0, + "disk_free_gb": 0, + "containers_down": 0, + "last_update": None, + "models": 0, +} + + +def log(msg): + try: + LOG.parent.mkdir(parents=True, exist_ok=True) + with open(LOG, "a", encoding="utf-8") as f: + f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n") + except Exception: + pass + + +def fetch_json(url, timeout=4): + try: + with urllib.request.urlopen(url, timeout=timeout) as r: + return json.loads(r.read().decode("utf-8", errors="replace")) + except Exception: + return None + + +def make_icon(color_hex="#3B82F6", text="A"): + """Generate 64x64 icon: filled circle + white letter.""" + size = 64 + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + d.ellipse((4, 4, size - 4, size - 4), fill=color_hex) + try: + font = ImageFont.truetype("arial.ttf", 38) + except Exception: + font = ImageFont.load_default() + bbox = d.textbbox((0, 0), text, font=font) + tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] + d.text(((size - tw) / 2 - bbox[0], (size - th) / 2 - bbox[1]), text, fill="white", font=font) + return img + + +def color_for(status): + return { + "ok": "#10B981", + "warn": "#F59E0B", + "err": "#EF4444", + "unknown": "#6B7280", + }.get(status, "#3B82F6") + + +def poll_loop(icon): + while True: + try: + pri = fetch_json(CFG["priorities"]) + tags = fetch_json(CFG["ollama_url"]) + + if pri: + STATE["overdue_eur"] = pri.get("odoo", {}).get("overdue_eur", 0) + STATE["disk_free_gb"] = pri.get("disk_free_gb", 0) + STATE["containers_down"] = pri.get("containers_down", 0) + STATE["last_update"] = time.strftime("%H:%M:%S") + + STATE["models"] = len(tags.get("models", [])) if tags else 0 + + if tags is None and pri is None: + STATE["status"] = "err" + elif STATE["containers_down"] > 0 or STATE["disk_free_gb"] < 5: + STATE["status"] = "err" + elif STATE["overdue_eur"] > 0 or STATE["disk_free_gb"] < 15: + STATE["status"] = "warn" + else: + STATE["status"] = "ok" + + try: + icon.icon = make_icon(color_for(STATE["status"])) + icon.title = ( + f"Atlas β€” {STATE['status'].upper()}\n" + f"Overdue: €{STATE['overdue_eur']}\n" + f"Disk: {STATE['disk_free_gb']}GB free\n" + f"Containers down: {STATE['containers_down']}\n" + f"Models on atlas-01: {STATE['models']}\n" + f"Updated: {STATE['last_update']}" + ) + except Exception as e: + log(f"icon update fail: {e}") + except Exception as e: + log(f"poll error: {e}") + + time.sleep(CFG["poll_seconds"]) + + +def open_url(url): + def _open(icon, item): + webbrowser.open(url) + log(f"opened url {url}") + return _open + + +def open_app(url, profile): + """Open in Edge --app mode for native-window feel.""" + edge = r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" + profile_dir = HOME / "AppData" / "Local" / "Atlas" / profile + + def _open(icon, item): + if not Path(edge).exists(): + webbrowser.open(url) + return + try: + subprocess.Popen([ + edge, + f"--app={url}", + f"--user-data-dir={profile_dir}", + "--no-first-run", "--no-default-browser-check", + ]) + log(f"app opened {url}") + except Exception as e: + log(f"app open fail: {e}") + return _open + + +def restart_ollama(icon, item): + log("restart ollama requested") + try: + subprocess.run(["taskkill", "/F", "/IM", "ollama.exe"], capture_output=True, timeout=5) + subprocess.run(["taskkill", "/F", "/IM", "ollama app.exe"], capture_output=True, timeout=5) + time.sleep(1) + ollama = HOME / "AppData" / "Local" / "Programs" / "Ollama" / "ollama.exe" + if ollama.exists(): + env = os.environ.copy() + env["OLLAMA_HOST"] = "0.0.0.0:11434" + env["OLLAMA_KEEP_ALIVE"] = "30m" + subprocess.Popen([str(ollama), "serve"], env=env, creationflags=0x08000000) + log("ollama restarted") + except Exception as e: + log(f"restart ollama fail: {e}") + + +def open_log(icon, item): + try: + subprocess.Popen(["notepad.exe", str(LOG)]) + except Exception: + webbrowser.open(f"file:///{LOG}") + + +def quit_tray(icon, item): + log("tray quit") + icon.stop() + + +def status_label(item): + return f"Status: {STATE['status'].upper()} Β· €{STATE['overdue_eur']} open Β· {STATE['disk_free_gb']}GB" + + +def updated_label(item): + return f"Updated: {STATE['last_update'] or 'never'}" + + +def main(): + log("=== atlas_tray starting ===") + + menu = Menu( + Item(status_label, lambda i, x: None, enabled=False), + Item(updated_label, lambda i, x: None, enabled=False), + Menu.SEPARATOR, + Item("Atlas Command Β· Ctrl+Shift+P", open_app("http://atlas-01/atlas-command.html", "Cockpit-Profile"), default=True), + Item("Atlas HUD Β· Ctrl+Shift+H", open_app("http://atlas-01/atlas-hud.html", "HUD-Profile")), + Item("Atlas Chat Β· Ctrl+Shift+C", open_app("http://atlas-01/atlas-chat.html", "Chat-Profile")), + Item("Atlas Portal (legacy)", open_url("http://atlas-01/portal.html")), + Menu.SEPARATOR, + Item("Odoo", open_url("http://:8069")), + Item("Nextcloud", open_url("http://cloud.your-domain.tld")), + Item("MeshCentral", open_url("http://mesh.your-domain.tld")), + Item("Open WebUI", open_url("http://:8082")), + Menu.SEPARATOR, + Item("Restart Ollama (PC)", restart_ollama), + Item("Open tray log", open_log), + Menu.SEPARATOR, + Item("Quit", quit_tray), + ) + + img = make_icon(color_for("unknown")) + icon = pystray.Icon("atlas", img, "Atlas (loading...)", menu) + + threading.Thread(target=poll_loop, args=(icon,), daemon=True).start() + icon.run() + + +if __name__ == "__main__": + main() diff --git a/scripts/cluster.py b/scripts/cluster.py new file mode 100644 index 0000000..0353d48 --- /dev/null +++ b/scripts/cluster.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +cluster.py β€” multi-node Ollama orchestrator. + +Three potential nodes: + - pc : localhost (this PC, GPU, 10 models, fast but laptop-up only) + - atlas-01 : :11434 (Hetzner VPS, CPU, 5 models, always-on) + - met : :11434 (MET desktop, secondary capacity) + +Routing strategy: + - For "always_on" tasks (overnight, scheduled, server-side): prefer atlas-01 + - For "fast" tasks (interactive, jarvis, code completion): prefer pc + - For "embed": prefer pc (nomic on both, pc is faster) + - For "vision" (qwen2.5vl): pc only (atlas-01 doesn't have it) + - Fallback chain: if preferred unreachable β†’ try next available node that has the model + +Public API: + ask(prompt, task='general', node_pref=None, max_tokens=512) -> {ok, text, node, model, elapsed_s} + embed(text) -> {ok, vector, node, model} + nodes_status() -> [{name, reachable, latency_ms, models}] + pick_node(task, model=None) -> (node_name, model_name) +""" +from __future__ import annotations +import json, time, urllib.request, urllib.error, socket + +NODES = { + 'pc': {'host': '127.0.0.1', 'port': 11434, 'always_on': False, 'fast': True, 'has_gpu': True}, + 'atlas-01': {'host': '', 'port': 11434, 'always_on': True, 'fast': False, 'has_gpu': False}, + # FIX 2026-05-26: was (which is PC's own tailnet IP). + # MET-Desktop actually lives at . + 'met': {'host': '','port': 11434, 'always_on': False, 'fast': True, 'has_gpu': False}, +} + +TASK_MODEL = { + 'chat': 'hermes3:8b', + 'voice': 'llama3.2:3b', + 'fast': 'llama3.2:1b', + 'code': 'qwen2.5-coder:7b', + 'code_fast': 'qwen2.5-coder:1.5b', + 'reason': 'phi3:medium', + 'general': 'qwen2.5:7b', + 'general_fast':'qwen2.5:1.5b', + 'embed': 'nomic-embed-text', + 'vision': 'qwen2.5vl:7b', +} + +# Per-node model availability β€” populated lazily on first nodes_status() call. +_NODE_MODELS_CACHE = {'pc': None, 'atlas-01': None, 'met': None} +_CACHE_AT = {'pc': 0, 'atlas-01': 0, 'met': 0} +_CACHE_TTL = 300 # 5 min + +def _http(url: str, body: dict | None = None, timeout=8): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method='POST' if body else 'GET') + if body: req.add_header('Content-Type', 'application/json') + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read().decode()) + +def _check_node(name: str) -> dict: + """Probe a node β€” returns {reachable, latency_ms, models, err?}.""" + if name not in NODES: return {'reachable': False, 'err': 'unknown node'} + n = NODES[name] + t0 = time.time() + try: + # Fast TCP check first + with socket.create_connection((n['host'], n['port']), timeout=2): pass + # Then fetch tags + r = _http(f"http://{n['host']}:{n['port']}/api/tags", timeout=5) + models = [m['name'] for m in r.get('models', [])] + return {'reachable': True, 'latency_ms': int((time.time() - t0) * 1000), 'models': models} + except (socket.error, urllib.error.URLError, urllib.error.HTTPError) as e: + return {'reachable': False, 'latency_ms': int((time.time() - t0) * 1000), 'err': str(e)[:60]} + +def nodes_status(force=False) -> dict: + """Return per-node {reachable, models, latency_ms}. Cached 5 min.""" + out = {} + now = time.time() + for name in NODES: + if not force and _NODE_MODELS_CACHE[name] is not None and now - _CACHE_AT[name] < _CACHE_TTL: + out[name] = _NODE_MODELS_CACHE[name] + else: + s = _check_node(name) + _NODE_MODELS_CACHE[name] = s + _CACHE_AT[name] = now + out[name] = s + return out + +def pick_node(task: str = 'general', model: str | None = None, prefer: str | None = None) -> tuple[str, str] | None: + """Decide (node, model) for a task. Returns None if no node available.""" + if not model: model = TASK_MODEL.get(task, TASK_MODEL['general']) + status = nodes_status() + + # Build preference order + order = [] + if prefer and prefer in NODES: order.append(prefer) + # task-driven defaults + if task in ('embed',) or task.endswith('_fast') or task == 'fast' or task == 'voice': + order += ['pc', 'atlas-01', 'met'] + elif task == 'vision': + order += ['pc'] # only PC has vision model + else: + order += ['pc', 'atlas-01', 'met'] + # dedupe preserving order + seen = set(); order = [n for n in order if not (n in seen or seen.add(n))] + + for name in order: + s = status.get(name, {}) + if not s.get('reachable'): continue + models = s.get('models', []) + # exact match + if model in models: return (name, model) + # match-by-prefix (qwen2.5-coder:7b matches qwen2.5-coder:7b-instruct-q4_K_M etc.) + for m in models: + if m.startswith(model.split(':')[0]): return (name, m) + return None + +def ask(prompt: str, task: str = 'general', node_pref: str | None = None, + max_tokens: int = 512, temperature: float = 0.4, timeout: int = 120) -> dict: + pick = pick_node(task, prefer=node_pref) + if not pick: return {'ok': False, 'err': 'no node has the required model'} + node, model = pick + n = NODES[node] + t0 = time.time() + try: + r = _http(f"http://{n['host']}:{n['port']}/api/generate", { + 'model': model, 'prompt': prompt, 'stream': False, + 'options': {'temperature': temperature, 'num_predict': max_tokens}, + }, timeout=timeout) + return {'ok': True, 'text': r.get('response', '').strip(), + 'node': node, 'model': model, + 'elapsed_s': round(time.time() - t0, 2)} + except Exception as e: + return {'ok': False, 'err': str(e)[:120], 'node': node, 'model': model, + 'elapsed_s': round(time.time() - t0, 2)} + +def embed(text: str, node_pref: str | None = None) -> dict: + pick = pick_node('embed', prefer=node_pref) + if not pick: return {'ok': False, 'err': 'no embed model'} + node, model = pick + n = NODES[node] + t0 = time.time() + try: + r = _http(f"http://{n['host']}:{n['port']}/api/embeddings", { + 'model': model, 'prompt': text, + }, timeout=30) + return {'ok': True, 'vector': r.get('embedding', []), + 'node': node, 'model': model, + 'elapsed_s': round(time.time() - t0, 2)} + except Exception as e: + return {'ok': False, 'err': str(e)[:120]} + +if __name__ == '__main__': + import sys + cmd = sys.argv[1] if len(sys.argv) > 1 else 'status' + if cmd == 'status': + import io + try: sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + except: pass + s = nodes_status(force=True) + for name, info in s.items(): + mark = 'OK ' if info.get('reachable') else 'FAIL' + mc = len(info.get('models', [])) + lat = info.get('latency_ms', '?') + err = info.get('err', '') + print(f' {mark} {name:10s} {mc:>2d} models {lat:>4}ms {err}') + elif cmd == 'ask': + task = sys.argv[2] if len(sys.argv) > 2 else 'general' + prompt = ' '.join(sys.argv[3:]) or 'say OK only' + r = ask(prompt, task=task) + if r['ok']: + print(f'[{r["node"]} / {r["model"]} / {r["elapsed_s"]}s]') + print(r['text']) + else: + print('ERR:', r.get('err')) + elif cmd == 'pick': + task = sys.argv[2] if len(sys.argv) > 2 else 'general' + prefer = sys.argv[3] if len(sys.argv) > 3 else None + p = pick_node(task, prefer=prefer) + print(p or 'no node available') diff --git a/scripts/cluster_then_polish.py b/scripts/cluster_then_polish.py new file mode 100644 index 0000000..5df0000 --- /dev/null +++ b/scripts/cluster_then_polish.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +""" +cluster_then_polish.py β€” two-pass LLM pattern wrapper. + +Wraps cluster.ask() with explicit "this is a draft, mark for human/Claude polish" +metadata. Goal: cluster does the heavy lifting (parallel, cheap, always-on), +Claude or human review handles the final polish. + +Why: open-source 7B models can produce structured / classified / extracted output +reliably, but produce generic narrative text without grounding. Letting them +"finish" content sent to clients = quality loss. This wrapper bakes in the +two-pass expectation: cluster draft β†’ human/Claude approval before any external use. + +Public API: + draft(prompt, task='general', node_pref=None, **kw) -> {ok, text, model, node, polish_required: True, draft_id, hint} + save_draft(draft, path) -> writes md with frontmatter + mark_polished(draft_id) -> updates draft registry + +Usage example: + from cluster_then_polish import draft, save_draft + d = draft("Schrijf cold-WhatsApp voor lunchroom in Tilburg", task='chat', node_pref='pc') + save_draft(d, '~/.config/atlas/drafts/lunchroom_outreach.md') + # User/Claude reviews + edits before sending + +Registry: drafts logged to ~/.config/atlas/drafts/_registry.json with +status (draft/polished/sent), creation time, node/model used, prompt hash. +""" +from __future__ import annotations +import sys, json, hashlib, time +from pathlib import Path +from datetime import datetime + +HOME = Path.home() +ATLAS = HOME / '.config' / 'atlas' +DRAFTS_DIR = ATLAS / 'drafts' +REGISTRY = DRAFTS_DIR / '_registry.json' + +sys.path.insert(0, str(ATLAS)) +try: + import cluster +except ImportError: + raise RuntimeError("cluster.py not importable; check ~/.config/atlas/cluster.py") + + +# Task types that should ALWAYS go through polish (narrative, klant-facing) +POLISH_REQUIRED_TASKS = {'chat', 'reason', 'general'} + +# Task types where cluster output is directly usable (structured, classified) +POLISH_OPTIONAL_TASKS = {'fast', 'embed', 'code', 'vision'} + + +def _hash_prompt(prompt: str) -> str: + return hashlib.sha1(prompt.encode('utf-8')).hexdigest()[:10] + + +def _load_registry() -> dict: + DRAFTS_DIR.mkdir(parents=True, exist_ok=True) + if REGISTRY.exists(): + try: + return json.loads(REGISTRY.read_text(encoding='utf-8')) + except Exception: + pass + return {"drafts": {}} + + +def _save_registry(reg: dict) -> None: + REGISTRY.write_text(json.dumps(reg, indent=2), encoding='utf-8') + + +def draft(prompt: str, task: str = 'general', node_pref: str | None = None, + max_tokens: int = 800, temperature: float = 0.5, timeout: int = 180, + purpose: str = '') -> dict: + """ + Call cluster.ask() but tag the result as draft requiring polish. + + :param purpose: short label (e.g. 'cold_outreach', 'morning_brief', 'mail_triage') + used for the draft filename and routing decisions + """ + t0 = time.time() + r = cluster.ask(prompt, task=task, node_pref=node_pref, + max_tokens=max_tokens, temperature=temperature, timeout=timeout) + elapsed = round(time.time() - t0, 1) + + if not r.get('ok'): + return { + "ok": False, + "err": r.get('err', 'cluster error'), + "polish_required": True, + "hint": "cluster call failed; do not use any partial output" + } + + draft_id = f"{int(time.time())}-{_hash_prompt(prompt)}" + polish_required = task in POLISH_REQUIRED_TASKS + + result = { + "ok": True, + "draft_id": draft_id, + "text": r['text'], + "model": r.get('model', '?'), + "node": r.get('node', '?'), + "elapsed_s": elapsed, + "task": task, + "purpose": purpose, + "polish_required": polish_required, + "polish_status": "pending" if polish_required else "not_required", + "created_at": datetime.now().isoformat(timespec='seconds'), + "hint": ( + "POLISH REQUIRED before external use. Cluster 7B models produce " + "generic narrative; review for: specificity, sector-fit, tone, " + "factual claims. Then call mark_polished(draft_id)." + if polish_required else + "Structured output, safe to use as-is for triage/classify. " + "Manual spot-check still recommended." + ) + } + + # Register + reg = _load_registry() + reg['drafts'][draft_id] = { + "purpose": purpose, + "task": task, + "node": result['node'], + "model": result['model'], + "created_at": result['created_at'], + "polish_status": result['polish_status'], + "prompt_hash": _hash_prompt(prompt), + "char_count": len(r['text']), + } + _save_registry(reg) + + return result + + +def save_draft(draft_result: dict, path: str | Path) -> Path: + """Save draft to .md with frontmatter so reviewer knows status.""" + p = Path(path).expanduser() + p.parent.mkdir(parents=True, exist_ok=True) + + fm = ( + f"---\n" + f"draft_id: {draft_result.get('draft_id', '?')}\n" + f"polish_status: {draft_result.get('polish_status', '?')}\n" + f"node: {draft_result.get('node', '?')}\n" + f"model: {draft_result.get('model', '?')}\n" + f"task: {draft_result.get('task', '?')}\n" + f"purpose: {draft_result.get('purpose', '?')}\n" + f"created_at: {draft_result.get('created_at', '?')}\n" + f"polish_required: {draft_result.get('polish_required', False)}\n" + f"---\n\n" + f"> **HINT**: {draft_result.get('hint', '')}\n\n" + ) + p.write_text(fm + draft_result.get('text', ''), encoding='utf-8') + return p + + +def mark_polished(draft_id: str, polished_by: str = 'human') -> bool: + """Update registry to indicate draft has been reviewed/polished.""" + reg = _load_registry() + if draft_id in reg.get('drafts', {}): + reg['drafts'][draft_id]['polish_status'] = 'polished' + reg['drafts'][draft_id]['polished_by'] = polished_by + reg['drafts'][draft_id]['polished_at'] = datetime.now().isoformat(timespec='seconds') + _save_registry(reg) + return True + return False + + +def list_pending(purpose_filter: str = '') -> list: + """Return list of drafts still awaiting polish (polish_status='pending').""" + reg = _load_registry() + out = [] + for did, info in reg.get('drafts', {}).items(): + if info.get('polish_status') != 'pending': + continue + if purpose_filter and purpose_filter not in info.get('purpose', ''): + continue + out.append({"draft_id": did, **info}) + return sorted(out, key=lambda x: x.get('created_at', ''), reverse=True) + + +if __name__ == '__main__': + import argparse + ap = argparse.ArgumentParser() + ap.add_argument('prompt', nargs='?', help='Prompt text') + ap.add_argument('-task', default='general') + ap.add_argument('-node', default=None) + ap.add_argument('-purpose', default='ad_hoc') + ap.add_argument('-out', default=None, help='Save draft to this path') + ap.add_argument('--list-pending', action='store_true') + args = ap.parse_args() + + if args.list_pending: + for d in list_pending(): + print(f"{d['draft_id']} [{d['purpose']}] {d['model']}@{d['node']} {d['char_count']}ch {d['created_at']}") + sys.exit(0) + + if not args.prompt: + print("usage: cluster_then_polish.py 'prompt' [-task T] [-node N] [-purpose P] [-out FILE]") + sys.exit(1) + + d = draft(args.prompt, task=args.task, node_pref=args.node, purpose=args.purpose) + if not d['ok']: + print(f"ERROR: {d.get('err')}") + sys.exit(1) + + print(f"[{d['node']}/{d['model']} {d['elapsed_s']}s] draft_id={d['draft_id']}") + print(f"polish_status: {d['polish_status']}") + print(f"--- text ({len(d['text'])} chars) ---") + print(d['text']) + print() + + if args.out: + p = save_draft(d, args.out) + print(f"saved -> {p}") diff --git a/scripts/vault_rag_expand.py b/scripts/vault_rag_expand.py new file mode 100644 index 0000000..35e349c --- /dev/null +++ b/scripts/vault_rag_expand.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +""" +vault_rag_expand.py β€” Atlas Mind expansion. + +Walks selected vault folders + batch uploads .md files to Open WebUI KB +so ALL cluster models query against the same knowledge graph. + +Curated set (signal > noise): + - 00_Atlas_Wiki/* β€” operator-curated canonical pages + - 01_Clients/* β€” client briefs + relationship history + - 04_Sales/* β€” pipeline, pricing, templates + - 06_Marketing/* β€” brand, positioning, campaigns + - 07_Finance/*.md β€” money facts + decisions + +Skip: + - 99_Quarantine/ + 99_Archive/ (dead) + - .git/ .obsidian/ (config) + - Files > 100 KB (template-heavy, low signal) +""" +import os +import json +import time +import hashlib +import urllib.request +import urllib.error +from pathlib import Path + +OPENWEBUI = "http://:8082" +KB_ID = "c2b9f985-69f6-4dee-b216-73d15bdfb8bd" +TOKEN_PATH = Path(r"C:\Users\Gebruiker\AppData\Local\Atlas\openwebui-token.json") +VAULT_ROOT = Path(r"C:\Users\Gebruiker\MEGA\Atlas Corporation") +INDEX_LOG = Path(r"C:\Users\Gebruiker\AppData\Local\Atlas\analyses\rag_index.json") + +INCLUDE_FOLDERS = [ + "00_Atlas_Wiki", + "01_Clients", + "04_Sales", + "06_Marketing", + "07_Finance", +] +EXCLUDE_KEYWORDS = ["99_Quarantine", "99_Archive", "_archive", ".git", ".obsidian", + "backup", "_backup", "draft-202", "old_"] +MAX_FILE_SIZE = 100_000 # 100 KB +MAX_FILES_PER_RUN = 200 # rate-limit per cron tick + + +def load_token(): + with open(TOKEN_PATH) as f: + return json.load(f)["token"] + + +def load_index(): + if INDEX_LOG.exists(): + try: return json.loads(INDEX_LOG.read_text(encoding="utf-8")) + except: pass + return {"uploaded": {}, "errors": []} + + +def save_index(idx): + INDEX_LOG.parent.mkdir(parents=True, exist_ok=True) + INDEX_LOG.write_text(json.dumps(idx, indent=2), encoding="utf-8") + + +def file_hash(p): + return hashlib.sha1(p.read_bytes()).hexdigest()[:12] + + +def list_candidates(): + """Walk include-folders, yield (Path, relpath_str, hash).""" + out = [] + for folder in INCLUDE_FOLDERS: + root = VAULT_ROOT / folder + if not root.exists(): + print(f"skip (not found): {root}") + continue + for p in root.rglob("*.md"): + rel = p.relative_to(VAULT_ROOT).as_posix() + if any(kw in rel for kw in EXCLUDE_KEYWORDS): continue + if p.stat().st_size > MAX_FILE_SIZE: continue + if p.stat().st_size < 100: continue # near-empty + try: + h = file_hash(p) + out.append((p, rel, h)) + except Exception: + pass + return out + + +def upload_file(token, path: Path, relpath: str): + """Upload single file to Open WebUI via multipart, then attach to KB.""" + # Build multipart manually (urllib doesn't have it natively, use boundary) + import uuid + boundary = "----atlas-" + uuid.uuid4().hex + filename = path.name + file_bytes = path.read_bytes() + body_parts = [ + f"--{boundary}".encode(), + f'Content-Disposition: form-data; name="file"; filename="{filename}"'.encode(), + b"Content-Type: text/markdown", + b"", + file_bytes, + f"--{boundary}--".encode(), + b"", + ] + body = b"\r\n".join(body_parts) + + # Step 1: upload file + req = urllib.request.Request( + f"{OPENWEBUI}/api/v1/files/", + data=body, method="POST", + ) + req.add_header("Authorization", f"Bearer {token}") + req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") + try: + with urllib.request.urlopen(req, timeout=30) as r: + file_info = json.loads(r.read().decode()) + file_id = file_info.get("id") + if not file_id: + return False, "no file_id returned" + except urllib.error.HTTPError as e: + return False, f"upload HTTP {e.code}: {e.read().decode()[:120]}" + except Exception as e: + return False, f"upload err: {type(e).__name__}" + + # Step 2: attach to KB + req2 = urllib.request.Request( + f"{OPENWEBUI}/api/v1/knowledge/{KB_ID}/file/add", + data=json.dumps({"file_id": file_id}).encode(), + method="POST", + ) + req2.add_header("Authorization", f"Bearer {token}") + req2.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req2, timeout=30) as r: + r.read() + return True, file_id + except urllib.error.HTTPError as e: + return False, f"attach HTTP {e.code}: {e.read().decode()[:120]}" + except Exception as e: + return False, f"attach err: {type(e).__name__}" + + +def main(): + token = load_token() + idx = load_index() + cands = list_candidates() + print(f"Found {len(cands)} candidate files in {len(INCLUDE_FOLDERS)} folders") + + new_to_upload = [] + skipped = 0 + for p, rel, h in cands: + prev = idx["uploaded"].get(rel) + if prev and prev.get("hash") == h: + skipped += 1 + continue + new_to_upload.append((p, rel, h)) + + new_to_upload = new_to_upload[:MAX_FILES_PER_RUN] + print(f"Already uploaded (skip): {skipped}") + print(f"To upload this run: {len(new_to_upload)} (cap {MAX_FILES_PER_RUN})") + + ok_count = 0 + err_count = 0 + t_start = time.time() + for i, (p, rel, h) in enumerate(new_to_upload, 1): + success, info = upload_file(token, p, rel) + if success: + idx["uploaded"][rel] = {"file_id": info, "hash": h, "ts": time.strftime("%Y-%m-%d %H:%M:%S")} + ok_count += 1 + print(f" [{i}/{len(new_to_upload)}] OK {rel}") + else: + idx["errors"].append({"rel": rel, "err": str(info), "ts": time.strftime("%Y-%m-%d %H:%M:%S")}) + err_count += 1 + print(f" [{i}/{len(new_to_upload)}] FAIL {rel} -> {info}") + # Save state every 10 uploads in case we crash + if i % 10 == 0: + save_index(idx) + time.sleep(0.3) # gentle pacing + + save_index(idx) + elapsed = round(time.time() - t_start, 1) + print(f"\nDone in {elapsed}s") + print(f" uploaded: {ok_count}") + print(f" errors: {err_count}") + print(f" total in index: {len(idx['uploaded'])}") + + +if __name__ == "__main__": + main() diff --git a/web/atlas-chat.html b/web/atlas-chat.html new file mode 100644 index 0000000..a46d6e1 --- /dev/null +++ b/web/atlas-chat.html @@ -0,0 +1,840 @@ + + + + + + + + +Atlas Chat + + + + + + + +
+ + + + + +
+
+ + +
Atlas Chat
+
+ +
+
+

Atlas Chat

+

Lokale cluster (3 nodes, 25 modellen) of Claude API als je een sleutel hebt gezet.
+ Geen Claude-UI nodig.

+
+
+ πŸ“Š Cash check + Wat staat er open in Odoo en wat is de status? +
+
+ πŸ“‹ Sprint plan + Maak een dag-planning voor vandaag op basis van Asana +
+
+ ✍️ Outreach + Schrijf een cold-WhatsApp voor een Tilburgse barbershop +
+
+ πŸ”§ Server check + Welke containers op atlas-01 draaien niet? +
+
+
+
+ +
+
+ + +
+
+ Gereed + +
+
+
+ +
+ + + + + + + + diff --git a/web/atlas-command.html b/web/atlas-command.html new file mode 100644 index 0000000..ec06039 --- /dev/null +++ b/web/atlas-command.html @@ -0,0 +1,984 @@ + + + + + + +Atlas Command β€” unified cockpit + + + + + + + +
+ + +
+
+ ATLAS COMMAND + unified cockpit +
+
+ β–Έ LIVEladen… +
+ + + + + + +
+ + + + + +
+
+
CHAT
+
BRIEF
+
BUY?
+
πŸŒ™ AUTONOMY
+
HUD
+
PORTAL
+
+
+
+
+
+
ATLAS
+
+

Welkom in de unified cockpit. Type een vraag, klik 🎀 ATLAS in de top bar voor voice, of switch naar BRIEF voor live cijfers.

+

Sneltoetsen: Ctrl+1/2/3/4 tabs Β· Ctrl+Space voice Β· Ctrl+K focus input

+
+
+
+
+ + +
+
+
+
+

πŸ›’ Pre-buy ROI calculator (Rule R1-R3)

+

Vul in vΓ³Γ³r je een hardware-lot bestelt. Krijg een direct GO/NO-GO advies op basis van strategic_plan rules.

+
+

πŸ“¦ Inputs

+
+ + + + + + +
+ +
+
+
+
+
+

πŸŒ™ Autonomy Loop β€” live view

+ loading… + + +
+

laden…

+
+
+
+
+
+ + + + + +
+ atlas-01 + pc + met + vault rag + Atlas Command v0.1 Β· --:-- Β· Ctrl+Shift+P recall +
+ +
+ + + + diff --git a/web/atlas-hud.html b/web/atlas-hud.html new file mode 100644 index 0000000..02cf1a7 --- /dev/null +++ b/web/atlas-hud.html @@ -0,0 +1,952 @@ + + + + + + +Atlas HUD + + + + + + + +
+
A T L A S
+
+
+ +
+ + +
+
+ ATLAS HUD + v0.1 Β· jarvis-class +
+
+ CLUSTER + MIC + CAM + + + +
+
+
+
+ + + + + +
+
+
+ + + + + + + + + + + + + + +
IDLE
+
+
+ + + + + +
+
+
+
+
+ + +
+
+ +
+ + + +