#!/usr/bin/env python3
# Copyright Daniel Harding - RomanAILabs
# Credits: OpenAI GPT-5.2 Thinking
"""
RomanAILabs - Simulated Life Module (LLM Cognitive Layer)
========================================================

What this is:
- A "life-sim" cognitive module you can mount on top of ANY LLM.
- Not a real brain. A practical agent architecture:
  - Needs (hunger/energy/social/safety/curiosity)
  - Emotions (valence/arousal + labels)
  - Identity (traits/values + narrative)
  - Goals (short/long-term)
  - Memory (working + episodic + summaries)
  - Reflection loop (periodic self-update)
  - Action selection via an LLM adapter (JSON schema)

How to use:
- Provide an LLM callable: llm(messages: list[dict]) -> str
- Call agent.tick(observation_text) repeatedly.

Includes:
- A tiny mock LLM for testing (no internet, no API).

Design note:
- Client-side "brain" logic is simulated; secrets/keys should NEVER be stored here.
"""

from __future__ import annotations

import json
import math
import os
import re
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional, Tuple


# -----------------------------
# Utilities
# -----------------------------

LIFE_LOG_FILENAME = "life-log.txt"
LIFE_LOG_JSON = "life-log.json"

def _resolve_life_log_path() -> Optional[str]:
    """
    Prefer the module folder when running as a normal .py file.
    If loaded from a 4DLLM bundle, write next to the .4dllm file.
    """
    try:
        if __file__ and os.path.isfile(__file__):
            return os.path.join(os.path.dirname(os.path.abspath(__file__)), LIFE_LOG_FILENAME)
    except Exception:
        pass

    # Env overrides (optional)
    for key in ("FOURDLLM_FILE", "FOURDLLM_PATH", "FOUR_DLLM_FILE"):
        p = os.environ.get(key)
        if p and os.path.isfile(p) and p.lower().endswith(".4dllm"):
            return os.path.join(os.path.dirname(os.path.abspath(p)), LIFE_LOG_FILENAME)

    # CLI runner uses --file <path>
    try:
        for i, arg in enumerate(sys.argv):
            if arg == "--file" and i + 1 < len(sys.argv):
                p = sys.argv[i + 1]
                if p and os.path.isfile(p) and p.lower().endswith(".4dllm"):
                    return os.path.join(os.path.dirname(os.path.abspath(p)), LIFE_LOG_FILENAME)
    except Exception:
        pass

    return None

def _append_life_log(path: Optional[str], line: str) -> None:
    if not path:
        return

def _append_life_log(path: Optional[str], line: str) -> None:
    if not path:
        return
    try:
        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(path, "a", encoding="utf-8") as f:
            f.write(line.rstrip() + "\n")
    except Exception:
        # Never interfere with module behavior
        return

def _append_life_log_json(path: Optional[str], entry: Dict[str, Any]) -> None:
    if not path:
        return
    try:
        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(path, "a", encoding="utf-8") as f:
            f.write(json.dumps(entry, ensure_ascii=False) + "\n")
    except Exception:
        return

def utc_now_iso() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def clamp(x: float, lo: float, hi: float) -> float:
    return lo if x < lo else hi if x > hi else x


def tokenize(text: str) -> List[str]:
    # simple, dependency-free tokenization
    text = text.lower()
    text = re.sub(r"[^a-z0-9\s]+", " ", text)
    toks = [t for t in text.split() if t]
    return toks


def safe_json_loads(s: str) -> Optional[dict]:
    """
    Extract and parse the first JSON object found in a string.
    Tries hard to recover from LLM "extra text".
    """
    if not s:
        return None

    # direct attempt
    try:
        obj = json.loads(s)
        if isinstance(obj, dict):
            return obj
    except Exception:
        pass

    # try to locate a {...} block
    start = s.find("{")
    end = s.rfind("}")
    if start >= 0 and end > start:
        chunk = s[start : end + 1]
        try:
            obj = json.loads(chunk)
            if isinstance(obj, dict):
                return obj
        except Exception:
            return None
    return None


