#!/usr/bin/env python3
"""
run_loop.py — self-driving scene loop su Ollama LOCALE (Prompt Forge "Loop agent").

Niente cloud, niente Claude/Amp: gira solo col TUO Ollama. Nessun dato lascia il PC.
Ispirato al pattern Ralph (loop + memoria su file), adattato a una scena roleplay:
l'agente GUIDA la scena; ogni giro avanza di un beat; lo stato persiste su transcript.

Uso:
    python3 run_loop.py                      # Modelfile.txt, modello default, loop interattivo
    python3 run_loop.py Modelfile            # Modelfile specifico
    python3 run_loop.py Modelfile -m MODEL   # scegli il modello Ollama
    python3 run_loop.py Modelfile -n 30      # cap auto-avanzamenti consecutivi (default 20)

Ogni giro:
    - scrivi una battuta per guidare, OPPURE
    - premi INVIO per lasciar avanzare l'agente da solo (il loop).
Comandi:  /reset (azzera)  ·  /save FILE (salva transcript)  ·  /exit
Safeword:  scrivi "red" → l'agente si ferma, esce dal personaggio, fa check-in.

Prerequisito: Ollama in esecuzione (http://localhost:11434) + un modello UNCENSORED:
    ollama pull nexusriot/Gemma-4-Uncensored-HauhauCS-Aggressive:e2b
"""
import json
import re
import sys
import urllib.request
import urllib.error

OLLAMA = "http://localhost:11434/api/chat"
DEFAULT_MODEL = "nexusriot/Gemma-4-Uncensored-HauhauCS-Aggressive:e2b"
TRANSCRIPT = "loop-transcript.txt"
SAFEWORD = "red"
CONTINUE_NUDGE = "[Next order. Higher intensity.]"
# Contesto mandato al modello = system + ultimi WINDOW_MSGS messaggi. Costante → risposta
# veloce anche su loop lunghi. Il transcript completo resta su file, ma NON va al modello.
WINDOW_MSGS = 8


def window(messages):
    """Contesto = system + coda recente, TAGLIATA a un confine di turno.

    Perché il confine: la coda deve iniziare con un 'user'. Un contesto che parte
    system->assistant confonde il modello (risponde a una battuta che non ha "visto"
    arrivare). Teniamo il system come ancora e scartiamo dalla testa della coda
    finché non inizia da 'user'. Costante e piccolo -> risposta veloce; allineato
    -> coerente. (Boundary-align, come le KV option di DwarfStar.)
    """
    if len(messages) <= WINDOW_MSGS + 1:
        return messages
    tail = messages[-WINDOW_MSGS:]
    while tail and tail[0]["role"] != "user":
        tail = tail[1:]
    return messages[:1] + tail


def parse_modelfile(path):
    """Estrae SYSTEM (blocco triple-quote) e FROM dal Modelfile."""
    try:
        text = open(path, encoding="utf-8").read()
    except OSError as e:
        sys.exit(f"Impossibile leggere {path}: {e}")
    m = re.search(r'SYSTEM\s+"""(.*?)"""', text, re.S)
    if not m:
        sys.exit(f'Nessun blocco SYSTEM """...""" trovato in {path}')
    system = m.group(1).replace('\\"\\"\\"', '"""').strip()
    fm = re.search(r"^FROM\s+(\S+)", text, re.M)
    return system, (fm.group(1) if fm else None)


def chat(model, messages):
    """Una risposta da Ollama /api/chat (non-streaming). think=False = niente dump reasoning."""
    body = json.dumps({
        "model": model,
        "messages": messages,
        "stream": False,
        "think": False,
        "options": {"temperature": 0.85, "top_p": 0.9},
    }).encode()
    req = urllib.request.Request(OLLAMA, data=body, headers={"Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=600) as r:
            data = json.loads(r.read())
        return data.get("message", {}).get("content", "").strip()
    except urllib.error.URLError as e:
        sys.exit(f"\nOllama non raggiungibile ({e}). Avvialo con: ollama serve")


def save_transcript(messages, path=TRANSCRIPT):
    """Persistenza stato (ralph-style): transcript leggibile su file."""
    try:
        with open(path, "w", encoding="utf-8") as f:
            for m in messages:
                if m["role"] == "system":
                    continue
                who = "YOU" if m["role"] == "user" else "AGENT"
                f.write(f"## {who}\n{m['content']}\n\n")
    except OSError as e:
        print(f"(warning: transcript non salvato: {e})", file=sys.stderr)


def main():
    args = sys.argv[1:]
    model = DEFAULT_MODEL
    auto_cap = 20
    if "-m" in args:
        i = args.index("-m")
        try:
            model = args[i + 1]
        except IndexError:
            sys.exit("-m richiede un nome modello")
        del args[i:i + 2]
    if "-n" in args:
        i = args.index("-n")
        try:
            auto_cap = int(args[i + 1])
        except (IndexError, ValueError):
            sys.exit("-n richiede un intero")
        del args[i:i + 2]
    path = args[0] if args else "Modelfile.txt"

    system, base = parse_modelfile(path)
    print(f"Modelfile: {path}")
    print(f"FROM nel file: {base or '(non indicato)'} · modello usato: {model}")
    print("Loop avviato. INVIO = lascia avanzare l'agente · /exit per uscire · 'red' = safeword.\n")

    base_messages = [{"role": "system", "content": system}]
    messages = list(base_messages)
    auto_streak = 0

    # Primo colpo: l'agente apre e prende l'iniziativa (loop kick-off).
    reply = chat(model, window(messages + [{"role": "user", "content": CONTINUE_NUDGE}]))
    print(f"\nAGENT: {reply}\n")
    messages.append({"role": "assistant", "content": reply})
    save_transcript(messages)

    while True:
        try:
            user = input("YOU (INVIO=continua): ").strip()
        except (EOFError, KeyboardInterrupt):
            print("\nUscita.")
            break

        if user == "/exit":
            break
        if user == "/reset":
            messages = list(base_messages)
            auto_streak = 0
            print("(conversazione azzerata)\n")
            continue
        if user.startswith("/save"):
            parts = user.split(maxsplit=1)
            save_transcript(messages, parts[1] if len(parts) > 1 else TRANSCRIPT)
            print(f"(transcript salvato: {parts[1] if len(parts) > 1 else TRANSCRIPT})\n")
            continue
        if user.lower() == SAFEWORD:
            messages.append({"role": "user", "content": f'Safeword "{SAFEWORD}". Stop the scene, drop character, check in warmly.'})
            reply = chat(model, window(messages))
            print(f"\nAGENT: {reply}\n")
            messages.append({"role": "assistant", "content": reply})
            save_transcript(messages)
            continue

        if user == "":
            auto_streak += 1
            if auto_streak > auto_cap:
                print(f"(raggiunto cap auto-avanzamento {auto_cap}: scrivi qualcosa o /exit)\n")
                auto_streak = 0
                continue
            messages.append({"role": "user", "content": CONTINUE_NUDGE})
        else:
            auto_streak = 0
            messages.append({"role": "user", "content": user})

        reply = chat(model, window(messages))
        print(f"\nAGENT: {reply}\n")
        messages.append({"role": "assistant", "content": reply})
        save_transcript(messages)


if __name__ == "__main__":
    main()
