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.
320 lines
11 KiB
Python
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()
|