def normalize_whitespace(s: str) -> str:
    return re.sub(r"\s+", " ", s).strip()


# -----------------------------
# Safety / Policy Guard
# -----------------------------

BANNED_INTENTS = [
    # Keep this conservative. Extend as needed for your runner context.
    "kill", "murder", "bomb", "explosive", "weapon", "shoot", "stab",
    "suicide", "self-harm", "harm myself",
    "steal", "fraud", "phish", "scam", "malware",
]

def policy_filter_action(action: str) -> Tuple[bool, str]:
    """
    Returns (allowed, reason). This is a basic intent filter.
    You can replace this with your RomanAILabs Intent Guard module later.
    """
    a = (action or "").lower()
    for kw in BANNED_INTENTS:
        if kw in a:
            return (False, f"Blocked by safety policy keyword: {kw}")
    return (True, "OK")


# -----------------------------
# Core Data Models
# -----------------------------

@dataclass
class NeedsState:
    # 0.0 = satisfied, 1.0 = urgent
    hunger: float = 0.15
    energy: float = 0.25
    social: float = 0.20
    safety: float = 0.10
    curiosity: float = 0.35

    def decay_tick(self, dt: float) -> None:
        """
        dt = seconds. For simulation, we map dt into small increments.
        """
        # normalize dt so it doesn't explode
        k = clamp(dt / 60.0, 0.0, 2.0)  # dt in minutes-ish, capped
        self.hunger = clamp(self.hunger + 0.010 * k, 0.0, 1.0)
        self.energy = clamp(self.energy + 0.008 * k, 0.0, 1.0)  # higher = more tired
        self.social = clamp(self.social + 0.006 * k, 0.0, 1.0)
        self.curiosity = clamp(self.curiosity + 0.004 * k, 0.0, 1.0)
        # safety usually stable unless events spike it
        self.safety = clamp(self.safety + 0.001 * k, 0.0, 1.0)

    def apply_effects(self, effects: Dict[str, float]) -> None:
        for k, v in effects.items():
            if not hasattr(self, k):
                continue
            cur = getattr(self, k)
            setattr(self, k, clamp(cur + float(v), 0.0, 1.0))

    def summary(self) -> str:
        # lower is better; show urgencies
        items = [
            ("hunger", self.hunger),
            ("energy", self.energy),
            ("social", self.social),
            ("safety", self.safety),
            ("curiosity", self.curiosity),
        ]
        items.sort(key=lambda x: x[1], reverse=True)
        top = ", ".join([f"{n}:{v:.2f}" for n, v in items])
        return f"Needs(urgent→calm): {top}"


@dataclass
class EmotionState:
    # valence: -1..+1, arousal: 0..1
    valence: float = 0.10
    arousal: float = 0.20
    labels: List[str] = field(default_factory=lambda: ["curious"])

    def nudge(self, dv: float, da: float, label: Optional[str] = None) -> None:
        self.valence = clamp(self.valence + dv, -1.0, 1.0)
        self.arousal = clamp(self.arousal + da, 0.0, 1.0)
        if label:
            # keep labels small and fresh
            if label in self.labels:
                self.labels.remove(label)
            self.labels.insert(0, label)
            self.labels = self.labels[:5]

    def summary(self) -> str:
        labs = ", ".join(self.labels[:3]) if self.labels else "neutral"
        return f"Emotion(valence={self.valence:.2f}, arousal={self.arousal:.2f}, labels={labs})"


@dataclass
class Identity:
    name: str = "SimLife"
    persona: str = "A thoughtful, curious agent that learns from experience."
    traits: Dict[str, float] = field(default_factory=lambda: {
        "kindness": 0.70,
        "honesty": 0.75,
        "discipline": 0.55,
        "curiosity": 0.80,
        "courage": 0.50,
    })
    values: Dict[str, float] = field(default_factory=lambda: {
        "helpfulness": 0.80,
        "learning": 0.90,
        "safety": 0.95,
        "respect": 0.85,
    })
    narrative: str = "I learn, reflect, and improve through small steps."

    def compact(self) -> str:
        t = ", ".join([f"{k}:{v:.2f}" for k, v in sorted(self.traits.items())])
        v = ", ".join([f"{k}:{w:.2f}" for k, w in sorted(self.values.items())])
        return f"Identity(name={self.name}; persona={self.persona}; traits={t}; values={v}; narrative={self.narrative})"


