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

320 lines
11 KiB
Python

#!/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/<timestamp>_<topic>.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/<topic>_<timestamp>.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()