#!/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 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.55 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.002 * 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.02}) # 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. """