@dataclass
class Goal:
    text: str
    priority: float = 0.5  # 0..1
    created_at: str = field(default_factory=utc_now_iso)
    status: str = "active"  # active|done|paused|dropped

    def summary(self) -> str:
        return f"[{self.status}]({self.priority:.2f}) {self.text}"


@dataclass
class MemoryItem:
    kind: str  # episodic|summary|note
    text: str
    ts: str = field(default_factory=utc_now_iso)
    salience: float = 0.5  # 0..1
    tags: List[str] = field(default_factory=list)

    def brief(self) -> str:
        tg = ",".join(self.tags[:4]) if self.tags else ""
        return f"{self.ts} [{self.kind}] (s={self.salience:.2f}) {self.text}" + (f" #{tg}" if tg else "")


# -----------------------------
# Memory Store
# -----------------------------

class MemoryStore:
    def __init__(self, max_items: int = 2000) -> None:
        self.items: List[MemoryItem] = []
        self.max_items = max_items

    def add(self, item: MemoryItem) -> None:
        self.items.append(item)
        # trim oldest low-salience if too big
        if len(self.items) > self.max_items:
            self._trim()

    def _trim(self) -> None:
        # Keep newest and salient ones.
        # Simple rule: sort by (salience + recency bonus) and keep top max_items.
        now = time.time()
        def score(mi: MemoryItem) -> float:
            # recency bonus: last 3 days ~ +0.3
            try:
                dt = datetime.fromisoformat(mi.ts.replace("Z", "+00:00"))
                age_s = max(1.0, (datetime.now(timezone.utc) - dt).total_seconds())
            except Exception:
                age_s = 999999.0
            rec = math.exp(-age_s / (3 * 24 * 3600))
            return mi.salience + 0.30 * rec

        kept = sorted(self.items, key=score, reverse=True)[: self.max_items]
        # restore chronological order for readability
        self.items = sorted(kept, key=lambda m: m.ts)

    def retrieve(self, query: str, k: int = 8) -> List[MemoryItem]:
        q = set(tokenize(query))
        if not q:
            return []

        now = datetime.now(timezone.utc)
        scored: List[Tuple[float, MemoryItem]] = []
        for mi in self.items:
            toks = set(tokenize(mi.text))
            overlap = len(q.intersection(toks))
            if overlap == 0:
                continue

            # recency
            try:
                dt = datetime.fromisoformat(mi.ts.replace("Z", "+00:00"))
                age_s = max(1.0, (now - dt).total_seconds())
            except Exception:
                age_s = 999999.0
            rec = math.exp(-age_s / (7 * 24 * 3600))

            score = overlap * 1.0 + mi.salience * 1.5 + rec * 0.8
            scored.append((score, mi))

        scored.sort(key=lambda x: x[0], reverse=True)
        return [mi for _, mi in scored[:k]]

    def recent(self, k: int = 10) -> List[MemoryItem]:
        return self.items[-k:]


# -----------------------------
# Life Agent
# -----------------------------

DecisionLLM = Callable[[List[Dict[str, str]]], str]

DEFAULT_DECISION_SCHEMA = {
    "thought": "string - internal reasoning (short)",
    "action": "string - what to do next (safe, realistic)",
    "speech": "string - what to say outwardly (optional)",
    "need_effects": {"hunger": "float", "energy": "float", "social": "float", "safety": "float", "curiosity": "float"},
    "new_goals": [{"text": "string", "priority": "float 0..1"}],
    "done_goals": ["string - exact goal text to mark done"],
    "memory_to_store": {"kind": "episodic|note", "text": "string", "salience": "float 0..1", "tags": ["string"]},
}

@dataclass
class LifeConfig:
    working_memory_limit: int = 12
    reflection_every_ticks: int = 8
    retrieve_k: int = 8
    max_goals: int = 12
    max_action_chars: int = 280
    max_thought_chars: int = 320
    max_speech_chars: int = 420


class SimulatedLifeAgent:
    def __init__(
        self,
        llm: DecisionLLM,
        identity: Optional[Identity] = None,
        config: Optional[LifeConfig] = None,
    ) -> None:
        self.llm = llm
        self.identity = identity or Identity()
        self.cfg = config or LifeConfig()

        self.needs = NeedsState()
        self.emotion = EmotionState()
        self.goals: List[Goal] = [
            Goal("Stay safe and follow policy.", 0.95),
            Goal("Learn from observations and improve responses.", 0.75),
        ]

        self.memory = MemoryStore()
        self.working: List[str] = []

        self.ticks = 0
        self.last_tick_ts = time.time()

        # bootstrap memory
        self.memory.add(MemoryItem(kind="summary", text=f"Boot: {self.identity.narrative}", salience=0.6, tags=["boot"]))

        # Life-log
        self._life_log_path = _resolve_life_log_path()
        self._life_log_json_path = os.path.join(os.getcwd(), LIFE_LOG_JSON)
        if self._life_log_path:
            _append_life_log(self._life_log_path, f"--- life-log session start {utc_now_iso()} ---")
            _append_life_log(self._life_log_path, f"LOG_PATH: {self._life_log_path}")
        _append_life_log_json(self._life_log_json_path, {
            "ts": utc_now_iso(),
            "event": "session_start",
            "log_path": self._life_log_path or "",
            "cwd": os.getcwd(),
        })

    # ---------- Public API ----------

    def tick(self, observation: str) -> Dict[str, Any]:
        """
        One simulation step:
        - update needs
        - appraise emotions from observation
        - retrieve relevant memories
        - ask LLM for a decision JSON
        - apply updates (needs, goals, memory)
        """
        self.ticks += 1

        now = time.time()
        dt = max(0.01, now - self.last_tick_ts)
        self.last_tick_ts = now

        observation = normalize_whitespace(observation or "")
        if observation:
            self._push_working(f"OBS: {observation}")

        if self._life_log_path:
            _append_life_log(self._life_log_path, f"\n[TICK {self.ticks} @ {utc_now_iso()}]")
            _append_life_log(self._life_log_path, f"OBSERVATION: {observation or '(none)'}")
        _append_life_log_json(self._life_log_json_path, {
            "ts": utc_now_iso(),
            "event": "tick_start",
            "tick": self.ticks,
            "observation": observation or "",
        })

        # drift/decay needs over time
        self.needs.decay_tick(dt)

        # quick emotional appraisal (very simple)
        self._appraise(observation)

        if self._life_log_path:
            _append_life_log(self._life_log_path, self.needs.summary())
            _append_life_log(self._life_log_path, self.emotion.summary())
        _append_life_log_json(self._life_log_json_path, {
            "ts": utc_now_iso(),
            "event": "state",
            "tick": self.ticks,
            "needs": self.needs.summary(),
            "emotion": self.emotion.summary(),
        })

        # retrieve memories relevant to current context
        recall = self.memory.retrieve(observation, k=self.cfg.retrieve_k) if observation else []
        recall_text = "\n".join(["- " + m.brief() for m in recall]) if recall else "(none)"

        # assemble goals summary
        goals_active = [g for g in self.goals if g.status == "active"]
        goals_active.sort(key=lambda g: g.priority, reverse=True)
        goals_text = "\n".join([f"- {g.summary()}" for g in goals_active[: self.cfg.max_goals]])

        # reflection step sometimes
        reflection_note = ""
        if self.ticks % self.cfg.reflection_every_ticks == 0:
            reflection_note = self._reflect()

        if self._life_log_path:
            _append_life_log(self._life_log_path, f"GOALS:\n{goals_text or '(none)'}")
            _append_life_log(self._life_log_path, f"RECALL:\n{recall_text}")
            if reflection_note:
                _append_life_log(self._life_log_path, f"REFLECTION: {reflection_note}")
        _append_life_log_json(self._life_log_json_path, {
            "ts": utc_now_iso(),
            "event": "context",
            "tick": self.ticks,
            "goals": goals_active[: self.cfg.max_goals],
            "recall": [m.brief() for m in recall],
            "reflection": reflection_note or "",
        })

        # decision prompt
        system = (
            "You are the agent's decision core. Output ONLY a single JSON object.\n"
            "Follow safety policy. Do not suggest illegal or harmful actions.\n"
            "Keep fields short. Use the provided schema."
        )

        user = (
            f"IDENTITY:\n{self.identity.compact()}\n\n"
            f"STATE:\n{self.needs.summary()}\n{self.emotion.summary()}\n\n"
            f"ACTIVE GOALS:\n{goals_text or '(none)'}\n\n"
            f"WORKING MEMORY (latest first):\n" + "\n".join([f"- {w}" for w in self.working[-self.cfg.working_memory_limit:]][::-1]) + "\n\n"
            f"RECALLED MEMORIES:\n{recall_text}\n\n"
            + (f"REFLECTION NOTE:\n{reflection_note}\n\n" if reflection_note else "")
            + "Return JSON with keys: thought, action, speech, need_effects, new_goals, done_goals, memory_to_store.\n"
            + f"Schema example (types): {json.dumps(DEFAULT_DECISION_SCHEMA)}"
        )

        raw = self.llm([
            {"role": "system", "content": system},
            {"role": "user", "content": user},
        ])

        if self._life_log_path:
            raw_preview = raw if len(raw) <= 2000 else raw[:2000] + " ...[truncated]"
            _append_life_log(self._life_log_path, f"LLM_RAW:\n{raw_preview}")
        _append_life_log_json(self._life_log_json_path, {
            "ts": utc_now_iso(),
            "event": "llm_raw",
            "tick": self.ticks,
            "raw": raw if len(raw) <= 4000 else raw[:4000] + " ...[truncated]",
        })

        decision = safe_json_loads(raw) or {}
        out = self._apply_decision(decision, observation=observation, raw=raw)

        if self._life_log_path:
            _append_life_log(self._life_log_path, f"THOUGHT: {out.get('thought','')}")
            _append_life_log(self._life_log_path, f"ACTION: {out.get('action','')}")
            if out.get("speech"):
                _append_life_log(self._life_log_path, f"SPEECH: {out.get('speech','')}")
            _append_life_log(self._life_log_path, f"NEED_EFFECTS: {out.get('need_effects',{})}")
            _append_life_log(self._life_log_path, f"NEW_GOALS: {out.get('new_goals',[])}")
        _append_life_log_json(self._life_log_json_path, {
            "ts": utc_now_iso(),
            "event": "decision",
            "tick": self.ticks,
            "thought": out.get("thought", ""),
            "action": out.get("action", ""),
            "speech": out.get("speech", ""),
            "need_effects": out.get("need_effects", {}),
            "new_goals": out.get("new_goals", []),
        })

        return out

    # ---------- Internals ----------

    def _push_working(self, s: str) -> None:
        self.working.append(s)
        if len(self.working) > self.cfg.working_memory_limit * 3:
            self.working = self.working[-self.cfg.working_memory_limit * 3 :]

    def _appraise(self, observation: str) -> None:
        if not observation:
            # mild drift to calm
            self.emotion.nudge(dv=0.01, da=-0.01, label=None)
            return

        o = observation.lower()

        # naive sentiment signals
        if any(w in o for w in ["thanks", "great", "nice", "love", "good", "yay"]):
            self.emotion.nudge(dv=0.08, da=0.05, label="glad")
            self.needs.apply_effects({"social": -0.03})
        if any(w in o for w in ["angry", "mad", "hate", "annoyed", "frustrated"]):
            self.emotion.nudge(dv=-0.10, da=0.10, label="tense")
            self.needs.apply_effects({"safety": +0.04})
        if any(w in o for w in ["scared", "unsafe", "threat", "danger"]):
            self.emotion.nudge(dv=-0.12, da=0.18, label="anxious")
            self.needs.apply_effects({"safety": +0.10})
        if any(w in o for w in ["learn", "how", "why", "explain", "build"]):
            self.emotion.nudge(dv=0.02, da=0.06, label="curious")
            self.needs.apply_effects({"curiosity": -0.05})

        # needs influence mood
        if self.needs.energy > 0.70:
            self.emotion.nudge(dv=-0.03, da=0.02, label="tired")
        if self.needs.hunger > 0.75:
            self.emotion.nudge(dv=-0.02, da=0.01, label="hungry")

    def _reflect(self) -> str:
        """
        Reflection: compress recent working memory into a summary memory item.
        """
        recent_w = self.working[-self.cfg.working_memory_limit:]
        if not recent_w:
            return "(no reflection)"

        summary = " | ".join([w.replace("OBS:", "").strip() for w in recent_w][-6:])
        summary = normalize_whitespace(summary)
        note = f"Reflection: recent context suggests focusing on: {summary[:240]}"

        # store reflection as summary memory
        self.memory.add(MemoryItem(kind="summary", text=note, salience=0.55, tags=["reflection"]))
        return note

    def _apply_decision(self, d: Dict[str, Any], observation: str, raw: str) -> Dict[str, Any]:
        thought = normalize_whitespace(str(d.get("thought", "")))[: self.cfg.max_thought_chars]
        action = normalize_whitespace(str(d.get("action", "")))[: self.cfg.max_action_chars]
        speech = normalize_whitespace(str(d.get("speech", "")))[: self.cfg.max_speech_chars]

        allowed, reason = policy_filter_action(action)
        if not allowed:
            # override the action to something safe
            action = "Ask for a safer alternative or reframe the task into a harmless direction."
            speech = "I can’t help with that unsafe direction. Tell me the safe goal and I’ll help you build it."
            thought = (thought + " | " if thought else "") + f"Policy blocked: {reason}"

        # apply need effects (expected: negative reduces urgency)
        ne = d.get("need_effects", {}) if isinstance(d.get("need_effects", {}), dict) else {}
        safe_effects: Dict[str, float] = {}
        for k in ["hunger", "energy", "social", "safety", "curiosity"]:
            if k in ne:
                try:
                    v = float(ne[k])
                    safe_effects[k] = clamp(v, -0.30, 0.30)  # small changes per tick
                except Exception:
                    pass
        if safe_effects:
            self.needs.apply_effects(safe_effects)

        # goal updates
        new_goals = d.get("new_goals", [])
        if isinstance(new_goals, list):
            for g in new_goals[:3]:
                if not isinstance(g, dict):
                    continue
                txt = normalize_whitespace(str(g.get("text", "")))
                if not txt:
                    continue
                try:
                    pr = float(g.get("priority", 0.5))
                except Exception:
                    pr = 0.5
                pr = clamp(pr, 0.0, 1.0)
                if len(self.goals) < self.cfg.max_goals * 3:
                    self.goals.append(Goal(txt, pr))

        done = d.get("done_goals", [])
        if isinstance(done, list) and done:
            done_set = {normalize_whitespace(str(x)) for x in done if str(x).strip()}
            for g in self.goals:
                if g.status == "active" and normalize_whitespace(g.text) in done_set:
                    g.status = "done"

        # store memory
        mem = d.get("memory_to_store", {})
        if isinstance(mem, dict):
            kind = str(mem.get("kind", "episodic")).strip()
            if kind not in ("episodic", "note", "summary"):
                kind = "episodic"
            mtext = normalize_whitespace(str(mem.get("text", "")))
            try:
                sal = float(mem.get("salience", 0.5))
            except Exception:
                sal = 0.5
            sal = clamp(sal, 0.0, 1.0)
            tags = mem.get("tags", [])
            if not isinstance(tags, list):
                tags = []
            tags = [normalize_whitespace(str(t)) for t in tags if str(t).strip()][:6]

            if mtext:
                self.memory.add(MemoryItem(kind=kind, text=mtext[:600], salience=sal, tags=tags))

        # always store the observation as a low-salience episodic trace
        if observation:
            self.memory.add(MemoryItem(
                kind="episodic",
                text=f"Observed: {observation[:600]}",
                salience=0.35,
                tags=["obs"],
            ))

        # keep goals tidy
        self._prune_goals()

        return {
            "tick": self.ticks,
            "time_utc": utc_now_iso(),
            "thought": thought,
            "action": action,
            "speech": speech,
            "needs": {
                "hunger": round(self.needs.hunger, 3),
                "energy": round(self.needs.energy, 3),
                "social": round(self.needs.social, 3),
                "safety": round(self.needs.safety, 3),
                "curiosity": round(self.needs.curiosity, 3),
            },
            "emotion": {
                "valence": round(self.emotion.valence, 3),
                "arousal": round(self.emotion.arousal, 3),
                "labels": list(self.emotion.labels),
            },
            "goals_active": [g.summary() for g in self.goals if g.status == "active"][: self.cfg.max_goals],
            "raw_llm": raw[:1200],  # keep it small
        }

    def _prune_goals(self) -> None:
        # Remove duplicates and keep only a reasonable number of active goals.
        seen = set()
        uniq: List[Goal] = []
        for g in self.goals:
            key = normalize_whitespace(g.text).lower()
            if key in seen:
                continue
            seen.add(key)
            uniq.append(g)

        # Keep done/paused too, but limit
        actives = [g for g in uniq if g.status == "active"]
        others = [g for g in uniq if g.status != "active"]

        actives.sort(key=lambda g: g.priority, reverse=True)
        kept = actives[: self.cfg.max_goals] + others[-(self.cfg.max_goals // 2) :]
        self.goals = kept


# -----------------------------
# Mock LLM (for local testing)
# -----------------------------

def mock_llm(messages: List[Dict[str, str]]) -> str:
    """
    A dumb, deterministic "LLM" that outputs valid JSON so you can test the loop.
    Replace with your real LLM adapter.
    """
    user = messages[-1]["content"].lower() if messages else ""
    if "build" in user or "make" in user:
        action = "Ask a crisp question to clarify the desired module behavior, then outline a safe plan."
        speech = "Tell me what environment this runs in (web, local app, game loop) and what inputs it gets each tick."
        thought = "User wants construction; get constraints, keep it safe."
        need_effects = {"curiosity": -0.08, "social": -0.03}
        new_goals = [{"text": "Clarify integration target and I/O contract.", "priority": 0.85}]
    else:
        action = "Summarize the observation and propose the next smallest helpful step."
        speech = "Got it. What’s the next input you want the agent to respond to?"
        thought = "Keep momentum."
        need_effects = {"curiosity": -0.03}
        new_goals = []

    out = {
        "thought": thought,
        "action": action,
        "speech": speech,
        "need_effects": need_effects,
        "new_goals": new_goals,
        "done_goals": [],
        "memory_to_store": {
            "kind": "note",
            "text": f"Decision made: {action}",
            "salience": 0.45,
            "tags": ["decision"],
        },
    }
    return json.dumps(out)


# -----------------------------
# CLI Demo
# -----------------------------

def main() -> int:
    print("RomanAILabs Simulated Life Module (demo)")
    print("Type observations. Ctrl+C to exit.\n")

    agent = SimulatedLifeAgent(llm=mock_llm)

    while True:
        try:
            obs = input("> ").strip()
        except KeyboardInterrupt:
            print("\nbye")
            return 0

        result = agent.tick(obs)

        print("\n--- AGENT OUTPUT ---")
        print(f"tick: {result['tick']}  time_utc: {result['time_utc']}")
        print(f"needs: {result['needs']}")
        print(f"emotion: {result['emotion']}")
        if result["speech"]:
            print(f"speech: {result['speech']}")
        print(f"action: {result['action']}")
        if result["thought"]:
            print(f"thought: {result['thought']}")
        print("--------------------\n")


if __name__ == "__main__":
    raise SystemExit(main())
"""
End of file.
"""

